Sostituito frontend-angular-new con frontend-angular
This commit is contained in:
@@ -9,3 +9,6 @@
|
|||||||
/frontend-angular/node_modules/
|
/frontend-angular/node_modules/
|
||||||
/.claude/
|
/.claude/
|
||||||
/.ai/mcp/mcp.json
|
/.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
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy package files
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
# Install dependencies
|
# ========================================
|
||||||
|
# Development: ng serve con hot reload
|
||||||
|
# ========================================
|
||||||
|
FROM base AS development
|
||||||
RUN npm install
|
RUN npm install
|
||||||
|
|
||||||
# Copy application code
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Expose port
|
|
||||||
EXPOSE 4200
|
EXPOSE 4200
|
||||||
|
|
||||||
# Start development server
|
|
||||||
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
|
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.
|
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
|
## Run Locally
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +1,59 @@
|
|||||||
{
|
{
|
||||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"newProjectRoot": "",
|
"cli": {
|
||||||
|
"packageManager": "npm",
|
||||||
|
"schematicCollections": [
|
||||||
|
"angular-eslint"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"newProjectRoot": "projects",
|
||||||
"projects": {
|
"projects": {
|
||||||
"app": {
|
"app": {
|
||||||
"projectType": "application",
|
"projectType": "application",
|
||||||
|
"schematics": {},
|
||||||
"root": "",
|
"root": "",
|
||||||
"sourceRoot": "./",
|
"sourceRoot": "src",
|
||||||
"prefix": "app",
|
"prefix": "app",
|
||||||
"architect": {
|
"architect": {
|
||||||
"build": {
|
"build": {
|
||||||
"builder": "@angular/build:application",
|
"builder": "@angular/build:application",
|
||||||
"options": {
|
"options": {
|
||||||
"outputPath": {
|
"define": {
|
||||||
"base": "./dist",
|
"GEMINI_API_KEY": "'YOUR_GEMINI_API_KEY'"
|
||||||
"browser": "."
|
|
||||||
},
|
},
|
||||||
"browser": "index.tsx",
|
"browser": "src/main.ts",
|
||||||
"tsConfig": "tsconfig.json"
|
"tsConfig": "tsconfig.app.json",
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"glob": "**/*",
|
||||||
|
"input": "public"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"styles": [
|
||||||
|
"src/styles.css"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"production": {
|
"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"
|
"outputHashing": "all"
|
||||||
},
|
},
|
||||||
"development": {
|
"development": {
|
||||||
@@ -33,9 +66,6 @@
|
|||||||
},
|
},
|
||||||
"serve": {
|
"serve": {
|
||||||
"builder": "@angular/build:dev-server",
|
"builder": "@angular/build:dev-server",
|
||||||
"options": {
|
|
||||||
"port": 4200
|
|
||||||
},
|
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"production": {
|
"production": {
|
||||||
"buildTarget": "app:build:production"
|
"buildTarget": "app:build:production"
|
||||||
@@ -45,8 +75,20 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"defaultConfiguration": "development"
|
"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",
|
"name": "ScommettiApp",
|
||||||
"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.",
|
"description": "A social betting and challenge platform to compete with friends and join communities.",
|
||||||
"requestFramePermissions": []
|
"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
+6966
-2194
File diff suppressed because it is too large
Load Diff
@@ -9,20 +9,38 @@
|
|||||||
"preview": "ng serve --configuration=production"
|
"preview": "ng serve --configuration=production"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"rxjs": "^7.8.2",
|
"@angular/cdk": "^21.0.0",
|
||||||
"@angular/compiler": "^20.3.0",
|
"@angular/common": "^21.0.0",
|
||||||
"@angular/platform-browser": "^20.3.0",
|
"@angular/compiler": "^21.0.0",
|
||||||
"@angular/common": "^20.3.0",
|
"@angular/core": "^21.0.0",
|
||||||
"@angular/core": "^20.3.0",
|
"@angular/forms": "^21.0.0",
|
||||||
"@angular/build": "^20.3.0",
|
"@angular/material": "^21.0.0",
|
||||||
"@angular/cli": "^20.3.0",
|
"@angular/platform-browser": "^21.0.0",
|
||||||
"@angular/compiler-cli": "^20.3.0",
|
"@angular/platform-server": "^21.0.0",
|
||||||
"keycloak-js": "^24.0.0",
|
"@angular/router": "^21.0.0",
|
||||||
"tailwindcss": "latest"
|
"@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": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.14.0",
|
"@angular/build": "^21.0.0",
|
||||||
"typescript": "~5.8.2",
|
"@angular/cli": "^21.0.0",
|
||||||
"vite": "^6.2.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 }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+16
-43
@@ -25,28 +25,16 @@
|
|||||||
<span class="text-white/80 font-medium text-sm uppercase tracking-wider">Season Performance</span>
|
<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>
|
<h2 class="text-4xl font-headline font-extrabold mt-1">{{ t('home.total_wins') }}: {{ userProfile().totalWins }}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between mt-4">
|
<div class="flex justify-end mt-4">
|
||||||
<div class="flex -space-x-3">
|
<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 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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-secondary-container p-6 rounded-2xl soft-shadow flex flex-col justify-between">
|
<div class="bg-secondary-container p-6 rounded-2xl soft-shadow flex flex-col justify-center">
|
||||||
<div>
|
<span class="text-secondary font-bold text-sm">{{ t('home.score') }}</span>
|
||||||
<span class="text-secondary font-bold text-sm">{{ t('profile.balance') }}</span>
|
<div class="flex items-baseline gap-1 mt-2">
|
||||||
<div class="flex items-baseline gap-1 mt-2">
|
<span class="text-3xl font-headline font-black text-on-secondary-container">{{ userProfile().score }}</span>
|
||||||
<span class="text-3xl font-headline font-black text-on-secondary-container">${{ userProfile().balance }}</span>
|
<span class="text-sm font-semibold text-secondary">pts</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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -72,35 +60,20 @@
|
|||||||
@for (challenge of filteredChallenges(); track challenge.id) {
|
@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="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 justify-between items-start mb-4">
|
||||||
<div class="flex items-center gap-3">
|
<div>
|
||||||
<div class="w-12 h-12 rounded-2xl bg-secondary/10 flex items-center justify-center">
|
<h4 class="font-bold">{{ challenge.title }}</h4>
|
||||||
<mat-icon class="text-secondary">
|
<p class="text-xs text-on-surface-variant mt-0.5">{{ challenge.description }}</p>
|
||||||
{{ 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>
|
|
||||||
</div>
|
|
||||||
</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 }}
|
{{ challenge.status }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-4">
|
<div class="flex justify-between items-center pt-2 border-t border-black/5">
|
||||||
<div>
|
<div class="flex items-center gap-1.5 text-xs text-on-surface-variant">
|
||||||
<div class="flex justify-between text-xs font-bold mb-1.5">
|
<mat-icon class="text-sm">group</mat-icon>
|
||||||
<span class="text-on-surface-variant">Target</span>
|
<span>{{ challenge.participantsCount }}</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>
|
</div>
|
||||||
|
<div class="text-lg font-headline font-black text-primary">{{ challenge.stake }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
} @empty {
|
} @empty {
|
||||||
+6
-8
@@ -27,7 +27,7 @@ export class Home {
|
|||||||
|
|
||||||
userProfile = computed<UserProfile>(() => {
|
userProfile = computed<UserProfile>(() => {
|
||||||
const user = this.currentUser();
|
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);
|
return this.mapUserToProfile(user);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -69,9 +69,10 @@ export class Home {
|
|||||||
name: user.name,
|
name: user.name,
|
||||||
username: user.email,
|
username: user.email,
|
||||||
avatar: user.avatarUrl,
|
avatar: user.avatarUrl,
|
||||||
balance: 1250, // Mock balance
|
score: user.score,
|
||||||
winStreak: 3, // Mock streak
|
winStreak: 0,
|
||||||
totalWins: user.score
|
totalWins: user.score,
|
||||||
|
rankPercentile: user.rankPercentile ?? 0
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,16 +83,13 @@ export class Home {
|
|||||||
'COMPLETED': 'completed',
|
'COMPLETED': 'completed',
|
||||||
'EXPIRED': 'completed'
|
'EXPIRED': 'completed'
|
||||||
};
|
};
|
||||||
const prize = parseFloat(bet.stake.replace(/[^0-9.]/g, '')) || 0;
|
|
||||||
return {
|
return {
|
||||||
id: bet.id,
|
id: bet.id,
|
||||||
title: bet.title,
|
title: bet.title,
|
||||||
description: bet.description ?? '',
|
description: bet.description ?? '',
|
||||||
target: 100, // Default target
|
|
||||||
deadline: bet.endDate ?? '',
|
deadline: bet.endDate ?? '',
|
||||||
goalType: 'tasks',
|
|
||||||
status: statusMap[bet.status] ?? 'pending',
|
status: statusMap[bet.status] ?? 'pending',
|
||||||
prizePool: prize,
|
stake: bet.stake,
|
||||||
participantsCount: bet.participants.length,
|
participantsCount: bet.participants.length,
|
||||||
createdAt: bet.startDate
|
createdAt: bet.startDate
|
||||||
};
|
};
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
"groups.create": "Create Group",
|
"groups.create": "Create Group",
|
||||||
"groups.members": "Members",
|
"groups.members": "Members",
|
||||||
"profile.balance": "Balance",
|
"profile.balance": "Balance",
|
||||||
|
"home.score": "Score",
|
||||||
"profile.win_streak": "Win Streak",
|
"profile.win_streak": "Win Streak",
|
||||||
"profile.streak": "Streak",
|
"profile.streak": "Streak",
|
||||||
"profile.losses": "Losses",
|
"profile.losses": "Losses",
|
||||||
@@ -43,5 +44,21 @@
|
|||||||
"common.search": "Search",
|
"common.search": "Search",
|
||||||
"common.edit": "Edit",
|
"common.edit": "Edit",
|
||||||
"common.delete": "Delete",
|
"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.create": "Crea Gruppo",
|
||||||
"groups.members": "Membri",
|
"groups.members": "Membri",
|
||||||
"profile.balance": "Bilancio",
|
"profile.balance": "Bilancio",
|
||||||
|
"home.score": "Punteggio",
|
||||||
"profile.win_streak": "Striscia Vincente",
|
"profile.win_streak": "Striscia Vincente",
|
||||||
"profile.streak": "Striscia",
|
"profile.streak": "Striscia",
|
||||||
"profile.losses": "Perdite",
|
"profile.losses": "Perdite",
|
||||||
@@ -43,5 +44,21 @@
|
|||||||
"common.search": "Cerca",
|
"common.search": "Cerca",
|
||||||
"common.edit": "Modifica",
|
"common.edit": "Modifica",
|
||||||
"common.delete": "Elimina",
|
"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;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
target: number;
|
|
||||||
deadline: string;
|
deadline: string;
|
||||||
goalType: 'steps' | 'tasks' | 'predictions';
|
|
||||||
status: 'in-progress' | 'completed' | 'pending';
|
status: 'in-progress' | 'completed' | 'pending';
|
||||||
prizePool: number;
|
stake: string;
|
||||||
participantsCount: number;
|
participantsCount: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
@@ -38,9 +36,10 @@ export interface UserProfile {
|
|||||||
name: string;
|
name: string;
|
||||||
username: string;
|
username: string;
|
||||||
avatar: string;
|
avatar: string;
|
||||||
balance: number;
|
score: number;
|
||||||
winStreak: number;
|
winStreak: number;
|
||||||
totalWins: number;
|
totalWins: number;
|
||||||
|
rankPercentile: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Modelli Backend (usati nei servizi HTTP) ────────────────────────────────
|
// ─── Modelli Backend (usati nei servizi HTTP) ────────────────────────────────
|
||||||
@@ -91,6 +90,7 @@ export interface User {
|
|||||||
email: string;
|
email: string;
|
||||||
avatarUrl: string;
|
avatarUrl: string;
|
||||||
score: number;
|
score: number;
|
||||||
|
rankPercentile?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Group {
|
export interface Group {
|
||||||
+1
-1
@@ -40,7 +40,7 @@ export class BetService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getBetById(id: string): Observable<Bet> {
|
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))
|
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": {
|
"compilerOptions": {
|
||||||
"target": "ES2022",
|
"strict": true,
|
||||||
"experimentalDecorators": true,
|
"noImplicitOverride": true,
|
||||||
"useDefineForClassFields": false,
|
"noPropertyAccessFromIndexSignature": true,
|
||||||
"module": "ESNext",
|
"noImplicitReturns": true,
|
||||||
"lib": [
|
"noFallthroughCasesInSwitch": true,
|
||||||
"ES2022",
|
|
||||||
"DOM",
|
|
||||||
"DOM.Iterable"
|
|
||||||
],
|
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"types": [
|
|
||||||
"node"
|
|
||||||
],
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"moduleDetection": "force",
|
"resolveJsonModule": true,
|
||||||
"allowJs": true,
|
"allowSyntheticDefaultImports": true,
|
||||||
"jsx": "react-jsx",
|
"experimentalDecorators": true,
|
||||||
"paths": {
|
"importHelpers": true,
|
||||||
"@/*": [
|
"target": "ES2022",
|
||||||
"./*"
|
"module": "preserve"
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"files": [
|
|
||||||
"./index.tsx"
|
|
||||||
],
|
|
||||||
"angularCompilerOptions": {
|
"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