Sostituito frontend-angular-new con frontend-angular
This commit is contained in:
@@ -9,3 +9,6 @@
|
||||
/frontend-angular/node_modules/
|
||||
/.claude/
|
||||
/.ai/mcp/mcp.json
|
||||
/frontend-angular-old/.angular/
|
||||
/frontend-angular-old/node_modules/
|
||||
/.ai/
|
||||
|
||||
+28
-12
@@ -1,20 +1,36 @@
|
||||
# Frontend Angular Dockerfile
|
||||
FROM node:20-alpine
|
||||
# ========================================
|
||||
# Frontend Angular Dockerfile (multi-stage)
|
||||
# ========================================
|
||||
|
||||
# Set working directory
|
||||
FROM node:20-alpine AS base
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
# ========================================
|
||||
# Development: ng serve con hot reload
|
||||
# ========================================
|
||||
FROM base AS development
|
||||
RUN npm install
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Expose port
|
||||
EXPOSE 4200
|
||||
|
||||
# Start development server
|
||||
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
|
||||
|
||||
# ========================================
|
||||
# Build: bundle statico di produzione
|
||||
# ========================================
|
||||
FROM base AS build
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
# Nota: la build SSR (server.ts/main.server.ts) non è al momento agganciata
|
||||
# ad angular.json ("server" builder option assente), quindi ng build produce
|
||||
# solo il bundle browser in dist/app/browser/. Se in futuro si abilita la SSR,
|
||||
# questo stage andrà aggiornato per servire dist/app/server con Node invece di nginx.
|
||||
|
||||
# ========================================
|
||||
# Production: bundle statico servito da nginx
|
||||
# ========================================
|
||||
FROM nginx:1.27-alpine AS production
|
||||
COPY --from=build /app/dist/app/browser /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
This contains everything you need to run your app locally.
|
||||
|
||||
View your app in AI Studio: https://ai.studio/apps/drive/1LXZWZLxW6p8pFAQ_ECq5pcD0utESimfc
|
||||
View your app in AI Studio: https://ai.studio/apps/7d951078-743c-4db5-ac20-6a2c625411e7
|
||||
|
||||
## Run Locally
|
||||
|
||||
|
||||
@@ -1,26 +1,59 @@
|
||||
{
|
||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||
"version": 1,
|
||||
"newProjectRoot": "",
|
||||
"cli": {
|
||||
"packageManager": "npm",
|
||||
"schematicCollections": [
|
||||
"angular-eslint"
|
||||
]
|
||||
},
|
||||
"newProjectRoot": "projects",
|
||||
"projects": {
|
||||
"app": {
|
||||
"projectType": "application",
|
||||
"schematics": {},
|
||||
"root": "",
|
||||
"sourceRoot": "./",
|
||||
"sourceRoot": "src",
|
||||
"prefix": "app",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular/build:application",
|
||||
"options": {
|
||||
"outputPath": {
|
||||
"base": "./dist",
|
||||
"browser": "."
|
||||
"define": {
|
||||
"GEMINI_API_KEY": "'YOUR_GEMINI_API_KEY'"
|
||||
},
|
||||
"browser": "index.tsx",
|
||||
"tsConfig": "tsconfig.json"
|
||||
"browser": "src/main.ts",
|
||||
"tsConfig": "tsconfig.app.json",
|
||||
"assets": [
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "public"
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
"src/styles.css"
|
||||
]
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "src/environments/environment.ts",
|
||||
"with": "src/environments/environment.prod.ts"
|
||||
}
|
||||
],
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "500kB",
|
||||
"maximumError": "1MB"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "4kB",
|
||||
"maximumError": "8kB"
|
||||
}
|
||||
],
|
||||
"outputHashing": "all"
|
||||
},
|
||||
"development": {
|
||||
@@ -33,9 +66,6 @@
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular/build:dev-server",
|
||||
"options": {
|
||||
"port": 4200
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"buildTarget": "app:build:production"
|
||||
@@ -45,6 +75,18 @@
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "development"
|
||||
},
|
||||
"test": {
|
||||
"builder": "@angular/build:unit-test"
|
||||
},
|
||||
"lint": {
|
||||
"builder": "@angular-eslint/builder:lint",
|
||||
"options": {
|
||||
"lintFilePatterns": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.html"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +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": []
|
||||
"name": "ScommettiApp",
|
||||
"description": "A social betting and challenge platform to compete with friends and join communities.",
|
||||
"requestFramePermissions": ["geolocation"]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
||||
|
||||
# Asset con hash nel nome: cache lunga
|
||||
location ~* "\.[0-9a-f]{8,}\.(js|css|svg|png|jpg|jpeg|gif|webp|woff2?)$" {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Fallback per il routing lato client di Angular
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
Generated
+6965
-2193
File diff suppressed because it is too large
Load Diff
@@ -9,20 +9,38 @@
|
||||
"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",
|
||||
"keycloak-js": "^24.0.0",
|
||||
"tailwindcss": "latest"
|
||||
"@angular/cdk": "^21.0.0",
|
||||
"@angular/common": "^21.0.0",
|
||||
"@angular/compiler": "^21.0.0",
|
||||
"@angular/core": "^21.0.0",
|
||||
"@angular/forms": "^21.0.0",
|
||||
"@angular/material": "^21.0.0",
|
||||
"@angular/platform-browser": "^21.0.0",
|
||||
"@angular/platform-server": "^21.0.0",
|
||||
"@angular/router": "^21.0.0",
|
||||
"@angular/ssr": "^21.0.0",
|
||||
"@google/genai": "^1.27.0",
|
||||
"express": "^5.1.0",
|
||||
"keycloak-js": "^26.2.3",
|
||||
"motion": "^12.23.24",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.14.0",
|
||||
"typescript": "~5.8.2",
|
||||
"vite": "^6.2.0"
|
||||
"@angular/build": "^21.0.0",
|
||||
"@angular/cli": "^21.0.0",
|
||||
"@angular/compiler-cli": "^21.0.0",
|
||||
"@tailwindcss/postcss": "^4.1.12",
|
||||
"@types/express": "^5.0.1",
|
||||
"@types/node": "^20.17.19",
|
||||
"angular-eslint": "21.1.0",
|
||||
"cross-env": "^10.1.0",
|
||||
"eslint": "^9.39.1",
|
||||
"jsdom": "^27.0.0",
|
||||
"postcss": "^8.5.3",
|
||||
"tailwindcss": "^4.1.12",
|
||||
"typescript": "~5.9.2",
|
||||
"typescript-eslint": "8.47.0",
|
||||
"vitest": "^4.0.0"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
@@ -0,0 +1,184 @@
|
||||
<div class="min-h-screen pb-32">
|
||||
<header class="sticky top-0 z-50 bg-background/80 backdrop-blur-md flex justify-between items-center px-6 py-4">
|
||||
<button class="w-10 h-10 rounded-full flex items-center justify-center hover:bg-surface-container transition-colors" [routerLink]="['/']">
|
||||
<mat-icon>arrow_back</mat-icon>
|
||||
</button>
|
||||
<button (click)="editChallenge()" class="w-10 h-10 rounded-full flex items-center justify-center hover:bg-surface-container transition-colors text-primary">
|
||||
<mat-icon>edit</mat-icon>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<main class="max-w-xl mx-auto px-6 pt-8 space-y-8">
|
||||
@if (isLoading()) {
|
||||
<div class="flex flex-col items-center justify-center py-20">
|
||||
<div class="w-12 h-12 border-4 border-primary border-t-transparent rounded-full animate-spin"></div>
|
||||
<p class="mt-4 text-on-surface-variant font-medium">{{ t('challenge.loading') }}</p>
|
||||
</div>
|
||||
} @else if (bet(); as data) {
|
||||
<!-- Hero Section -->
|
||||
<section class="space-y-4">
|
||||
<div class="flex justify-between items-start">
|
||||
<div class="space-y-1">
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full bg-secondary-container text-secondary text-xs font-semibold tracking-wide uppercase">
|
||||
{{ data.status }}
|
||||
</span>
|
||||
<h2 class="text-3xl font-headline font-extrabold">{{ data.title }}</h2>
|
||||
</div>
|
||||
<div class="bg-surface-container p-3 rounded-2xl flex flex-col items-center">
|
||||
<span class="text-[10px] font-bold text-on-surface-variant uppercase tracking-widest">{{ t('challenge.stake') }}</span>
|
||||
<span class="text-xl font-headline font-black text-primary">{{ data.stake }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-on-surface-variant text-lg">{{ data.description || t('challenge.no_description') }}</p>
|
||||
</section>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="bg-surface-container-low p-6 rounded-2xl flex flex-col gap-1 border border-black/5">
|
||||
<span class="text-xs font-medium text-on-surface-variant">{{ t('challenge.start_date') }}</span>
|
||||
<span class="text-sm font-headline font-bold">{{ data.startDate | date:'mediumDate' }}</span>
|
||||
</div>
|
||||
<div class="bg-surface-container-low p-6 rounded-2xl flex flex-col gap-1 border border-black/5">
|
||||
<span class="text-xs font-medium text-on-surface-variant">{{ t('challenge.end_date') }}</span>
|
||||
<span class="text-sm font-headline font-bold">{{ data.endDate ? (data.endDate | date:'mediumDate') : t('challenge.end_date_indefinite') }}</span>
|
||||
</div>
|
||||
<div class="bg-surface-container-low p-6 rounded-2xl flex flex-col gap-1 border border-black/5">
|
||||
<span class="text-xs font-medium text-on-surface-variant">{{ t('challenge.visibility') }}</span>
|
||||
<span class="text-sm font-headline font-bold">{{ data.isPublic ? t('challenge.visibility.public') : t('challenge.visibility.private') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Participants -->
|
||||
<section class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="font-headline font-bold">{{ t('challenge.participants') }}</h3>
|
||||
<span class="text-sm font-medium text-primary">{{ data.participants.length }} {{ t('challenge.joined') }}</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
@for (p of data.participants; track $index) {
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<div class="relative">
|
||||
@if (getParticipantUser(p)?.avatarUrl) {
|
||||
<img [src]="getParticipantUser(p)!.avatarUrl" class="h-12 w-12 rounded-full ring-2 ring-primary/20 object-cover" [alt]="getParticipantUser(p)!.name">
|
||||
} @else {
|
||||
<div class="h-12 w-12 rounded-full ring-2 ring-primary/20 bg-primary-container/20 flex items-center justify-center">
|
||||
<mat-icon class="text-primary">person</mat-icon>
|
||||
</div>
|
||||
}
|
||||
@if (p.role === 'OBSERVER') {
|
||||
<div class="absolute -bottom-1 -right-1 bg-surface-container rounded-full p-0.5 shadow-sm">
|
||||
<mat-icon class="text-[10px] text-on-surface-variant">visibility</mat-icon>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<span class="text-[10px] font-medium text-on-surface-variant">{{ getParticipantUser(p)?.name || 'Utente' }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
} @else {
|
||||
<div class="flex flex-col items-center justify-center py-20 text-center">
|
||||
<mat-icon class="text-6xl text-on-surface-variant/20 mb-4">search_off</mat-icon>
|
||||
<h3 class="text-xl font-headline font-bold">{{ t('challenge.not_found.title') }}</h3>
|
||||
<p class="text-on-surface-variant mt-2">{{ t('challenge.not_found.description') }}</p>
|
||||
<button class="mt-6 text-primary font-bold" [routerLink]="['/']">{{ t('challenge.not_found.back') }}</button>
|
||||
</div>
|
||||
}
|
||||
</main>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="fixed bottom-0 left-0 w-full p-6 bg-background/80 backdrop-blur-xl z-40 border-t border-black/5">
|
||||
<div class="max-w-xl mx-auto flex flex-col gap-3">
|
||||
<button
|
||||
(click)="editChallenge()"
|
||||
class="w-full py-4 bg-gradient-to-r from-primary to-primary-container text-white font-headline font-bold rounded-full shadow-lg active:scale-95 transition-transform">
|
||||
{{ t('challenge.update_progress') }}
|
||||
</button>
|
||||
<button
|
||||
(click)="openInviteSheet()"
|
||||
class="w-full py-4 bg-surface-container-highest text-on-surface font-headline font-bold rounded-full active:scale-95 transition-transform flex items-center justify-center gap-2">
|
||||
<mat-icon>person_add</mat-icon>
|
||||
{{ t('challenge.invite_friends') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Invite Friends Bottom Sheet -->
|
||||
@if (showInviteSheet()) {
|
||||
<div class="fixed inset-0 z-50 flex flex-col justify-end">
|
||||
<div class="absolute inset-0 bg-black/40 backdrop-blur-sm" (click)="closeInviteSheet()"></div>
|
||||
|
||||
<div
|
||||
class="relative bg-background shadow-2xl flex flex-col transition-all duration-300 ease-out"
|
||||
[class]="inviteSheetExpanded() ? 'fixed inset-0 rounded-none h-full' : 'rounded-t-3xl h-[65vh]'">
|
||||
|
||||
<!-- Handle -->
|
||||
<div
|
||||
class="flex justify-center pt-3 pb-1 cursor-grab touch-none select-none"
|
||||
(touchstart)="onHandleTouchStart($event)"
|
||||
(touchend)="onHandleTouchEnd($event)">
|
||||
<div class="w-10 h-1.5 rounded-full bg-on-surface/20"></div>
|
||||
</div>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between px-6 py-3">
|
||||
<h3 class="text-lg font-headline font-bold">{{ t('challenge.invite_friends') }} ({{ friends().length }})</h3>
|
||||
<button (click)="closeInviteSheet()" class="w-8 h-8 flex items-center justify-center rounded-full hover:bg-surface-container transition-colors">
|
||||
<mat-icon class="text-on-surface-variant">close</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="px-6 pb-4">
|
||||
<div class="flex items-center gap-3 bg-surface-container rounded-2xl px-4 py-3">
|
||||
<mat-icon class="text-on-surface-variant flex-none">search</mat-icon>
|
||||
<input
|
||||
type="text"
|
||||
[placeholder]="t('common.search') + '...'"
|
||||
class="flex-1 bg-transparent text-sm outline-none text-on-surface placeholder:text-on-surface-variant"
|
||||
[ngModel]="friendsSearchQuery()"
|
||||
(ngModelChange)="friendsSearchQuery.set($event)"
|
||||
autofocus
|
||||
>
|
||||
@if (friendsSearchQuery().trim()) {
|
||||
<button (click)="friendsSearchQuery.set('')" class="flex-none">
|
||||
<mat-icon class="text-on-surface-variant text-lg">close</mat-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Friends List -->
|
||||
<div class="overflow-y-auto flex-1 px-6 pb-8 space-y-1">
|
||||
@if (filteredFriends().length > 0) {
|
||||
@for (friend of filteredFriends(); track friend.id) {
|
||||
<div class="w-full flex items-center gap-3 p-3 rounded-2xl"
|
||||
[class]="isAlreadyParticipant(friend) ? 'opacity-50' : ''">
|
||||
<div class="w-11 h-11 rounded-full bg-primary-container/20 flex items-center justify-center flex-none overflow-hidden">
|
||||
@if (friend.avatarUrl) {
|
||||
<img [src]="friend.avatarUrl" class="w-full h-full object-cover" [alt]="friend.name">
|
||||
} @else {
|
||||
<mat-icon class="text-primary">person</mat-icon>
|
||||
}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-semibold text-sm truncate">{{ friend.name }}</p>
|
||||
<p class="text-xs text-on-surface-variant truncate">{{ friend.email }}</p>
|
||||
</div>
|
||||
@if (isAlreadyParticipant(friend)) {
|
||||
<mat-icon class="text-secondary flex-none text-lg">check_circle</mat-icon>
|
||||
} @else {
|
||||
<mat-icon class="text-primary flex-none text-lg">person_add</mat-icon>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
} @else if (friendsSearchQuery().trim()) {
|
||||
<p class="text-sm text-on-surface-variant text-center py-8">Nessun amico trovato</p>
|
||||
} @else {
|
||||
<p class="text-sm text-on-surface-variant text-center py-8">Non hai ancora nessun amico</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import {ChangeDetectionStrategy, Component, inject, signal, OnInit} from '@angular/core';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {FormsModule} from '@angular/forms';
|
||||
import {MatIconModule} from '@angular/material/icon';
|
||||
import {RouterModule, Router, ActivatedRoute} from '@angular/router';
|
||||
import {BetService} from '../../services/bet';
|
||||
import {FriendService} from '../../services/friend.service';
|
||||
import {Bet, BetParticipant, User} from '../../models/data.models';
|
||||
import {LanguageService} from '../../services/language';
|
||||
|
||||
@Component({
|
||||
selector: 'app-challenge-detail',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, MatIconModule, RouterModule],
|
||||
templateUrl: './challenge-detail.html',
|
||||
styleUrl: './challenge-detail.css',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ChallengeDetail implements OnInit {
|
||||
private router = inject(Router);
|
||||
private route = inject(ActivatedRoute);
|
||||
private betService = inject(BetService);
|
||||
private friendService = inject(FriendService);
|
||||
private langService = inject(LanguageService);
|
||||
|
||||
challengeId = this.route.snapshot.paramMap.get('id');
|
||||
bet = signal<Bet | null>(null);
|
||||
isLoading = signal(true);
|
||||
|
||||
friends = signal<User[]>([]);
|
||||
showInviteSheet = signal(false);
|
||||
inviteSheetExpanded = signal(false);
|
||||
friendsSearchQuery = signal('');
|
||||
|
||||
private touchStartY = 0;
|
||||
|
||||
filteredFriends(): User[] {
|
||||
const q = this.friendsSearchQuery().toLowerCase().trim();
|
||||
if (!q) return this.friends();
|
||||
return this.friends().filter(f =>
|
||||
f.name.toLowerCase().includes(q) || f.email?.toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
|
||||
isAlreadyParticipant(friend: User): boolean {
|
||||
const participants = this.bet()?.participants ?? [];
|
||||
return participants.some(p => {
|
||||
const user = this.getParticipantUser(p);
|
||||
return user?.id === friend.id;
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
if (this.challengeId) {
|
||||
this.betService.getBetById(this.challengeId).subscribe({
|
||||
next: (data) => {
|
||||
this.bet.set(data);
|
||||
this.isLoading.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Error loading bet:', err);
|
||||
this.isLoading.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.friendService.getFriends().subscribe({
|
||||
next: (friends) => this.friends.set(friends),
|
||||
error: (err) => console.error('Error loading friends:', err),
|
||||
});
|
||||
}
|
||||
|
||||
openInviteSheet() {
|
||||
this.friendsSearchQuery.set('');
|
||||
this.inviteSheetExpanded.set(false);
|
||||
this.showInviteSheet.set(true);
|
||||
}
|
||||
|
||||
closeInviteSheet() {
|
||||
this.showInviteSheet.set(false);
|
||||
this.inviteSheetExpanded.set(false);
|
||||
}
|
||||
|
||||
onHandleTouchStart(event: TouchEvent) {
|
||||
this.touchStartY = event.touches[0].clientY;
|
||||
}
|
||||
|
||||
onHandleTouchEnd(event: TouchEvent) {
|
||||
const deltaY = this.touchStartY - event.changedTouches[0].clientY;
|
||||
if (deltaY > 40) {
|
||||
this.inviteSheetExpanded.set(true);
|
||||
} else if (deltaY < -40) {
|
||||
if (this.inviteSheetExpanded()) {
|
||||
this.inviteSheetExpanded.set(false);
|
||||
} else {
|
||||
this.closeInviteSheet();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
t(key: string): string {
|
||||
return this.langService.translate(key);
|
||||
}
|
||||
|
||||
getParticipantUser(p: BetParticipant): User | null {
|
||||
return typeof p.user === 'object' && p.user !== null ? p.user as User : null;
|
||||
}
|
||||
|
||||
editChallenge() {
|
||||
const data = this.bet();
|
||||
if (data) {
|
||||
this.router.navigate(['/create-bet'], {
|
||||
state: { bet: data }
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
-40
@@ -25,28 +25,16 @@
|
||||
<span class="text-white/80 font-medium text-sm uppercase tracking-wider">Season Performance</span>
|
||||
<h2 class="text-4xl font-headline font-extrabold mt-1">{{ t('home.total_wins') }}: {{ userProfile().totalWins }}</h2>
|
||||
</div>
|
||||
<div class="flex items-center justify-between mt-4">
|
||||
<div class="flex -space-x-3">
|
||||
<div class="w-10 h-10 rounded-full border-2 border-primary bg-surface-container flex items-center justify-center text-xs text-primary font-bold">JD</div>
|
||||
<div class="w-10 h-10 rounded-full border-2 border-primary overflow-hidden">
|
||||
<img src="https://picsum.photos/seed/friend1/100/100" class="w-full h-full object-cover" alt="Friend Avatar">
|
||||
</div>
|
||||
<div class="w-10 h-10 rounded-full border-2 border-primary bg-primary-container flex items-center justify-center text-xs">+12</div>
|
||||
</div>
|
||||
<span class="bg-white/20 px-4 py-1.5 rounded-full text-sm font-semibold backdrop-blur-md">Top 5% Rank</span>
|
||||
<div class="flex justify-end mt-4">
|
||||
<span class="bg-white/20 px-4 py-1.5 rounded-full text-sm font-semibold backdrop-blur-md">Top {{ userProfile().rankPercentile }}% Rank</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-secondary-container p-6 rounded-2xl soft-shadow flex flex-col justify-between">
|
||||
<div>
|
||||
<span class="text-secondary font-bold text-sm">{{ t('profile.balance') }}</span>
|
||||
<div class="bg-secondary-container p-6 rounded-2xl soft-shadow flex flex-col justify-center">
|
||||
<span class="text-secondary font-bold text-sm">{{ t('home.score') }}</span>
|
||||
<div class="flex items-baseline gap-1 mt-2">
|
||||
<span class="text-3xl font-headline font-black text-on-secondary-container">${{ userProfile().balance }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white/40 p-3 rounded-2xl flex items-center gap-3">
|
||||
<mat-icon class="text-secondary">trending_up</mat-icon>
|
||||
<p class="text-xs font-semibold text-secondary">{{ t('profile.win_streak') }}: {{ userProfile().winStreak }}</p>
|
||||
<span class="text-3xl font-headline font-black text-on-secondary-container">{{ userProfile().score }}</span>
|
||||
<span class="text-sm font-semibold text-secondary">pts</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -72,35 +60,20 @@
|
||||
@for (challenge of filteredChallenges(); track challenge.id) {
|
||||
<div class="bg-surface p-6 rounded-2xl soft-shadow relative overflow-hidden border border-black/5" [routerLink]="['/challenge-detail', challenge.id]">
|
||||
<div class="flex justify-between items-start mb-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-12 h-12 rounded-2xl bg-secondary/10 flex items-center justify-center">
|
||||
<mat-icon class="text-secondary">
|
||||
{{ challenge.goalType === 'steps' ? 'footprint' : challenge.goalType === 'tasks' ? 'task_alt' : 'trending_up' }}
|
||||
</mat-icon>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-bold">{{ challenge.title }}</h4>
|
||||
<p class="text-xs text-on-surface-variant">{{ challenge.description }}</p>
|
||||
<p class="text-xs text-on-surface-variant mt-0.5">{{ challenge.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="px-3 py-1 rounded-full bg-secondary-container text-secondary text-[10px] font-black uppercase tracking-widest">
|
||||
<span class="px-3 py-1 rounded-full bg-secondary-container text-secondary text-[10px] font-black uppercase tracking-widest shrink-0 ml-3">
|
||||
{{ challenge.status }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<div class="flex justify-between text-xs font-bold mb-1.5">
|
||||
<span class="text-on-surface-variant">Target</span>
|
||||
<span class="text-secondary">{{ challenge.target }} {{ challenge.goalType }}</span>
|
||||
</div>
|
||||
<div class="w-full h-3 bg-surface-container rounded-full overflow-hidden">
|
||||
<div class="h-full bg-secondary rounded-full" [style.width.%]="challenge.status === 'completed' ? 100 : challenge.status === 'in-progress' ? 65 : 0"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between items-center pt-2">
|
||||
<div class="text-sm font-bold">Prize Pool</div>
|
||||
<div class="text-lg font-headline font-black text-primary">${{ challenge.prizePool }}</div>
|
||||
<div class="flex justify-between items-center pt-2 border-t border-black/5">
|
||||
<div class="flex items-center gap-1.5 text-xs text-on-surface-variant">
|
||||
<mat-icon class="text-sm">group</mat-icon>
|
||||
<span>{{ challenge.participantsCount }}</span>
|
||||
</div>
|
||||
<div class="text-lg font-headline font-black text-primary">{{ challenge.stake }}</div>
|
||||
</div>
|
||||
</div>
|
||||
} @empty {
|
||||
+6
-8
@@ -27,7 +27,7 @@ export class Home {
|
||||
|
||||
userProfile = computed<UserProfile>(() => {
|
||||
const user = this.currentUser();
|
||||
if (!user) return {id: '', name: '', username: '', avatar: '', balance: 0, winStreak: 0, totalWins: 0};
|
||||
if (!user) return {id: '', name: '', username: '', avatar: '', score: 0, winStreak: 0, totalWins: 0, rankPercentile: 0};
|
||||
return this.mapUserToProfile(user);
|
||||
});
|
||||
|
||||
@@ -69,9 +69,10 @@ export class Home {
|
||||
name: user.name,
|
||||
username: user.email,
|
||||
avatar: user.avatarUrl,
|
||||
balance: 1250, // Mock balance
|
||||
winStreak: 3, // Mock streak
|
||||
totalWins: user.score
|
||||
score: user.score,
|
||||
winStreak: 0,
|
||||
totalWins: user.score,
|
||||
rankPercentile: user.rankPercentile ?? 0
|
||||
};
|
||||
}
|
||||
|
||||
@@ -82,16 +83,13 @@ export class Home {
|
||||
'COMPLETED': 'completed',
|
||||
'EXPIRED': 'completed'
|
||||
};
|
||||
const prize = parseFloat(bet.stake.replace(/[^0-9.]/g, '')) || 0;
|
||||
return {
|
||||
id: bet.id,
|
||||
title: bet.title,
|
||||
description: bet.description ?? '',
|
||||
target: 100, // Default target
|
||||
deadline: bet.endDate ?? '',
|
||||
goalType: 'tasks',
|
||||
status: statusMap[bet.status] ?? 'pending',
|
||||
prizePool: prize,
|
||||
stake: bet.stake,
|
||||
participantsCount: bet.participants.length,
|
||||
createdAt: bet.startDate
|
||||
};
|
||||
@@ -19,6 +19,7 @@
|
||||
"groups.create": "Create Group",
|
||||
"groups.members": "Members",
|
||||
"profile.balance": "Balance",
|
||||
"home.score": "Score",
|
||||
"profile.win_streak": "Win Streak",
|
||||
"profile.streak": "Streak",
|
||||
"profile.losses": "Losses",
|
||||
@@ -43,5 +44,21 @@
|
||||
"common.search": "Search",
|
||||
"common.edit": "Edit",
|
||||
"common.delete": "Delete",
|
||||
"common.confirm": "Confirm"
|
||||
"common.confirm": "Confirm",
|
||||
"challenge.loading": "Loading details...",
|
||||
"challenge.stake": "Stake",
|
||||
"challenge.no_description": "No description provided.",
|
||||
"challenge.start_date": "Start Date",
|
||||
"challenge.end_date": "End Date",
|
||||
"challenge.end_date_indefinite": "Indefinite",
|
||||
"challenge.visibility": "Visibility",
|
||||
"challenge.visibility.public": "Public",
|
||||
"challenge.visibility.private": "Private",
|
||||
"challenge.participants": "Participants",
|
||||
"challenge.joined": "Joined",
|
||||
"challenge.not_found.title": "Challenge Not Found",
|
||||
"challenge.not_found.description": "The challenge you're looking for doesn't exist or has been removed.",
|
||||
"challenge.not_found.back": "Go Back Home",
|
||||
"challenge.update_progress": "Update Progress",
|
||||
"challenge.invite_friends": "Invite Friends"
|
||||
}
|
||||
@@ -19,6 +19,7 @@
|
||||
"groups.create": "Crea Gruppo",
|
||||
"groups.members": "Membri",
|
||||
"profile.balance": "Bilancio",
|
||||
"home.score": "Punteggio",
|
||||
"profile.win_streak": "Striscia Vincente",
|
||||
"profile.streak": "Striscia",
|
||||
"profile.losses": "Perdite",
|
||||
@@ -43,5 +44,21 @@
|
||||
"common.search": "Cerca",
|
||||
"common.edit": "Modifica",
|
||||
"common.delete": "Elimina",
|
||||
"common.confirm": "Conferma"
|
||||
"common.confirm": "Conferma",
|
||||
"challenge.loading": "Caricamento dettagli...",
|
||||
"challenge.stake": "Posta",
|
||||
"challenge.no_description": "Nessuna descrizione fornita.",
|
||||
"challenge.start_date": "Data Inizio",
|
||||
"challenge.end_date": "Data Fine",
|
||||
"challenge.end_date_indefinite": "Indefinita",
|
||||
"challenge.visibility": "Visibilità",
|
||||
"challenge.visibility.public": "Pubblica",
|
||||
"challenge.visibility.private": "Privata",
|
||||
"challenge.participants": "Partecipanti",
|
||||
"challenge.joined": "Iscritti",
|
||||
"challenge.not_found.title": "Sfida Non Trovata",
|
||||
"challenge.not_found.description": "La sfida che stai cercando non esiste o è stata rimossa.",
|
||||
"challenge.not_found.back": "Torna alla Home",
|
||||
"challenge.update_progress": "Aggiorna Progressi",
|
||||
"challenge.invite_friends": "Invita Amici"
|
||||
}
|
||||
+4
-4
@@ -4,11 +4,9 @@ export interface ChallengeData {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
target: number;
|
||||
deadline: string;
|
||||
goalType: 'steps' | 'tasks' | 'predictions';
|
||||
status: 'in-progress' | 'completed' | 'pending';
|
||||
prizePool: number;
|
||||
stake: string;
|
||||
participantsCount: number;
|
||||
createdAt: string;
|
||||
}
|
||||
@@ -38,9 +36,10 @@ export interface UserProfile {
|
||||
name: string;
|
||||
username: string;
|
||||
avatar: string;
|
||||
balance: number;
|
||||
score: number;
|
||||
winStreak: number;
|
||||
totalWins: number;
|
||||
rankPercentile: number;
|
||||
}
|
||||
|
||||
// ─── Modelli Backend (usati nei servizi HTTP) ────────────────────────────────
|
||||
@@ -91,6 +90,7 @@ export interface User {
|
||||
email: string;
|
||||
avatarUrl: string;
|
||||
score: number;
|
||||
rankPercentile?: number;
|
||||
}
|
||||
|
||||
export interface Group {
|
||||
+1
-1
@@ -40,7 +40,7 @@ export class BetService {
|
||||
}
|
||||
|
||||
getBetById(id: string): Observable<Bet> {
|
||||
return this.http.get<SuccessResponse<Bet[]>>(`${this.base}/${id}`).pipe(
|
||||
return this.http.get<SuccessResponse<Bet>>(`${this.base}/${id}`).pipe(
|
||||
map(res => mapBet(res.data))
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// Dominio unico con routing per path (Caddy): frontend, /api e /auth
|
||||
// sono serviti dalla stessa origin, quindi bastano URL relativi.
|
||||
export const environment = {
|
||||
production: true,
|
||||
apiUrl: '',
|
||||
keycloakUrl: '/auth',
|
||||
keycloakRealm: 'scommettiapp',
|
||||
keycloakClientId: 'scommetti-frontend',
|
||||
};
|
||||
@@ -1,33 +1,35 @@
|
||||
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||
{
|
||||
"compileOnSave": false,
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"experimentalDecorators": true,
|
||||
"useDefineForClassFields": false,
|
||||
"module": "ESNext",
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"skipLibCheck": true,
|
||||
"types": [
|
||||
"node"
|
||||
],
|
||||
"moduleResolution": "bundler",
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"allowJs": true,
|
||||
"jsx": "react-jsx",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
}
|
||||
"resolveJsonModule": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"experimentalDecorators": true,
|
||||
"importHelpers": true,
|
||||
"target": "ES2022",
|
||||
"module": "preserve"
|
||||
},
|
||||
"files": [
|
||||
"./index.tsx"
|
||||
],
|
||||
"angularCompilerOptions": {
|
||||
"disableTypeScriptVersionCheck": true
|
||||
"enableI18nLegacyMessageIdFormat": false,
|
||||
"strictInjectionParameters": true,
|
||||
"strictInputAccessModifiers": true,
|
||||
"strictTemplates": true
|
||||
},
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user