Initial commit

This commit is contained in:
Lorenzo Sanesi
2026-03-26 22:57:02 +01:00
parent 18e0e04700
commit 8752acd2c4
44 changed files with 10272 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
/.idea/
/.vscode/
/node_modules/
/.env
/.angular/
/frontend-angular/.angular/
/backend-node/node_modules/
/backend-node/.env
+400
View File
@@ -0,0 +1,400 @@
# Changelog - Frontend Angular
Tracciamento modifiche specifiche del frontend Angular.
---
## ✅ Completato
### Setup Iniziale
- [x] Inizializzazione progetto Angular 20
- [x] Configurazione Vite come bundler
- [x] Configurazione Tailwind CSS
- [x] Setup TypeScript 5.8
- [x] Configurazione RxJS 7.8
### Architettura & Struttura
#### Servizi
- [x] **ApiService** (`services/api.service.ts`)
- Comunicazione HTTP con backend
- Base URL configurabile
- Metodi per tutte le entità (users, groups, bets, friends)
- Gestione errori HTTP
- [x] **DataService** (`services/data.service.ts`)
- State management con RxJS signals
- Cache dati utente corrente
- Cache amici, gruppi, scommesse
- Metodi per refresh dati
- Observable streams per reactive updates
#### Modelli TypeScript
- [x] **Bet Model** (`models/bet.model.ts`)
- Interface Bet completa
- Enum BetStatus (ACTIVE, AWAITING_VALIDATION, COMPLETED)
- Enum ParticipantRole (PARTICIPANT, OBSERVER)
- Type BetParticipant
- [x] **User Model** (inline nei servizi per ora)
- [x] **Group Model** (inline nei servizi per ora)
### Componenti Implementati
#### App Component
- [x] **AppComponent** (`app.component.ts`)
- Layout principale con navigation bar
- Gestione routing tra viste con signal
- Bottom navigation responsive
- 5 sezioni: Home, Scopri, Crea Scommessa, Gruppi, Profilo
- FAB centrale per creazione scommessa
#### Home Section
- [x] **HomeComponent** (`components/home/home.component.ts`)
- Dashboard principale
- Visualizzazione scommesse attive/passate
- Toggle tra filtri temporali e per gruppo
- Integrazione con sotto-componenti
- [x] **BetsByTimeComponent** (`components/home/bets-by-time/bets-by-time.component.ts`)
- Filtro scommesse per periodo temporale
- Periodi: Oggi, Questa Settimana, Questo Mese
- Indicatore visuale periodo selezionato
- Fetch dati da API con filtri
- [x] **BetsByGroupComponent** (`components/home/bets-by-group/bets-by-group.component.ts`)
- Filtro scommesse per gruppo
- Selezione gruppo da dropdown
- Opzione "Tutti i Gruppi"
- Fetch dati da API per gruppo specifico
#### Discover Section
- [x] **DiscoverComponent** (`components/discover/discover.component.ts`)
- Esplorazione scommesse pubbliche
- Lista scommesse scopribili
- Possibilità di entrare come observer
#### Create Bet Section
- [x] **CreateBetComponent** (`components/create-bet/create-bet.component.ts`)
- Form creazione nuova scommessa
- Campi: titolo, descrizione, posta in gioco, data fine, gruppo, visibilità
- Validazione form
- Event emitter per chiusura
- Integrazione con API
#### Groups Section
- [x] **GroupsComponent** (`components/groups/groups.component.ts`)
- Lista tutti i gruppi dell'utente
- Navigazione verso dettaglio gruppo
- Creazione nuovo gruppo
- Badge contatore membri
- [x] **GroupDetailComponent** (`components/groups/group-detail/group-detail.component.ts`)
- Dettaglio singolo gruppo
- Lista membri con avatar
- Aggiunta membri al gruppo
- Rimozione membri (se admin)
- Eliminazione gruppo
#### Profile Section
- [x] **ProfileComponent** (`components/profile/profile.component.ts`)
- Visualizzazione profilo utente
- Avatar, nome, email, punteggio
- Statistiche personali
- Lista richieste di amicizia pending
- Bottone modifica profilo
- [x] **EditProfileComponent** (`components/profile/edit-profile/edit-profile.component.ts`)
- Form modifica dati profilo
- Campi: nome, email, avatar URL
- Validazione input
- Save/Cancel actions
#### Shared Components
- [x] **BetCardComponent** (`components/bet-card/bet-card.component.ts`)
- Card riutilizzabile per visualizzare scommesse
- Badge stato scommessa (Attiva, In Attesa, Completata)
- Informazioni partecipanti con avatar
- Posta in gioco
- Date inizio/fine
- Responsive design
### UI/UX
#### Design System
- [x] Tema dark con palette:
- Background: slate-900
- Cards: slate-800
- Accenti: cyan-400/cyan-500
- Testo: white/slate-300
- [x] Tailwind CSS utilities
- [x] Responsive layout mobile-first
- [x] Icone SVG per navigation
#### Navigation
- [x] Bottom navigation bar sticky
- [x] 5 tab con icone e label
- [x] Indicatore visuale tab attiva (color cyan)
- [x] FAB centrale per azione primaria (Crea Scommessa)
- [x] Hover states e transitions
#### Interazioni
- [x] Click handlers per navigazione
- [x] Form submissions
- [x] Toggle filters
- [x] Dropdown selects
- [x] Event emitters tra parent/child components
### Configurazione
- [x] **package.json** - Dipendenze e scripts
- [x] **angular.json** - Configurazione Angular CLI
- [x] **tsconfig.json** - TypeScript configuration
- [x] **tailwind.config.js** (implicito con Tailwind)
- [x] **vite.config** (gestito da Angular)
---
## 📋 Da Fare
### Funzionalità Core Mancanti
#### Scommesse
- [ ] Visualizzazione dettaglio scommessa completo
- Modal o pagina dedicata
- Cronologia eventi
- Commenti (se implementati)
- [ ] Modifica scommessa esistente
- [ ] Eliminazione scommessa con conferma
- [ ] Dichiarazione vincitore
- [ ] Sistema di votazione vincitore
- [ ] Join scommessa pubblica come observer
- [ ] Leave scommessa
- [ ] Invita amici a scommessa
#### Profilo & Amici
- [ ] Ricerca utenti
- [ ] Invio richiesta di amicizia (UI)
- [ ] Accetta/rifiuta richiesta amicizia (UI)
- [ ] Lista amici visualizzata nel profilo
- [ ] Visualizzazione profilo altri utenti
- [ ] Statistiche avanzate profilo
- Win rate
- Streak
- Classifica generale
- [ ] Notifiche
#### Gruppi
- [ ] Modifica info gruppo
- [ ] Invita utenti al gruppo
- [ ] Gestione permessi gruppo (admin/membro)
- [ ] Statistiche gruppo
- [ ] Ricerca membri
- [ ] Leave gruppo
### UX Improvements
#### Stati & Feedback
- [ ] Loading states per tutti i componenti
- Skeleton loaders
- Spinners
- Progress indicators
- [ ] Error handling UI
- Toast notifications
- Error messages inline
- Retry mechanisms
- [ ] Empty states
- Nessuna scommessa
- Nessun amico
- Nessun gruppo
- [ ] Success confirmations
- Toast per azioni completate
- Feedback visuale
#### Animazioni & Transizioni
- [ ] Animazioni transizione tra viste
- [ ] Micro-interazioni su bottoni
- [ ] Slide/fade effects per modal
- [ ] Loading animations
- [ ] Skeleton loading per cards
#### Interattività
- [ ] Pull-to-refresh
- [ ] Infinite scroll per liste lunghe
- [ ] Swipe gestures (elimina, archivia)
- [ ] Drag and drop (riordino)
- [ ] Conferme azioni critiche
- Modal di conferma eliminazione
- Double-tap protection
#### Filtri & Ricerca
- [ ] Ricerca scommesse per titolo
- [ ] Filtri avanzati scommesse
- Per stato
- Per partecipanti
- Per data
- Per posta in gioco
- [ ] Ordinamento scommesse
- Per data
- Per popolarità
- Per punteggio
- [ ] Salvataggio preferenze filtri
### Componenti da Creare
- [ ] **BetDetailModal/Page** - Dettaglio completo scommessa
- [ ] **ToastNotification** - Notifiche toast
- [ ] **ConfirmDialog** - Dialog conferma generica
- [ ] **SearchBar** - Barra di ricerca riutilizzabile
- [ ] **UserCard** - Card utente riutilizzabile
- [ ] **EmptyState** - Componente empty state generico
- [ ] **LoadingSpinner** - Spinner riutilizzabile
- [ ] **AvatarUpload** - Upload avatar con preview
- [ ] **DatePicker** - Selettore date customizzato
- [ ] **FriendsList** - Lista amici con azioni
- [ ] **NotificationsList** - Lista notifiche
- [ ] **StatsCard** - Card statistiche riutilizzabile
### Routing & Navigation
- [ ] Implementare Angular Router
- Route per ogni vista
- Route parametriche (es. /group/:id)
- Query parameters per filtri
- Route guards
- [ ] Deep linking
- [ ] Browser back button handling
- [ ] Route transition animations
### State Management
- [ ] Migliorare DataService
- Gestione stato più robusta
- Cache invalidation strategy
- Optimistic updates
- Rollback on error
- [ ] Considerare NgRx o Signals API avanzate
- [ ] Persistent state (localStorage)
- [ ] Sync state across tabs
### Forms & Validation
- [ ] Reactive forms everywhere
- [ ] Validatori custom
- [ ] Error messages user-friendly
- [ ] Form dirty/pristine tracking
- [ ] Unsaved changes warning
- [ ] Auto-save drafts
### Testing
- [ ] Unit test per componenti
- [ ] Unit test per servizi
- [ ] Integration test
- [ ] E2E test con Cypress/Playwright
- [ ] Coverage almeno 80%
- [ ] Visual regression testing
### Performance
- [ ] Lazy loading components
- [ ] Virtual scrolling per liste lunghe
- [ ] Image lazy loading
- [ ] OnPush change detection
- [ ] Memoization expensive computations
- [ ] Bundle size optimization
- [ ] Tree shaking
- [ ] Code splitting
### Accessibilità (a11y)
- [ ] ARIA labels
- [ ] Keyboard navigation
- [ ] Focus management
- [ ] Screen reader support
- [ ] Contrast ratios WCAG AA
- [ ] Semantic HTML
### PWA Features
- [ ] Service Worker
- [ ] Offline mode
- [ ] App manifest
- [ ] Install prompt
- [ ] Push notifications
- [ ] Background sync
### Internazionalizzazione
- [ ] i18n setup (Angular i18n)
- [ ] Traduzioni IT/EN
- [ ] Date/number formatting locale-aware
- [ ] RTL support (se necessario)
### DevOps & Build
- [ ] Environment files (dev, staging, prod)
- [ ] Build optimization per production
- [ ] Source maps
- [ ] Linting (ESLint)
- [ ] Formatting (Prettier)
- [ ] Pre-commit hooks (Husky)
- [ ] CI/CD pipeline
### Monitoring & Analytics
- [ ] Error tracking (Sentry)
- [ ] Analytics (Google Analytics / Plausible)
- [ ] Performance monitoring
- [ ] User behavior tracking
---
## 🐛 Bug Conosciuti
- [ ] Nessun handling errori API in caso di network failure
- [ ] Refresh dati non automatico (solo manuale al mount)
- [ ] Possibili race conditions su multiple API calls
---
## 🔧 Miglioramenti Tecnici Proposti
- [ ] Separare models in file dedicati
- [ ] Creare barrel exports per organizzazione
- [ ] Introdurre design patterns (Repository, Facade)
- [ ] Standardizzare naming conventions
- [ ] Constants file per magic strings
- [ ] Utility functions file
- [ ] Typed HTTP responses
- [ ] Error types customizzati
- [ ] Logging service
- [ ] Configuration service
---
## Note Tecniche
### Dipendenze Principali
- **@angular/core**: ^20.3.0
- **@angular/common**: ^20.3.0
- **@angular/platform-browser**: ^20.3.0
- **rxjs**: ^7.8.2
- **tailwindcss**: latest
- **typescript**: ~5.8.2
- **vite**: ^6.2.0
### Scripts NPM
```bash
npm run dev # Avvia dev server (porta 4200)
npm run build # Build production
npm run preview # Preview build production
```
### Porte
- Dev server: `4200`
- API backend: `3000`
### Browser Target
- Modern browsers (Chrome, Firefox, Safari, Edge)
- ES2022+ support required
+20
View File
@@ -0,0 +1,20 @@
# Frontend Angular Dockerfile
FROM node:20-alpine
# Set working directory
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci
# Copy application code
COPY . .
# Expose port
EXPOSE 4200
# Start development server
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
+20
View File
@@ -0,0 +1,20 @@
<div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
</div>
# Run and deploy your AI Studio app
This contains everything you need to run your app locally.
View your app in AI Studio: https://ai.studio/apps/drive/1LXZWZLxW6p8pFAQ_ECq5pcD0utESimfc
## Run Locally
**Prerequisites:** Node.js
1. Install dependencies:
`npm install`
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
3. Run the app:
`npm run dev`
+52
View File
@@ -0,0 +1,52 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "",
"projects": {
"app": {
"projectType": "application",
"root": "",
"sourceRoot": "./",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"outputPath": {
"base": "./dist",
"browser": "."
},
"browser": "index.tsx",
"tsConfig": "tsconfig.json"
},
"configurations": {
"production": {
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular/build:dev-server",
"options": {
"port": 3000
},
"configurations": {
"production": {
"buildTarget": "app:build:production"
},
"development": {
"buildTarget": "app:build:development"
}
},
"defaultConfiguration": "development"
}
}
}
}
}
+63
View File
@@ -0,0 +1,63 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="utf-8" />
<title>Amici Scommesse</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
<script src="https://cdn.tailwindcss.com"></script>
<style>
/* Custom scrollbar for a better look */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: #1e293b; /* slate-800 */
}
::-webkit-scrollbar-thumb {
background: #475569; /* slate-600 */
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #64748b; /* slate-500 */
}
/* For filter chips horizontal scroll */
.hide-scrollbar::-webkit-scrollbar {
display: none;
}
.hide-scrollbar {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
/* Simple fade-in animation */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-fade-in {
animation: fadeIn 0.3s ease-out forwards;
}
</style>
<script type="importmap">
{
"imports": {
"rxjs": "https://aistudiocdn.com/rxjs@^7.8.2?conditions=es2015",
"rxjs/operators": "https://aistudiocdn.com/rxjs@^7.8.2/operators?conditions=es2015",
"rxjs/ajax": "https://aistudiocdn.com/rxjs@^7.8.2/ajax?conditions=es2015",
"rxjs/webSocket": "https://aistudiocdn.com/rxjs@^7.8.2/webSocket?conditions=es2015",
"rxjs/testing": "https://aistudiocdn.com/rxjs@^7.8.2/testing?conditions=es2015",
"rxjs/fetch": "https://aistudiocdn.com/rxjs@^7.8.2/fetch?conditions=es2015",
"@angular/compiler": "https://next.esm.sh/@angular/compiler@^20.3.4?external=rxjs",
"@angular/platform-browser": "https://next.esm.sh/@angular/platform-browser@^20.3.4?external=rxjs",
"@angular/common": "https://next.esm.sh/@angular/common@^20.3.4?external=rxjs",
"@angular/core": "https://next.esm.sh/@angular/core@^20.3.4?external=rxjs",
"@angular/common/http": "https://next.esm.sh/@angular/common@^20.3.4/http?external=rxjs"
}
}
</script>
</head>
<body class="bg-slate-900 text-slate-100 antialiased">
<app-root></app-root>
</body>
</html>
+15
View File
@@ -0,0 +1,15 @@
import '@angular/compiler';
import { bootstrapApplication } from '@angular/platform-browser';
import { provideZonelessChangeDetection } from '@angular/core';
import { provideHttpClient, withFetch } from '@angular/common/http';
import { AppComponent } from './src/app.component';
bootstrapApplication(AppComponent, {
providers: [
provideZonelessChangeDetection(),
provideHttpClient(withFetch()),
],
}).catch(err => console.error(err));
// AI Studio always uses an `index.tsx` file for all project types.
+5
View File
@@ -0,0 +1,5 @@
{
"name": "Amici Scommesse",
"description": "Un'app per creare e gestire scommesse tra amici. Tieni traccia delle scommesse, crea gruppi, e partecipa a sfide pubbliche per guadagnare punti gloria.",
"requestFramePermissions": []
}
+7540
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "amici-scommesse",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "ng serve",
"build": "ng build",
"preview": "ng serve --configuration=production"
},
"dependencies": {
"rxjs": "^7.8.2",
"@angular/compiler": "^20.3.0",
"@angular/platform-browser": "^20.3.0",
"@angular/common": "^20.3.0",
"@angular/core": "^20.3.0",
"@angular/build": "^20.3.0",
"@angular/cli": "^20.3.0",
"@angular/compiler-cli": "^20.3.0",
"tailwindcss": "latest"
},
"devDependencies": {
"@types/node": "^22.14.0",
"typescript": "~5.8.2",
"vite": "^6.2.0"
}
}
+55
View File
@@ -0,0 +1,55 @@
<div class="h-screen w-screen flex flex-col">
<main class="flex-grow overflow-hidden">
@switch (currentView()) {
@case ('home') { <app-home></app-home> }
@case ('discover') { <app-discover></app-discover> }
@case ('create-bet') { <app-create-bet (close)="onBetActionClosed()"></app-create-bet> }
@case ('groups') { <app-groups></app-groups> }
@case ('profile') { <app-profile></app-profile> }
}
</main>
<!-- Navigation -->
<nav class="flex-shrink-0 bg-slate-800 border-t border-slate-700">
<div class="flex justify-around items-center h-20">
<!-- Nav Item: Home -->
<button (click)="setView('home')" [class]="'flex flex-col items-center justify-center w-full transition-colors ' + (currentView() === 'home' ? 'text-cyan-400' : 'text-slate-400 hover:text-white')">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
<span class="text-xs mt-1">Home</span>
</button>
<!-- Nav Item: Discover -->
<button (click)="setView('discover')" [class]="'flex flex-col items-center justify-center w-full transition-colors ' + (currentView() === 'discover' ? 'text-cyan-400' : 'text-slate-400 hover:text-white')">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M4 2a2 2 0 00-2 2v12a2 2 0 002 2h12a2 2 0 002-2V4a2 2 0 00-2-2H4zm.293 8.293a1 1 0 011.414 0L8 12.586l2.293-2.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
<span class="text-xs mt-1">Scopri</span>
</button>
<!-- Nav Item: Add Bet (Central) -->
<button (click)="setView('create-bet')" class="w-20 h-20 -mt-10 bg-cyan-500 rounded-full flex items-center justify-center text-white shadow-lg hover:bg-cyan-600 transition-transform transform hover:scale-110">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
</svg>
</button>
<!-- Nav Item: Groups -->
<button (click)="setView('groups')" [class]="'flex flex-col items-center justify-center w-full transition-colors ' + (currentView() === 'groups' ? 'text-cyan-400' : 'text-slate-400 hover:text-white')">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.653-.124-1.282-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.653.124-1.282.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
<span class="text-xs mt-1">Gruppi</span>
</button>
<!-- Nav Item: Profile -->
<button (click)="setView('profile')" [class]="'flex flex-col items-center justify-center w-full transition-colors ' + (currentView() === 'profile' ? 'text-cyan-400' : 'text-slate-400 hover:text-white')">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
<span class="text-xs mt-1">Profilo</span>
</button>
</div>
</nav>
</div>
+41
View File
@@ -0,0 +1,41 @@
import { Component, ChangeDetectionStrategy, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { HomeComponent } from './features/home/home.component';
import { DiscoverComponent } from './features/discover/discover.component';
import { GroupsComponent } from './features/groups/groups.component';
import { ProfileComponent } from './features/profile/profile.component';
import { CreateBetComponent } from './features/bets/create-bet.component';
type View = 'home' | 'discover' | 'groups' | 'profile' | 'create-bet';
@Component({
selector: 'app-root',
standalone: true,
imports: [
CommonModule,
HomeComponent,
DiscoverComponent,
GroupsComponent,
ProfileComponent,
CreateBetComponent,
],
templateUrl: './app.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppComponent {
currentView = signal<View>('home');
private previousView = signal<View>('home');
setView(view: View) {
if (view === 'create-bet') {
this.previousView.set(this.currentView());
this.currentView.set('create-bet');
} else {
this.currentView.set(view);
}
}
onBetActionClosed() {
this.currentView.set(this.previousView());
}
}
@@ -0,0 +1,36 @@
import { User } from './user.model';
import { Group } from './group.model';
import { Bet } from './bet.model';
import { FriendRequest } from './friend-request.model';
export interface SuccessResponse<T = any> {
success: boolean;
message?: string;
data: T;
timestamp: string;
}
export interface ErrorDetail {
field?: string;
message: string;
}
export interface ErrorResponse {
success: boolean;
message: string;
errors?: ErrorDetail[];
timestamp: string;
}
export interface InitialData {
currentUser: User;
users: User[];
friends: User[];
friendRequests: FriendRequest[];
receivedFriendRequests?: FriendRequest[]; // Legacy or convenience
sentFriendRequests?: FriendRequest[]; // Legacy or convenience
groups: Group[];
userBets: Bet[];
bets?: Bet[]; // Compatibility with data.service
publicBets: Bet[];
}
@@ -0,0 +1,50 @@
import { User } from './user.model';
export enum BetRole {
Participant = 'PARTICIPANT',
Observer = 'OBSERVER',
}
export interface BetParticipant {
user: User | string; // Can be user object or ID depending on context
role: BetRole;
}
export enum BetStatus {
Active = 'ACTIVE',
AwaitingValidation = 'AWAITING_VALIDATION',
Completed = 'COMPLETED',
Expired = 'EXPIRED',
}
export interface Bet {
id: string;
title: string;
description?: string | null;
stake: string;
participants: BetParticipant[];
status: BetStatus;
startDate: Date;
endDate: Date | null;
isPublic: boolean;
groupId?: string | null;
winner?: User | string | null;
}
export interface CreateBetRequest {
title: string;
description?: string;
stake: string;
participants: { user: string; role: string }[];
isPublic?: boolean;
groupId?: string | null;
}
export interface UpdateBetRequest {
title?: string;
description?: string;
stake?: string;
status?: BetStatus;
endDate?: string;
winner?: string | null;
}
@@ -0,0 +1,22 @@
import { User } from './user.model';
export enum FriendRequestStatus {
Pending = 'PENDING',
Accepted = 'ACCEPTED',
Rejected = 'REJECTED',
}
export interface FriendRequest {
id: string;
fromUserId: string;
toUserId: string;
status: FriendRequestStatus;
fromUser?: User;
toUser?: User;
createdAt: string;
updatedAt?: string;
}
export interface SendFriendRequestRequest {
identifier: string | number;
}
@@ -0,0 +1,20 @@
import { User } from './user.model';
export interface Group {
id: string;
name: string;
members: User[];
}
export interface CreateGroupRequest {
name: string;
members: string[]; // User IDs
}
export interface UpdateGroupRequest {
name?: string;
}
export interface AddMembersRequest {
userIds: string[];
}
@@ -0,0 +1,13 @@
export interface User {
id: string;
name: string;
email: string;
avatarUrl?: string;
score?: number;
}
export interface UpdateUserRequest {
name?: string;
email?: string;
avatarUrl?: string | null;
}
@@ -0,0 +1,53 @@
import { inject, Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Bet, CreateBetRequest, UpdateBetRequest } from '../../models/bet.model';
import { SuccessResponse } from '../../models/api-responses.model';
import { map } from 'rxjs/operators';
import { toDate } from '../../utils/type-guards';
function mapBet(bet: any): Bet {
return {
...bet,
startDate: toDate(bet.startDate)!,
endDate: toDate(bet.endDate)
};
}
@Injectable({
providedIn: 'root'
})
export class BetApiService {
private http = inject(HttpClient);
private readonly apiUrl = 'http://localhost:3000/api/v1/bets';
getBets(): Observable<Bet[]> {
return this.http.get<SuccessResponse<Bet[]>>(this.apiUrl).pipe(
map(res => res.data.map(mapBet))
);
}
getBet(id: string): Observable<Bet> {
return this.http.get<SuccessResponse<Bet>>(`${this.apiUrl}/${id}`).pipe(
map(res => mapBet(res.data))
);
}
createBet(bet: CreateBetRequest): Observable<Bet> {
return this.http.post<SuccessResponse<Bet>>(this.apiUrl, bet).pipe(
map(res => mapBet(res.data))
);
}
updateBet(id: string, bet: UpdateBetRequest): Observable<Bet> {
return this.http.put<SuccessResponse<Bet>>(`${this.apiUrl}/${id}`, bet).pipe(
map(res => mapBet(res.data))
);
}
deleteBet(id: string): Observable<void> {
return this.http.delete<SuccessResponse<null>>(`${this.apiUrl}/${id}`).pipe(
map(() => undefined)
);
}
}
@@ -0,0 +1,35 @@
import { inject, Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { FriendRequest, SendFriendRequestRequest } from '../../models/friend-request.model';
import { User } from '../../models/user.model';
import { SuccessResponse } from '../../models/api-responses.model';
import { map } from 'rxjs/operators';
@Injectable({
providedIn: 'root'
})
export class FriendRequestApiService {
private http = inject(HttpClient);
private readonly apiUrl = 'http://localhost:3000/api/v1/friend-requests';
sendRequest(request: SendFriendRequestRequest): Observable<FriendRequest> {
return this.http.post<SuccessResponse<FriendRequest>>(this.apiUrl, request).pipe(
map(res => res.data)
);
}
acceptRequest(id: string): Observable<{ message: string; newFriend: User }> {
return this.http.post<SuccessResponse<{ message: string; newFriend: User }>>(`${this.apiUrl}/${id}/accept`, {}).pipe(
map(res => res.data)
);
}
cancelRequest(id: string): Observable<SuccessResponse<null>> {
return this.http.delete<SuccessResponse<null>>(`${this.apiUrl}/${id}`);
}
declineRequest(id: string): Observable<SuccessResponse<null>> {
return this.http.delete<SuccessResponse<null>>(`${this.apiUrl}/${id}`);
}
}
@@ -0,0 +1,56 @@
import { inject, Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Group, CreateGroupRequest, UpdateGroupRequest, AddMembersRequest } from '../../models/group.model';
import { SuccessResponse } from '../../models/api-responses.model';
import { map } from 'rxjs/operators';
@Injectable({
providedIn: 'root'
})
export class GroupApiService {
private http = inject(HttpClient);
private readonly apiUrl = 'http://localhost:3000/api/v1/groups';
getGroups(): Observable<Group[]> {
return this.http.get<SuccessResponse<Group[]>>(this.apiUrl).pipe(
map(res => res.data)
);
}
getGroup(id: string): Observable<Group> {
return this.http.get<SuccessResponse<Group>>(`${this.apiUrl}/${id}`).pipe(
map(res => res.data)
);
}
createGroup(group: CreateGroupRequest): Observable<Group> {
return this.http.post<SuccessResponse<Group>>(this.apiUrl, group).pipe(
map(res => res.data)
);
}
updateGroup(id: string, group: UpdateGroupRequest): Observable<Group> {
return this.http.put<SuccessResponse<Group>>(`${this.apiUrl}/${id}`, group).pipe(
map(res => res.data)
);
}
deleteGroup(id: string): Observable<void> {
return this.http.delete<SuccessResponse<null>>(`${this.apiUrl}/${id}`).pipe(
map(() => undefined)
);
}
addMembers(groupId: string, members: AddMembersRequest): Observable<Group> {
return this.http.put<SuccessResponse<Group>>(`${this.apiUrl}/${groupId}/members`, members).pipe(
map(res => res.data)
);
}
removeMember(groupId: string, userId: string): Observable<Group> {
return this.http.delete<SuccessResponse<Group>>(`${this.apiUrl}/${groupId}/members/${userId}`).pipe(
map(res => res.data)
);
}
}
@@ -0,0 +1,44 @@
import { inject, Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { User, UpdateUserRequest } from '../../models/user.model';
import { SuccessResponse, InitialData } from '../../models/api-responses.model';
import { map } from 'rxjs/operators';
import { toDate } from '../../utils/type-guards';
function mapInitialData(data: InitialData): InitialData {
const mapBet = (bet: any) => ({
...bet,
startDate: toDate(bet.startDate)!,
endDate: toDate(bet.endDate)
});
return {
...data,
userBets: (data.userBets || []).map(mapBet),
publicBets: (data.publicBets || []).map(mapBet),
bets: (data.bets || []).map(mapBet)
};
}
@Injectable({
providedIn: 'root'
})
export class UserApiService {
private http = inject(HttpClient);
private readonly apiUrl = 'http://localhost:3000/api/v1';
getInitialData(): Observable<InitialData> {
return this.http.get<SuccessResponse<InitialData>>(`${this.apiUrl}/initial-data`).pipe(
map(res => mapInitialData(res.data))
);
}
updateCurrentUser(user: UpdateUserRequest): Observable<User> {
return this.http.put<SuccessResponse<User>>(`${this.apiUrl}/users/current`, user).pipe(
map(res => res.data)
);
}
// Potential future: getProfile(id), etc.
}
@@ -0,0 +1,186 @@
import { Injectable, signal, computed, inject } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { Bet, BetParticipant, BetStatus } from '../models/bet.model';
import { User } from '../models/user.model';
import { Group } from '../models/group.model';
import { FriendRequest, FriendRequestStatus } from '../models/friend-request.model';
import { BetApiService } from './api/bet-api.service';
import { GroupApiService } from './api/group-api.service';
import { UserApiService } from './api/user-api.service';
import { FriendRequestApiService } from './api/friend-request-api.service';
@Injectable({
providedIn: 'root'
})
export class DataService {
private betApiService = inject(BetApiService);
private groupApiService = inject(GroupApiService);
private userApiService = inject(UserApiService);
private friendRequestApiService = inject(FriendRequestApiService);
private readonly _users = signal<User[]>([]);
private readonly _bets = signal<Bet[]>([]);
private readonly _groups = signal<Group[]>([]);
private readonly _friends = signal<User[]>([]);
private readonly _friendRequests = signal<FriendRequest[]>([]);
private readonly _publicBets = signal<Bet[]>([]);
readonly currentUser = signal<User | null>(null);
readonly friends = computed(() => this._friends());
readonly receivedFriendRequests = computed(() => {
return this._friendRequests().filter(req => req.toUser?.id === this.currentUser()?.id && req.status === FriendRequestStatus.Pending);
});
readonly sentFriendRequests = computed(() => {
return this._friendRequests().filter(req => req.fromUser?.id === this.currentUser()?.id && req.status === FriendRequestStatus.Pending);
});
readonly userGroups = computed(() => this._groups());
readonly getActiveBets = computed(() => {
return this._bets().filter(bet =>
(bet.status === BetStatus.Active || bet.status === BetStatus.AwaitingValidation)
);
});
readonly getPublicBets = computed(() => this._publicBets());
constructor() {
this._initializeData();
}
private _initializeData() {
this.userApiService.getInitialData().subscribe({
next: data => {
this.currentUser.set(data.currentUser);
this._users.set(data.users);
this._friends.set(data.friends);
// Combine received and sent requests if they come separated, or use the array if unified
this._friendRequests.set(data.friendRequests || []);
this._bets.set(data.userBets || data.bets || []);
this._publicBets.set(data.publicBets);
this._groups.set(data.groups);
},
error: err => console.error("Failed to load initial data", err)
});
}
getGroupById(id: string): Group | undefined {
return this._groups().find(g => g.id === id);
}
getBetsByGroupId(groupId: string): Bet[] {
return this._bets().filter(b => b.groupId === groupId);
}
async sendFriendRequest(identifier: string): Promise<{ success: boolean, message: string }> {
try {
const newRequest = await firstValueFrom(this.friendRequestApiService.sendRequest({ identifier }));
this._friendRequests.update(reqs => [...reqs, newRequest]);
return { success: true, message: 'Richiesta di amicizia inviata!' };
} catch (error: any) {
return { success: false, message: error.error?.message || 'Errore imprevisto.' };
}
}
async cancelFriendRequest(requestId: string) {
try {
await firstValueFrom(this.friendRequestApiService.cancelRequest(requestId));
this._friendRequests.update(reqs => reqs.filter(r => r.id !== requestId));
} catch (error) {
console.error("Failed to cancel friend request", error);
}
}
async acceptFriendRequest(request: FriendRequest) {
try {
const { newFriend } = await firstValueFrom(this.friendRequestApiService.acceptRequest(request.id));
this._friends.update(f => [...f, newFriend]);
this._friendRequests.update(reqs => reqs.filter(r => r.id !== request.id));
} catch (error) {
console.error("Failed to accept friend request", error);
}
}
async declineFriendRequest(requestId: string) {
try {
await firstValueFrom(this.friendRequestApiService.declineRequest(requestId));
this._friendRequests.update(reqs => reqs.filter(r => r.id !== requestId));
} catch (error) {
console.error("Failed to decline friend request", error);
}
}
async addGroup(name: string, members: User[]) {
try {
const memberIds = members.map(m => m.id);
const newGroup = await firstValueFrom(this.groupApiService.createGroup({ name, members: memberIds }));
this._groups.update(groups => [...groups, newGroup]);
} catch (error) {
console.error("Failed to add group", error);
}
}
async removeUserFromGroup(groupId: string, userId: string) {
try {
const updatedGroup = await firstValueFrom(this.groupApiService.removeMember(groupId, userId));
this._groups.update(groups => groups.map(g => g.id === groupId ? updatedGroup : g));
} catch (error) {
console.error("Failed to remove user from group", error);
}
}
async addUsersToGroup(groupId: string, users: User[]) {
try {
const userIds = users.map(u => u.id);
const updatedGroup = await firstValueFrom(this.groupApiService.addMembers(groupId, { userIds }));
this._groups.update(groups => groups.map(g => g.id === groupId ? updatedGroup : g));
} catch (error) {
console.error("Failed to add users to group", error);
}
}
async updateCurrentUser(data: { name: string, email: string }) {
try {
const updatedUser = await firstValueFrom(this.userApiService.updateCurrentUser(data));
this.currentUser.set(updatedUser);
this._users.update(users => users.map(u => u.id === updatedUser.id ? updatedUser : u));
} catch (error) {
console.error("Failed to update user", error);
}
}
async addBet(betData: {
title: string;
description: string;
stake: string;
isPublic: boolean;
endDate: Date | null;
groupId?: string;
participants: BetParticipant[];
}) {
const currentUser = this.currentUser();
if (!currentUser) return;
const participantPayload = betData.participants.map(p => ({
user: typeof p.user === 'string' ? p.user : p.user.id,
role: p.role
}));
try {
const newBet = await firstValueFrom(this.betApiService.createBet({
title: betData.title,
description: betData.description,
stake: betData.stake,
isPublic: betData.isPublic,
groupId: betData.groupId,
participants: participantPayload
}));
this._bets.update(bets => [newBet, ...bets]);
} catch (error) {
console.error("Failed to add bet", error);
}
}
}
@@ -0,0 +1,33 @@
import { User } from '../models/user.model';
/**
* Type guard to check if an object is a User.
*/
export function isUser(obj: any): obj is User {
return obj && typeof obj === 'object' && typeof obj.id === 'string' && typeof obj.name === 'string';
}
/**
* Extracts the ID from a User object or returns the string if it's already an ID.
*/
export function getUserId(user: User | string | undefined | null): string {
if (!user) return '';
return typeof user === 'string' ? user : user.id;
}
/**
* Extracts the name from a User object or returns a placeholder if it's a string (ID).
*/
export function getUserName(user: User | string | undefined | null): string {
if (!user) return '';
return typeof user === 'string' ? 'Utente' : user.name;
}
/**
* Safely converts a value to a Date object.
*/
export function toDate(value: any): Date | null {
if (!value) return null;
const date = new Date(value);
return isNaN(date.getTime()) ? null : date;
}
@@ -0,0 +1,144 @@
<div class="flex flex-col h-full text-white animate-fade-in">
<!-- Header -->
<header class="p-4 flex items-center flex-shrink-0 border-b border-slate-700">
<button (click)="close.emit()" class="p-2 rounded-full hover:bg-slate-700 mr-3">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<div>
<h1 class="text-2xl font-bold">Crea Scommessa</h1>
</div>
</header>
<!-- Form -->
<main class="flex-grow overflow-y-auto p-4 space-y-6">
<form (submit)="$event.preventDefault(); createBet()">
<!-- Titolo -->
<div>
<label for="title" class="block text-sm font-medium text-slate-300 mb-2">Titolo *</label>
<input type="text" id="title" name="title"
[value]="title()"
(input)="onFieldInput('title', $event)"
required
class="w-full bg-slate-800 border border-slate-600 rounded-md py-2 px-3 text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:border-cyan-500"
placeholder="Es. Chi vincerà la partita?">
</div>
<!-- Descrizione -->
<div>
<label for="description" class="block text-sm font-medium text-slate-300 mb-2">Descrizione</label>
<textarea id="description" name="description" rows="3"
[value]="description()"
(input)="onFieldInput('description', $event)"
class="w-full bg-slate-800 border border-slate-600 rounded-md py-2 px-3 text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:border-cyan-500"
placeholder="Aggiungi dettagli sulla scommessa..."></textarea>
</div>
<!-- Premio -->
<div>
<label for="stake" class="block text-sm font-medium text-slate-300 mb-2">Premio in palio *</label>
<input type="text" id="stake" name="stake"
[value]="stake()"
(input)="onFieldInput('stake', $event)"
required
class="w-full bg-slate-800 border border-slate-600 rounded-md py-2 px-3 text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:border-cyan-500"
placeholder="Es. Una pizza, 10€, gloria eterna">
</div>
<!-- Visibilità -->
<div class="flex items-center justify-between bg-slate-800 p-3 rounded-lg border border-slate-700">
<div>
<h4 class="font-semibold text-slate-200">Scommessa Pubblica</h4>
<p class="text-sm text-slate-400">Visibile a tutti nella sezione "Scopri".</p>
</div>
<button type="button" (click)="isPublic.set(!isPublic())" [class]="'relative inline-flex items-center h-6 rounded-full w-11 transition-colors ' + (isPublic() ? 'bg-cyan-500' : 'bg-slate-600')">
<span [class]="'inline-block w-4 h-4 transform bg-white rounded-full transition-transform ' + (isPublic() ? 'translate-x-6' : 'translate-x-1')"></span>
</button>
</div>
<!-- Partecipanti -->
<section>
<h3 class="text-lg font-semibold text-cyan-400 mb-3">Partecipanti</h3>
<div class="space-y-3">
@for (p of participants(); track getUserId(p.user)) {
<div class="flex items-center bg-slate-800 p-2 rounded-lg">
<img class="w-10 h-10 rounded-full mr-4" [src]="p.user.avatarUrl" [alt]="p.user.name">
<p class="flex-grow font-medium">{{ p.user.name }}</p>
<select [value]="p.role" (change)="updateParticipantRole(p.user.id, $event)" class="bg-slate-700 border-slate-600 rounded-md py-1 px-2 text-sm text-white focus:outline-none focus:ring-1 focus:ring-cyan-500">
<option value="PARTICIPANT">Partecipante</option>
<option value="OBSERVER">Osservatore</option>
</select>
@if(p.user.id !== currentUser().id) {
<button type="button" (click)="removeParticipant(p.user.id)" class="ml-2 text-slate-500 hover:text-red-500 p-2 rounded-full transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
}
</div>
}
</div>
<div class="mt-4 relative">
<button type="button" (click)="showFriendSelector.set(!showFriendSelector())" class="w-full flex items-center justify-center py-2 px-4 border-2 border-dashed border-slate-600 rounded-lg text-slate-400 hover:bg-slate-700 hover:border-slate-500 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 5a1 1 0 011 1v3h3a1 1 0 110 2h-3v3a1 1 0 11-2 0v-3H6a1 1 0 110-2h3V6a1 1 0 011-1z" clip-rule="evenodd" />
</svg>
Aggiungi Amico
</button>
@if (showFriendSelector()) {
<div class="absolute bottom-full mb-2 w-full bg-slate-700 border border-slate-600 rounded-lg shadow-lg max-h-48 overflow-y-auto z-10 animate-fade-in">
@for (friend of availableFriends(); track friend.id) {
<div (click)="addParticipant(friend)" class="flex items-center p-3 hover:bg-slate-600 cursor-pointer">
<img class="w-8 h-8 rounded-full mr-3" [src]="friend.avatarUrl" [alt]="friend.name">
<span class="font-medium">{{ friend.name }}</span>
</div>
}
@empty {
<p class="text-slate-400 text-center p-4">Nessun altro amico da aggiungere.</p>
}
</div>
}
</div>
@if (activeParticipantsCount() < 1) {
<p class="text-yellow-400 text-sm mt-2">È necessario almeno 1 partecipante attivo.</p>
}
</section>
<!-- Gruppo e Scadenza -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="group" class="block text-sm font-medium text-slate-300 mb-2">Gruppo (Opzionale)</label>
<select id="group" name="group" [value]="groupId() ?? 'none'" (change)="onGroupChange($event)" class="w-full bg-slate-800 border border-slate-600 rounded-md py-2 px-3 text-white focus:outline-none focus:ring-2 focus:ring-cyan-500">
<option value="none">Nessun gruppo</option>
@for(group of userGroups(); track group.id) {
<option [value]="group.id">{{ group.name }}</option>
}
</select>
</div>
<div>
<label for="endDate" class="block text-sm font-medium text-slate-300 mb-2">Scadenza (Opzionale)</label>
<input type="date" id="endDate" name="endDate"
[value]="endDate()"
(input)="onFieldInput('endDate', $event)"
class="w-full bg-slate-800 border border-slate-600 rounded-md py-2 px-3 text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-cyan-500">
</div>
</div>
</form>
</main>
<!-- Footer Buttons -->
<footer class="p-4 flex-shrink-0 border-t border-slate-700 flex justify-end space-x-4">
<button type="button" (click)="close.emit()" class="px-6 py-2 rounded-md bg-slate-600 hover:bg-slate-500 text-white font-semibold transition-colors">Annulla</button>
<button type="button"
(click)="createBet()"
[disabled]="!isFormValid()"
[class]="'px-6 py-2 rounded-md font-semibold transition-colors ' + (isFormValid() ? 'bg-cyan-500 hover:bg-cyan-600 text-white' : 'bg-slate-700 text-slate-500 cursor-not-allowed')">
Crea Scommessa
</button>
</footer>
</div>
@@ -0,0 +1,120 @@
import { Component, ChangeDetectionStrategy, output, inject, signal, computed, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { DataService } from '../../core/services/data.service';
import { BetParticipant, BetRole } from '../../core/models/bet.model';
import { Group } from '../../core/models/group.model';
import { User } from '../../core/models/user.model';
import { getUserId } from '../../core/utils/type-guards';
@Component({
selector: 'app-create-bet',
standalone: true,
imports: [CommonModule],
templateUrl: './create-bet.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CreateBetComponent implements OnInit {
protected getUserId = getUserId;
close = output<void>();
private dataService = inject(DataService);
// Form state
title = signal('');
description = signal('');
stake = signal('');
isPublic = signal(false);
endDate = signal('');
groupId = signal<string | undefined>(undefined);
participants = signal<BetParticipant[]>([]);
// Data from service
currentUser = this.dataService.currentUser;
friends = this.dataService.friends;
userGroups = this.dataService.userGroups;
// UI state
showFriendSelector = signal(false);
availableFriends = computed(() => {
const participantIds = new Set(this.participants().map(p => getUserId(p.user)));
return this.friends().filter(f => !participantIds.has(f.id));
});
activeParticipantsCount = computed(() => {
return this.participants().filter(p => p.role === BetRole.Participant).length;
});
isFormValid = computed(() => {
return this.title().trim().length > 0 &&
this.stake().trim().length > 0 &&
this.activeParticipantsCount() >= 1;
});
ngOnInit() {
const user = this.currentUser();
if (user) {
this.participants.set([{ user, role: BetRole.Participant }]);
}
}
addParticipant(friend: User) {
this.participants.update(p => [...p, { user: friend, role: BetRole.Participant }]);
this.groupId.set(undefined); // Reset group if participants are manually changed
this.showFriendSelector.set(false);
}
removeParticipant(userId: string) {
this.participants.update(p => p.filter(participant => getUserId(participant.user) !== userId));
this.groupId.set(undefined); // Reset group if participants are manually changed
}
updateParticipantRole(userId: string, newRole: BetRole) {
this.participants.update(p => p.map(participant =>
getUserId(participant.user) === userId ? { ...participant, role: newRole } : participant
));
}
onFieldInput(field: 'title' | 'description' | 'stake' | 'endDate', event: Event) {
const value = (event.target as HTMLInputElement).value;
this[field].set(value);
}
onGroupChange(event: Event) {
const value = (event.target as HTMLSelectElement).value;
const selectedGroupId = value === 'none' ? undefined : value;
this.groupId.set(selectedGroupId);
if (selectedGroupId) {
const selectedGroup = this.userGroups().find(g => g.id === selectedGroupId);
if (selectedGroup) {
const groupParticipants: BetParticipant[] = selectedGroup.members.map(member => ({
user: member,
role: BetRole.Participant
}));
this.participants.set(groupParticipants);
}
} else {
// Reset to just the current user if "Nessun gruppo" is selected
const user = this.currentUser();
if (user) {
this.participants.set([{ user, role: BetRole.Participant }]);
}
}
}
async createBet() {
if (!this.isFormValid()) return;
await this.dataService.addBet({
title: this.title().trim(),
description: this.description().trim(),
stake: this.stake().trim(),
isPublic: this.isPublic(),
endDate: this.endDate() ? new Date(this.endDate()) : null,
groupId: this.groupId(),
participants: this.participants(),
});
this.close.emit();
}
}
@@ -0,0 +1,17 @@
<div class="p-4 flex flex-col h-full">
<header class="mb-4">
<h1 class="text-3xl font-bold text-white">Scommesse Pubbliche</h1>
<p class="text-slate-400 mt-1">Unisciti a queste scommesse o prendi ispirazione!</p>
</header>
<main class="flex-grow overflow-y-auto space-y-4 pr-2 -mr-2">
@for(bet of publicBets(); track bet.id) {
<app-bet-card [bet]="bet"></app-bet-card>
}
@empty {
<div class="text-center text-slate-500 mt-8">
<p>Al momento non ci sono scommesse pubbliche.</p>
<p>Torna più tardi!</p>
</div>
}
</main>
</div>
@@ -0,0 +1,16 @@
import { Component, ChangeDetectionStrategy, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { DataService } from '../../core/services/data.service';
import { BetCardComponent } from '../../shared/components/bet-card/bet-card.component';
@Component({
selector: 'app-discover',
standalone: true,
imports: [CommonModule, BetCardComponent],
templateUrl: './discover.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DiscoverComponent {
private dataService = inject(DataService);
publicBets = this.dataService.getPublicBets;
}
@@ -0,0 +1,126 @@
<div class="p-4 flex flex-col h-full text-white animate-fade-in">
@if (group(); as currentGroup) {
<!-- Header -->
<header class="mb-4 flex items-center">
<button (click)="goBack.emit()" class="p-2 rounded-full hover:bg-slate-700 mr-3">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
</svg>
</button>
<div>
<h1 class="text-3xl font-bold">{{ currentGroup.name }}</h1>
<p class="text-slate-400 mt-1">{{ currentGroup.members.length }} Membri</p>
</div>
</header>
<main class="flex-grow overflow-y-auto pr-2 -mr-2 space-y-6">
<!-- Members Section -->
<section>
<h2 class="text-xl font-semibold text-cyan-400 mb-3">Membri</h2>
<div class="space-y-3">
@for (member of sortedMembers(); track member.id) {
<div class="flex items-center bg-slate-800 p-2 rounded-lg">
<img class="w-10 h-10 rounded-full mr-4" [src]="member.avatarUrl" [alt]="member.name">
<div class="flex-grow">
<p class="font-medium">{{ member.name }}</p>
<div class="flex items-center text-xs text-amber-400">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1" viewBox="0 0 20 20" fill="currentColor">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
<span>{{ member.score }} Punti Gloria</span>
</div>
</div>
<button (click)="removeMember(member.id)" class="text-slate-500 hover:text-red-500 p-2 rounded-full transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
}
</div>
@if (!isAddingMembers()) {
<button (click)="toggleAddMembers()" class="mt-4 w-full flex items-center justify-center py-2 px-4 border-2 border-dashed border-slate-600 rounded-lg text-slate-400 hover:bg-slate-700 hover:border-slate-500 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 5a1 1 0 011 1v3h3a1 1 0 110 2h-3v3a1 1 0 11-2 0v-3H6a1 1 0 110-2h3V6a1 1 0 011-1z" clip-rule="evenodd" />
</svg>
Aggiungi Partecipanti
</button>
} @else {
<div class="mt-4 bg-slate-800 p-4 rounded-lg border border-slate-700">
<h3 class="text-lg font-semibold text-cyan-400 mb-3">Seleziona Amici</h3>
<div class="max-h-40 overflow-y-auto space-y-2 bg-slate-700 p-3 rounded-md border border-slate-600">
@for (friend of availableFriends(); track friend.id) {
<label class="flex items-center space-x-3 cursor-pointer p-2 rounded-md hover:bg-slate-600 transition-colors">
<input type="checkbox"
(change)="onFriendSelect(friend.id, $event)"
[checked]="selectedFriendIds()[friend.id] || false"
class="h-5 w-5 rounded bg-slate-800 border-slate-500 text-cyan-500 focus:ring-cyan-600 cursor-pointer">
<img class="w-8 h-8 rounded-full" [src]="friend.avatarUrl" [alt]="friend.name">
<span class="font-medium text-slate-200">{{ friend.name }}</span>
</label>
}
@empty {
<p class="text-slate-400 text-center py-4">Tutti i tuoi amici sono già in questo gruppo!</p>
}
</div>
<div class="flex justify-end space-x-3 mt-4">
<button type="button" (click)="toggleAddMembers()" class="px-4 py-2 rounded-md bg-slate-600 hover:bg-slate-500 text-white font-semibold transition-colors">Annulla</button>
<button type="button"
(click)="addSelectedFriends()"
[disabled]="!isAddFriendsFormValid()"
[class]="'px-4 py-2 rounded-md font-semibold transition-colors ' + (isAddFriendsFormValid() ? 'bg-cyan-500 hover:bg-cyan-600 text-white' : 'bg-slate-700 text-slate-500 cursor-not-allowed')">
Aggiungi
</button>
</div>
</div>
}
</section>
<!-- Accordions -->
<section class="space-y-4">
<!-- Active Bets -->
<div>
<button (click)="toggleAccordion('active')" class="w-full flex justify-between items-center bg-slate-800 p-3 rounded-lg hover:bg-slate-700 transition-colors">
<h2 class="text-xl font-semibold text-cyan-400">Scommesse Attive ({{ activeBets().length }})</h2>
<svg xmlns="http://www.w3.org/2000/svg" [class]="'h-6 w-6 transition-transform ' + (activeAccordion() === 'active' ? 'rotate-180' : '')" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
@if (activeAccordion() === 'active') {
<div class="pt-4 space-y-4 animate-fade-in">
@for (bet of activeBets(); track bet.id) {
<app-bet-card [bet]="bet"></app-bet-card>
}
@empty {
<p class="text-center text-slate-500 pt-4">Nessuna scommessa attiva in questo gruppo.</p>
}
</div>
}
</div>
<!-- Past Bets -->
<div>
<button (click)="toggleAccordion('past')" class="w-full flex justify-between items-center bg-slate-800 p-3 rounded-lg hover:bg-slate-700 transition-colors">
<h2 class="text-xl font-semibold text-cyan-400">Scommesse Concluse ({{ pastBets().length }})</h2>
<svg xmlns="http://www.w3.org/2000/svg" [class]="'h-6 w-6 transition-transform ' + (activeAccordion() === 'past' ? 'rotate-180' : '')" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
@if (activeAccordion() === 'past') {
<div class="pt-4 space-y-4 animate-fade-in">
@for (bet of pastBets(); track bet.id) {
<app-bet-card [bet]="bet"></app-bet-card>
}
@empty {
<p class="text-center text-slate-500 pt-4">Nessuna scommessa conclusa in questo gruppo.</p>
}
</div>
}
</div>
</section>
</main>
} @else {
<p class="text-center text-slate-500 mt-8">Gruppo non trovato.</p>
}
</div>
@@ -0,0 +1,100 @@
import { Component, ChangeDetectionStrategy, input, output, inject, signal, computed, effect } from '@angular/core';
import { CommonModule } from '@angular/common';
import { DataService } from '../../../core/services/data.service';
import { Bet, BetStatus } from '../../../core/models/bet.model';
import { Group } from '../../../core/models/group.model';
import { User } from '../../../core/models/user.model';
import { BetCardComponent } from '../../../shared/components/bet-card/bet-card.component';
@Component({
selector: 'app-group-detail',
standalone: true,
imports: [CommonModule, BetCardComponent],
templateUrl: './group-detail.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class GroupDetailComponent {
groupId = input.required<string>();
goBack = output<void>();
private dataService = inject(DataService);
private allGroups = this.dataService.userGroups;
group = computed(() => this.allGroups().find(g => g.id === this.groupId()) ?? null);
groupBets = signal<Bet[]>([]);
isAddingMembers = signal(false);
selectedFriendIds = signal<{ [key: string]: boolean }>({});
constructor() {
effect(() => {
const id = this.groupId();
if (id) {
this.groupBets.set(this.dataService.getBetsByGroupId(id));
}
});
}
sortedMembers = computed(() => {
return this.group()?.members.slice().sort((a, b) => b.score - a.score) ?? [];
});
activeBets = computed(() => {
return this.groupBets().filter(b => b.status === BetStatus.Active || b.status === BetStatus.AwaitingValidation);
});
pastBets = computed(() => {
return this.groupBets().filter(b => b.status === BetStatus.Completed || b.status === BetStatus.Expired);
});
availableFriends = computed(() => {
const friends = this.dataService.friends();
const memberIds = new Set(this.group()?.members.map(m => m.id) ?? []);
return friends.filter(f => !memberIds.has(f.id));
});
isAddFriendsFormValid = computed(() => {
return Object.keys(this.selectedFriendIds()).length > 0;
});
activeAccordion = signal<'active' | 'past' | 'none'>('active');
toggleAccordion(section: 'active' | 'past') {
this.activeAccordion.update(current => current === section ? 'none' : section);
}
async removeMember(userId: string) {
if (confirm('Sei sicuro di voler rimuovere questo membro?')) {
await this.dataService.removeUserFromGroup(this.groupId(), userId);
}
}
toggleAddMembers() {
this.isAddingMembers.update(val => !val);
this.selectedFriendIds.set({});
}
onFriendSelect(friendId: string, event: Event) {
const isChecked = (event.target as HTMLInputElement).checked;
this.selectedFriendIds.update(currentIds => {
const newIds = { ...currentIds };
if (isChecked) {
newIds[friendId] = true;
} else {
delete newIds[friendId];
}
return newIds;
});
}
async addSelectedFriends() {
if (!this.isAddFriendsFormValid()) return;
const selectedIds = Object.keys(this.selectedFriendIds());
const friendsToAdd = this.availableFriends().filter(f => selectedIds.includes(f.id));
await this.dataService.addUsersToGroup(this.groupId(), friendsToAdd);
this.toggleAddMembers();
}
}
@@ -0,0 +1,94 @@
@if (!selectedGroupId()) {
<div class="p-4 flex flex-col h-full text-white">
<header class="mb-4">
<div class="flex justify-between items-center">
<div>
<h1 class="text-3xl font-bold">I Miei Gruppi</h1>
<p class="text-slate-400 mt-1">Gestisci i tuoi gruppi o creane di nuovi.</p>
</div>
<button (click)="toggleCreateMode()" class="bg-cyan-500 hover:bg-cyan-600 text-white font-bold p-2 rounded-full transition-colors duration-300 shadow-lg">
@if (isCreating()) {
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
} @else {
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
</svg>
}
</button>
</div>
</header>
@if (isCreating()) {
<div class="bg-slate-800 p-4 rounded-lg border border-slate-700 mb-4 animate-fade-in">
<h2 class="text-xl font-bold text-cyan-400 mb-4">Nuovo Gruppo</h2>
<form (submit)="$event.preventDefault(); createGroup()">
<div class="mb-4">
<label for="groupName" class="block text-sm font-medium text-slate-300 mb-2">Nome Gruppo</label>
<input type="text" id="groupName" name="groupName"
[value]="newGroupName()"
(input)="onGroupNameChange($event)"
required
class="w-full bg-slate-700 border border-slate-600 rounded-md py-2 px-3 text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:border-cyan-500"
placeholder="Es. Colleghi d'ufficio">
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-slate-300 mb-2">Membri (Amici)</label>
<div class="max-h-40 overflow-y-auto space-y-2 bg-slate-700 p-3 rounded-md border border-slate-600">
@for (friend of friends(); track friend.id) {
<label class="flex items-center space-x-3 cursor-pointer p-2 rounded-md hover:bg-slate-600 transition-colors">
<input type="checkbox"
(change)="onFriendSelect(friend.id, $event)"
[checked]="selectedFriendIds()[friend.id] || false"
class="h-5 w-5 rounded bg-slate-800 border-slate-500 text-cyan-500 focus:ring-cyan-600 cursor-pointer">
<img class="w-8 h-8 rounded-full" [src]="friend.avatarUrl" [alt]="friend.name">
<span class="font-medium text-slate-200">{{ friend.name }}</span>
</label>
}
</div>
</div>
<div class="flex justify-end space-x-3 mt-4">
<button type="button" (click)="toggleCreateMode()" class="px-4 py-2 rounded-md bg-slate-600 hover:bg-slate-500 text-white font-semibold transition-colors">Annulla</button>
<button type="submit"
[disabled]="!isFormValid()"
[class]="'px-4 py-2 rounded-md font-semibold transition-colors ' + (isFormValid() ? 'bg-cyan-500 hover:bg-cyan-600 text-white' : 'bg-slate-700 text-slate-500 cursor-not-allowed')">
Crea Gruppo
</button>
</div>
</form>
</div>
}
<main class="flex-grow overflow-y-auto space-y-4 pr-2 -mr-2">
@for (group of groups(); track group.id) {
<div (click)="selectGroup(group.id)" class="bg-slate-800 p-4 rounded-lg border border-slate-700 hover:border-cyan-500 transition-colors duration-300 cursor-pointer">
<div class="flex justify-between items-center mb-3">
<h3 class="text-xl font-bold text-cyan-400">{{ group.name }}</h3>
<span class="text-sm font-semibold bg-slate-700 text-slate-300 px-2 py-1 rounded-md">{{ group.members.length }} Membri</span>
</div>
<div class="flex -space-x-2">
@for(member of group.members; track member.id) {
<img class="inline-block h-10 w-10 rounded-full ring-2 ring-slate-900" [src]="member.avatarUrl" [alt]="member.name" [title]="member.name">
}
</div>
</div>
}
@empty {
@if (!isCreating()) {
<div class="text-center text-slate-500 mt-8 flex flex-col items-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 mb-4 text-slate-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1">
<path stroke-linecap="round" stroke-linejoin="round" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.653-.124-1.282-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.653.124-1.282.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
<p>Non fai ancora parte di nessun gruppo.</p>
<p class="mt-1">Crea un gruppo per iniziare a scommettere con i tuoi amici!</p>
</div>
}
}
</main>
</div>
} @else {
<app-group-detail [groupId]="selectedGroupId()!" (goBack)="deselectGroup()"></app-group-detail>
}
@@ -0,0 +1,83 @@
import { Component, ChangeDetectionStrategy, inject, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { DataService } from '../../core/services/data.service';
import { User } from '../../core/models/user.model';
import { GroupDetailComponent } from './group-detail/group-detail.component';
@Component({
selector: 'app-groups',
standalone: true,
imports: [CommonModule, GroupDetailComponent],
templateUrl: './groups.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class GroupsComponent {
private dataService = inject(DataService);
groups = this.dataService.userGroups;
friends = this.dataService.friends;
isCreating = signal(false);
newGroupName = signal('');
selectedFriendIds = signal<{ [key: string]: boolean }>({});
selectedGroupId = signal<string | null>(null);
isFormValid = computed(() => {
return this.newGroupName().trim().length > 0 && Object.keys(this.selectedFriendIds()).length > 0;
});
selectGroup(groupId: string) {
this.selectedGroupId.set(groupId);
}
deselectGroup() {
this.selectedGroupId.set(null);
}
toggleCreateMode() {
this.isCreating.update(val => !val);
if (!this.isCreating()) {
this.resetForm();
}
}
onGroupNameChange(event: Event) {
this.newGroupName.set((event.target as HTMLInputElement).value);
}
onFriendSelect(friendId: string, event: Event) {
const isChecked = (event.target as HTMLInputElement).checked;
this.selectedFriendIds.update(currentIds => {
const newIds = { ...currentIds };
if (isChecked) {
newIds[friendId] = true;
} else {
delete newIds[friendId];
}
return newIds;
});
}
async createGroup() {
if (!this.isFormValid()) {
return;
}
const selectedIds = Object.keys(this.selectedFriendIds());
const selectedMembers = this.friends().filter(f => selectedIds.includes(f.id));
const currentUser = this.dataService.currentUser();
if (!currentUser) return;
const allMembers = [currentUser, ...selectedMembers];
const uniqueMembers = [...new Map(allMembers.map(item => [item['id'], item])).values()];
await this.dataService.addGroup(this.newGroupName(), uniqueMembers);
this.toggleCreateMode();
}
private resetForm() {
this.newGroupName.set('');
this.selectedFriendIds.set({});
}
}
@@ -0,0 +1,35 @@
<div class="flex flex-col h-full">
<!-- FILTERS -->
<div class="mb-4 overflow-x-auto pb-2 -mx-4 px-4 hide-scrollbar">
<div class="flex items-center space-x-2 whitespace-nowrap">
<button (click)="setGroupFilter('all')"
[class]="'px-3 py-1 text-sm font-medium rounded-full transition-colors ' + (activeGroupFilter() === 'all' ? 'bg-cyan-500 text-white' : 'bg-slate-700 text-slate-300 hover:bg-slate-600')">
Tutti
</button>
@for(group of groupFilters(); track group.id) {
<button (click)="setGroupFilter(group.id)"
[class]="'px-3 py-1 text-sm font-medium rounded-full transition-colors ' + (activeGroupFilter() === group.id ? 'bg-cyan-500 text-white' : 'bg-slate-700 text-slate-300 hover:bg-slate-600')">
{{ group.name }}
</button>
}
</div>
</div>
<main class="flex-grow overflow-y-auto pr-2 -mr-2">
<div class="space-y-6">
@for (group of betsByGroup(); track group.group.id) {
<div>
<h2 class="text-xl font-semibold text-cyan-400 mb-3 sticky top-0 bg-slate-900 py-2">{{ group.group.name }}</h2>
<div class="space-y-4">
@for(bet of group.bets; track bet.id) {
<app-bet-card [bet]="bet"></app-bet-card>
}
</div>
</div>
}
@empty {
<p class="text-center text-slate-500 mt-8">Nessun gruppo con i filtri selezionati.</p>
}
</div>
</main>
</div>
@@ -0,0 +1,73 @@
import { Component, ChangeDetectionStrategy, inject, signal, computed, input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { DataService } from '../../../core/services/data.service';
import { BetCardComponent } from '../../../shared/components/bet-card/bet-card.component';
import { Bet } from '../../../core/models/bet.model';
import { Group } from '../../../core/models/group.model';
import { getUserId, getUserName } from '../../../core/utils/type-guards';
@Component({
selector: 'app-bets-by-group',
standalone: true,
imports: [CommonModule, BetCardComponent],
templateUrl: './bets-by-group.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class BetsByGroupComponent {
private dataService = inject(DataService);
allBets = input.required<Bet[]>();
activeGroupFilter = signal<string>('all'); // 'all' or group.id
private unfilteredBetsByGroup = computed(() => {
const grouped = new Map<string, { group: Group, bets: Bet[] }>();
for (const bet of this.allBets()) {
let groupId: string;
let groupName: string;
let groupInfo: Group | undefined;
if (bet.groupId) {
groupId = bet.groupId;
groupInfo = this.dataService.getGroupById(groupId);
groupName = groupInfo?.name ?? 'Gruppo Sconosciuto';
} else {
const participantIds = bet.participants.map(p => getUserId(p.user)).sort().join('-');
groupId = `adhoc-${participantIds}`;
groupInfo = undefined;
groupName = this.createAdHocGroupName(bet);
}
if (!grouped.has(groupId)) {
grouped.set(groupId, {
group: groupInfo ?? { id: groupId, name: groupName, members: [] } as Group,
bets: []
});
}
grouped.get(groupId)!.bets.push(bet);
}
return Array.from(grouped.values());
});
groupFilters = computed(() => {
return this.unfilteredBetsByGroup().map(item => item.group);
});
betsByGroup = computed(() => {
const filter = this.activeGroupFilter();
if (filter === 'all') {
return this.unfilteredBetsByGroup();
}
return this.unfilteredBetsByGroup().filter(item => item.group.id === filter);
});
setGroupFilter(groupId: string) {
this.activeGroupFilter.set(groupId);
}
private createAdHocGroupName(bet: Bet): string {
const names = bet.participants.map(p => getUserName(p.user).split(' ')[0]);
if (names.length === 1) return `Scommessa Personale`;
return names.slice(0, 3).join(', ') + (names.length > 3 ? '...' : '');
}
}
@@ -0,0 +1,24 @@
<div class="flex flex-col h-full">
<!-- FILTERS -->
<div class="mb-4 overflow-x-auto pb-2 -mx-4 px-4 hide-scrollbar">
<div class="flex items-center space-x-2 whitespace-nowrap">
@for(filter of timeFilters; track filter.key) {
<button (click)="setTimeFilter(filter.key)"
[class]="'px-3 py-1 text-sm font-medium rounded-full transition-colors ' + (activeTimeFilter() === filter.key ? 'bg-cyan-500 text-white' : 'bg-slate-700 text-slate-300 hover:bg-slate-600')">
{{ filter.label }}
</button>
}
</div>
</div>
<main class="flex-grow overflow-y-auto pr-2 -mr-2">
<div class="space-y-4">
@for (bet of betsByTime(); track bet.id) {
<app-bet-card [bet]="bet"></app-bet-card>
}
@empty {
<p class="text-center text-slate-500 mt-8">Nessuna scommessa attiva con i filtri selezionati.</p>
}
</div>
</main>
</div>
@@ -0,0 +1,71 @@
import { Component, ChangeDetectionStrategy, input, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { BetCardComponent } from '../../../shared/components/bet-card/bet-card.component';
import { Bet } from '../../../core/models/bet.model';
type TimeFilter = 'all' | '1d' | '1w' | '1m' | '6m' | 'none';
interface TimeFilterOption {
key: TimeFilter;
label: string;
}
@Component({
selector: 'app-bets-by-time',
standalone: true,
imports: [CommonModule, BetCardComponent],
templateUrl: './bets-by-time.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class BetsByTimeComponent {
allBets = input.required<Bet[]>();
activeTimeFilter = signal<TimeFilter>('all');
readonly timeFilters: TimeFilterOption[] = [
{ key: 'all', label: 'Tutte' },
{ key: '1d', label: '1 giorno' },
{ key: '1w', label: '1 settimana' },
{ key: '1m', label: '1 mese' },
{ key: '6m', label: '6 mesi' },
{ key: 'none', label: 'Senza scadenza' },
];
betsByTime = computed(() => {
const allSortedBets = [...this.allBets()].sort((a, b) => {
const aEndDate = a.endDate?.getTime() ?? Infinity;
const bEndDate = b.endDate?.getTime() ?? Infinity;
return aEndDate - bEndDate;
});
const filter = this.activeTimeFilter();
if (filter === 'all') {
return allSortedBets;
}
const now = new Date().getTime();
return allSortedBets.filter(bet => {
if (filter === 'none') {
return !bet.endDate;
}
if (!bet.endDate) {
return false;
}
const endDate = bet.endDate.getTime();
let limit = 0;
switch (filter) {
case '1d': limit = now + 24 * 60 * 60 * 1000; break;
case '1w': limit = now + 7 * 24 * 60 * 60 * 1000; break;
case '1m': limit = now + 30 * 24 * 60 * 60 * 1000; break;
case '6m': limit = now + 180 * 24 * 60 * 60 * 1000; break;
}
return endDate > now && endDate <= limit;
});
});
setTimeFilter(filter: TimeFilter) {
this.activeTimeFilter.set(filter);
}
}
@@ -0,0 +1,25 @@
<div class="p-4 flex flex-col h-full">
<header class="mb-4">
<h1 class="text-3xl font-bold text-white">Scommesse Attive</h1>
<div class="mt-4 flex justify-center bg-slate-800 rounded-lg p-1">
<button
(click)="setViewMode('time')"
[class]="'w-1/2 py-2 text-sm font-semibold rounded-md transition-colors ' + (viewMode() === 'time' ? 'bg-cyan-500 text-white' : 'text-slate-400 hover:bg-slate-700')">
Per Scadenza
</button>
<button
(click)="setViewMode('group')"
[class]="'w-1/2 py-2 text-sm font-semibold rounded-md transition-colors ' + (viewMode() === 'group' ? 'bg-cyan-500 text-white' : 'text-slate-400 hover:bg-slate-700')">
Per Gruppi
</button>
</div>
</header>
<div class="flex-grow overflow-hidden">
@if (viewMode() === 'time') {
<app-bets-by-time [allBets]="allBets()"></app-bets-by-time>
} @else {
<app-bets-by-group [allBets]="allBets()"></app-bets-by-group>
}
</div>
</div>
@@ -0,0 +1,30 @@
import { Component, ChangeDetectionStrategy, inject, signal, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { DataService } from '../../core/services/data.service';
import { Bet } from '../../core/models/bet.model';
import { BetsByTimeComponent } from './bets-by-time/bets-by-time.component';
import { BetsByGroupComponent } from './bets-by-group/bets-by-group.component';
@Component({
selector: 'app-home',
standalone: true,
imports: [CommonModule, BetsByTimeComponent, BetsByGroupComponent],
templateUrl: './home.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class HomeComponent implements OnInit {
private dataService = inject(DataService);
viewMode = signal<'time' | 'group'>('time');
allBets = signal<Bet[]>([]);
ngOnInit() {
// FIX: getActiveBets is a computed signal which returns a value when called once.
// The extra parentheses caused an error because it tried to execute an array.
this.allBets.set(this.dataService.getActiveBets());
}
setViewMode(mode: 'time' | 'group') {
this.viewMode.set(mode);
}
}
@@ -0,0 +1,80 @@
<div class="flex flex-col h-full text-white animate-fade-in">
<!-- Header -->
<header class="mb-6 flex items-center flex-shrink-0">
<button (click)="close.emit()" class="p-2 rounded-full hover:bg-slate-700 mr-3">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
</svg>
</button>
<div>
<h1 class="text-3xl font-bold">Modifica Profilo</h1>
<p class="text-slate-400 mt-1">Aggiorna i tuoi dati personali.</p>
</div>
</header>
<!-- Form -->
<main class="flex-grow overflow-y-auto pr-2 -mr-2">
<form (submit)="$event.preventDefault(); saveChanges()" class="space-y-6">
<!-- Nome -->
<div>
<label for="name" class="block text-sm font-medium text-slate-300 mb-2">Nome</label>
<input type="text" id="name" name="name"
[value]="name()"
(input)="onNameInput($event)"
required
class="w-full bg-slate-800 border border-slate-600 rounded-md py-2 px-3 text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:border-cyan-500">
</div>
<!-- Email -->
<div>
<label for="email" class="block text-sm font-medium text-slate-300 mb-2">Email</label>
<input type="email" id="email" name="email"
[value]="email()"
(input)="onEmailInput($event)"
required
class="w-full bg-slate-800 border border-slate-600 rounded-md py-2 px-3 text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:border-cyan-500">
</div>
<div class="border-t border-slate-700 pt-6">
<h3 class="text-lg font-semibold text-cyan-400">Cambia Password</h3>
<p class="text-sm text-slate-400 mb-4">Lascia vuoto per non modificare la password attuale.</p>
<!-- Nuova Password -->
<div>
<label for="password" class="block text-sm font-medium text-slate-300 mb-2">Nuova Password</label>
<input type="password" id="password" name="password"
[value]="password()"
(input)="onPasswordInput($event)"
placeholder="Minimo 6 caratteri"
class="w-full bg-slate-800 border border-slate-600 rounded-md py-2 px-3 text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:border-cyan-500">
</div>
<!-- Conferma Password -->
<div class="mt-4">
<label for="confirmPassword" class="block text-sm font-medium text-slate-300 mb-2">Conferma Nuova Password</label>
<input type="password" id="confirmPassword" name="confirmPassword"
[value]="confirmPassword()"
(input)="onConfirmPasswordInput($event)"
class="w-full bg-slate-800 border rounded-md py-2 px-3 text-white placeholder-slate-400 focus:outline-none focus:ring-2"
[class.border-slate-600]="password().length === 0 || passwordsMatch()"
[class.border-red-500]="password().length > 0 && !passwordsMatch()"
[class.focus:ring-cyan-500]="password().length === 0 || passwordsMatch()"
[class.focus:ring-red-500]="password().length > 0 && !passwordsMatch()">
@if(password().length > 0 && !passwordsMatch()) {
<p class="mt-2 text-sm text-red-400">Le password non coincidono.</p>
}
</div>
</div>
<!-- Buttons -->
<div class="flex justify-end space-x-4 pt-4">
<button type="button" (click)="close.emit()" class="px-6 py-2 rounded-md bg-slate-600 hover:bg-slate-500 text-white font-semibold transition-colors">Annulla</button>
<button type="submit"
[disabled]="!isFormValid()"
[class]="'px-6 py-2 rounded-md font-semibold transition-colors ' + (isFormValid() ? 'bg-cyan-500 hover:bg-cyan-600 text-white' : 'bg-slate-700 text-slate-500 cursor-not-allowed')">
Salva Modifiche
</button>
</div>
</form>
</main>
</div>
@@ -0,0 +1,60 @@
import { Component, ChangeDetectionStrategy, output, inject, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { DataService } from '../../../core/services/data.service';
@Component({
selector: 'app-edit-profile',
standalone: true,
imports: [CommonModule],
templateUrl: './edit-profile.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class EditProfileComponent {
close = output<void>();
private dataService = inject(DataService);
currentUser = this.dataService.currentUser;
name = signal(this.currentUser()?.name ?? '');
email = signal(this.currentUser()?.email ?? '');
password = signal('');
confirmPassword = signal('');
passwordsMatch = computed(() => this.password() === this.confirmPassword());
isFormValid = computed(() => {
const nameValid = this.name().trim().length > 2;
// A simple email regex check
const emailValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(this.email());
const passwordValid = this.password().length === 0 || (this.password().length >= 6 && this.passwordsMatch());
return nameValid && emailValid && passwordValid;
});
onNameInput(event: Event) {
this.name.set((event.target as HTMLInputElement).value);
}
onEmailInput(event: Event) {
this.email.set((event.target as HTMLInputElement).value);
}
onPasswordInput(event: Event) {
this.password.set((event.target as HTMLInputElement).value);
}
onConfirmPasswordInput(event: Event) {
this.confirmPassword.set((event.target as HTMLInputElement).value);
}
async saveChanges() {
if (!this.isFormValid()) {
return;
}
await this.dataService.updateCurrentUser({
name: this.name(),
email: this.email(),
});
// Note: Password change is not persisted.
this.close.emit();
}
}
@@ -0,0 +1,134 @@
@if (!isEditing()) {
<div class="p-4 flex flex-col h-full text-white animate-fade-in">
<!-- Header with Logout -->
<header class="flex justify-between items-center mb-4">
<h1 class="text-3xl font-bold">Profilo</h1>
<button title="Logout" class="p-2 text-slate-400 hover:text-white rounded-full hover:bg-slate-700 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
</button>
</header>
<!-- User Info -->
<section class="flex flex-col items-center text-center p-6 bg-slate-800 rounded-lg border border-slate-700 mb-6">
<img class="w-24 h-24 rounded-full border-4 border-cyan-500 shadow-lg mb-4" [src]="currentUser().avatarUrl" [alt]="currentUser().name">
<h2 class="text-2xl font-bold">{{ currentUser().name }}</h2>
<div class="flex items-center text-amber-400 mt-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-1" viewBox="0 0 20 20" fill="currentColor">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
<span class="font-semibold">{{ currentUser().score }} Punti Gloria</span>
</div>
<button (click)="toggleEditMode()" class="mt-4 w-full max-w-xs bg-slate-700 hover:bg-slate-600 text-white font-bold py-2 px-4 rounded-lg transition-colors duration-300">
Modifica Profilo
</button>
</section>
<main class="flex-grow overflow-y-auto pr-2 -mr-2 space-y-6">
<!-- Friend Requests Received -->
@if (receivedFriendRequests().length > 0) {
<section>
<h3 class="text-xl font-semibold text-cyan-400 mb-3">Richieste Ricevute</h3>
<div class="space-y-3">
@for(request of receivedFriendRequests(); track request.id) {
<div class="flex items-center bg-slate-800 p-2 rounded-lg">
<img class="w-10 h-10 rounded-full mr-4" [src]="request.fromUser.avatarUrl" [alt]="request.fromUser.name">
<p class="flex-grow font-medium">{{ request.fromUser.name }}</p>
<div class="flex space-x-2">
<button (click)="acceptRequest(request)" title="Accetta" class="p-2 text-green-400 hover:bg-slate-700 rounded-full transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</button>
<button (click)="declineRequest(request.id)" title="Rifiuta" class="p-2 text-red-400 hover:bg-slate-700 rounded-full transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
}
</div>
</section>
}
<!-- Friend Requests Sent -->
@if (sentFriendRequests().length > 0) {
<section>
<h3 class="text-xl font-semibold text-cyan-400 mb-3">Richieste Inviate</h3>
<div class="space-y-3">
@for(request of sentFriendRequests(); track request.id) {
<div class="flex items-center bg-slate-800 p-2 rounded-lg">
<img class="w-10 h-10 rounded-full mr-4" [src]="request.toUser.avatarUrl" [alt]="request.toUser.name">
<p class="flex-grow font-medium">{{ request.toUser.name }}</p>
<button (click)="cancelRequest(request.id)" title="Annulla Richiesta" class="p-2 text-slate-500 hover:text-red-400 hover:bg-slate-700 rounded-full transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
</div>
}
</div>
</section>
}
<!-- Friends List -->
<section>
<div class="flex justify-between items-center mb-3">
<h3 class="text-xl font-semibold text-cyan-400">Amici</h3>
<button (click)="toggleAddFriend()" [class]="'p-2 rounded-full transition-colors ' + (isAddingFriend() ? 'bg-slate-700 text-white' : 'text-slate-400 hover:bg-slate-700 hover:text-white')">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
</svg>
</button>
</div>
@if (isAddingFriend()) {
<div class="bg-slate-800 p-4 rounded-lg border border-slate-700 mb-4 animate-fade-in">
<label for="friendName" class="block text-sm font-medium text-slate-300 mb-2">Aggiungi amico per nome o email</label>
<div class="flex space-x-2">
<input type="text" id="friendName" name="friendName"
[value]="addFriendIdentifier()" (input)="onAddFriendInput($event)"
(keyup.enter)="sendFriendRequest()"
class="flex-grow bg-slate-700 border border-slate-600 rounded-md py-2 px-3 text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:border-cyan-500"
placeholder="Es. Bob o bob@email.com">
<button (click)="sendFriendRequest()" class="bg-cyan-500 hover:bg-cyan-600 text-white font-bold py-2 px-4 rounded-md transition-colors">Invia</button>
</div>
@if(addFriendError()) { <p class="text-red-400 text-sm mt-2 animate-fade-in">{{ addFriendError() }}</p> }
@if(addFriendSuccess()) { <p class="text-green-400 text-sm mt-2 animate-fade-in">{{ addFriendSuccess() }}</p> }
</div>
}
<div class="space-y-3">
@for (friend of friends(); track friend.id) {
<div class="flex items-center bg-slate-800 p-2 rounded-lg">
<img class="w-10 h-10 rounded-full mr-4" [src]="friend.avatarUrl" [alt]="friend.name">
<div class="flex-grow">
<p class="font-medium">{{ friend.name }}</p>
<div class="flex items-center text-xs text-amber-400">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1" viewBox="0 0 20 20" fill="currentColor">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
<span>{{ friend.score }} Punti Gloria</span>
</div>
</div>
</div>
}
@empty {
@if (!isAddingFriend()) {
<div class="text-center text-slate-500 py-4">
<p>Non hai ancora nessun amico.</p>
<p class="mt-1">Aggiungine uno per iniziare a scommettere!</p>
</div>
}
}
</div>
</section>
</main>
</div>
} @else {
<app-edit-profile (close)="toggleEditMode()"></app-edit-profile>
}
@@ -0,0 +1,77 @@
import { Component, ChangeDetectionStrategy, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { DataService } from '../../core/services/data.service';
import { EditProfileComponent } from './edit-profile/edit-profile.component';
import { FriendRequest } from '../../core/models/friend-request.model';
@Component({
selector: 'app-profile',
standalone: true,
imports: [CommonModule, EditProfileComponent],
templateUrl: './profile.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ProfileComponent {
private dataService = inject(DataService);
currentUser = this.dataService.currentUser;
friends = this.dataService.friends;
receivedFriendRequests = this.dataService.receivedFriendRequests;
sentFriendRequests = this.dataService.sentFriendRequests;
isEditing = signal(false);
isAddingFriend = signal(false);
addFriendIdentifier = signal('');
addFriendError = signal<string | null>(null);
addFriendSuccess = signal<string | null>(null);
toggleEditMode() {
this.isEditing.update(val => !val);
}
toggleAddFriend() {
this.isAddingFriend.update(val => !val);
this.addFriendIdentifier.set('');
this.addFriendError.set(null);
this.addFriendSuccess.set(null);
}
onAddFriendInput(event: Event) {
this.addFriendIdentifier.set((event.target as HTMLInputElement).value);
this.addFriendError.set(null);
this.addFriendSuccess.set(null);
}
async sendFriendRequest() {
if (this.addFriendIdentifier().trim() === '') return;
const result = await this.dataService.sendFriendRequest(this.addFriendIdentifier().trim());
if (result.success) {
this.addFriendSuccess.set(result.message);
this.addFriendError.set(null);
this.addFriendIdentifier.set('');
setTimeout(() => {
this.addFriendSuccess.set(null);
if (this.isAddingFriend()) {
this.toggleAddFriend();
}
}, 2000);
} else {
this.addFriendError.set(result.message);
this.addFriendSuccess.set(null);
}
}
cancelRequest(requestId: string) {
this.dataService.cancelFriendRequest(requestId);
}
acceptRequest(request: FriendRequest) {
this.dataService.acceptFriendRequest(request);
}
declineRequest(requestId: string) {
this.dataService.declineFriendRequest(requestId);
}
}
@@ -0,0 +1,73 @@
@if (bet()) {
<div
class="bg-slate-800 rounded-lg shadow-lg p-4 flex flex-col space-y-4 border border-slate-700 hover:border-cyan-500 transition-colors duration-300 mb-4">
<header class="flex justify-between items-start">
<h3 class="text-lg font-bold text-cyan-400">{{ bet().title }}</h3>
<div class="flex items-center space-x-2 text-sm">
<span [class]="'px-2 py-1 text-xs font-semibold rounded-full text-slate-900 ' + statusInfo().color">
{{ statusInfo().text }}
</span>
@if(bet().isPublic) {
<span class="text-slate-400" title="Pubblica">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM4.332 8.027a6.012 6.012 0 011.912-2.706C6.512 5.73 7.97 5 10 5c2.03 0 3.488.73 4.756 1.321l.004.002c.08.036.16.07.24.106a6.008 6.008 0 011.912 2.706A7.958 7.958 0 0118 10a7.958 7.958 0 01-1.168 1.973 6.012 6.012 0 01-1.912 2.706C13.488 14.27 12.03 15 10 15c-2.03 0-3.488-.73-4.756-1.321l-.004-.002a6.005 6.005 0 01-2.152-2.814A7.958 7.958 0 012 10a7.958 7.958 0 011.168-1.973zM10 12.5a2.5 2.5 0 100-5 2.5 2.5 0 000 5z"
clip-rule="evenodd" />
</svg>
</span>
}
</div>
</header>
<p class="text-sm text-slate-400 flex-grow">{{ bet().description }}</p>
<div>
<h4 class="text-xs font-semibold text-slate-500 uppercase mb-2">Partecipanti</h4>
<div class="flex items-center space-x-2">
<div class="flex -space-x-2 overflow-hidden">
@for (p of participants(); track getUserId(p.user)) {
@if (isUser(p.user)) {
<img class="inline-block h-8 w-8 rounded-full ring-2 ring-slate-800" [src]="p.user.avatarUrl"
[alt]="p.user.name" [title]="p.user.name + ' - Premio: ' + bet().stake">
} @else {
<div
class="inline-block h-8 w-8 rounded-full ring-2 ring-slate-800 bg-slate-700 flex items-center justify-center text-[10px] text-slate-400"
[title]="'Utente ID: ' + p.user">
U
</div>
}
}
</div>
@if (observers().length > 0) {
<div class="flex items-center text-slate-400" [title]="observerNamesTitle()">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path d="M10 12a2 2 0 100-4 2 2 0 000 4z" />
<path fill-rule="evenodd"
d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.022 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z"
clip-rule="evenodd" />
</svg>
<span class="ml-1 text-sm">{{ observers().length }}</span>
</div>
}
</div>
</div>
<footer class="border-t border-slate-700 pt-3 flex justify-between items-center text-sm text-slate-400">
<div class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1.5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd"
d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z"
clip-rule="evenodd" />
</svg>
<span>{{ bet().startDate | date:'dd MMM' }}</span>
@if (bet().endDate) {
<span class="mx-1.5">&rarr;</span>
<span>{{ bet().endDate | date:'dd MMM' }}</span>
}
</div>
<div class="font-semibold text-cyan-400">
{{ getTimeRemaining() }}
</div>
</footer>
</div>
}
@@ -0,0 +1,63 @@
import { Component, ChangeDetectionStrategy, input, computed } from '@angular/core';
import { CommonModule, DatePipe } from '@angular/common';
import { Bet, BetStatus, BetRole } from '../../../core/models/bet.model';
import { getUserId, getUserName, isUser } from '../../../core/utils/type-guards';
@Component({
selector: 'app-bet-card',
standalone: true,
imports: [CommonModule, DatePipe],
templateUrl: './bet-card.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class BetCardComponent {
protected getUserId = getUserId;
protected getUserName = getUserName;
protected isUser = isUser;
bet = input.required<Bet>();
participants = computed(() => this.bet().participants.filter(p => p.role === BetRole.Participant));
observers = computed(() => this.bet().participants.filter(p => p.role === BetRole.Observer));
observerNamesTitle = computed(() => {
const observers = this.observers();
if (observers.length === 0) {
return '';
}
const observerNames = observers.map(o => getUserName(o.user)).join(', ');
return 'Osservatori: ' + observerNames;
});
statusInfo = computed(() => {
const bet = this.bet();
switch (bet.status) {
case BetStatus.Active:
return { text: 'Attiva', color: 'bg-green-500' };
case BetStatus.AwaitingValidation:
return { text: 'In Convalida', color: 'bg-yellow-500' };
case BetStatus.Completed:
return { text: 'Completata', color: 'bg-blue-500' };
case BetStatus.Expired:
return { text: 'Scaduta', color: 'bg-red-500' };
default:
return { text: 'Sconosciuto', color: 'bg-gray-500' };
}
});
getTimeRemaining() {
if (!this.bet().endDate) {
return 'Senza scadenza';
}
const diff = this.bet().endDate!.getTime() - new Date().getTime();
if (diff < 0) {
return 'Scaduta';
}
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
if (days > 0) return `${days}g rimasti`;
if (hours > 0) return `${hours}h rimaste`;
return `${minutes}m rimasti`;
}
}
+33
View File
@@ -0,0 +1,33 @@
{
"compilerOptions": {
"target": "ES2022",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
"types": [
"node"
],
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": true,
"jsx": "react-jsx",
"paths": {
"@/*": [
"./*"
]
}
},
"files": [
"./index.tsx"
],
"angularCompilerOptions": {
"disableTypeScriptVersionCheck": true
}
}