Sostituito frontend-angular-new con frontend-angular
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
# GEMINI_API_KEY: Required for Gemini AI API calls.
|
||||
# AI Studio automatically injects this at runtime from user secrets.
|
||||
# Users configure this via the Secrets panel in the AI Studio UI.
|
||||
GEMINI_API_KEY="MY_GEMINI_API_KEY"
|
||||
|
||||
# APP_URL: The URL where this applet is hosted.
|
||||
# AI Studio automatically injects this at runtime with the Cloud Run service URL.
|
||||
# Used for self-referential links, OAuth callbacks, and API endpoints.
|
||||
APP_URL="MY_APP_URL"
|
||||
@@ -0,0 +1,47 @@
|
||||
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
|
||||
|
||||
# Env variables
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
# Compiled output
|
||||
/dist
|
||||
/tmp
|
||||
/out-tsc
|
||||
/bazel-out
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
|
||||
# IDEs and editors
|
||||
.idea/
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# Visual Studio Code
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
.history/*
|
||||
|
||||
# Miscellaneous
|
||||
/.angular/cache
|
||||
.sass-cache/
|
||||
/connect.lock
|
||||
/coverage
|
||||
/libpeerconnection.log
|
||||
testem.log
|
||||
/typings
|
||||
__screenshots__/
|
||||
|
||||
# System files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"plugins": {
|
||||
"@tailwindcss/postcss": {}
|
||||
}
|
||||
}
|
||||
+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,8 +75,20 @@
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "development"
|
||||
},
|
||||
"test": {
|
||||
"builder": "@angular/build:unit-test"
|
||||
},
|
||||
"lint": {
|
||||
"builder": "@angular-eslint/builder:lint",
|
||||
"options": {
|
||||
"lintFilePatterns": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.html"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// @ts-check
|
||||
const eslint = require('@eslint/js');
|
||||
const {defineConfig} = require('eslint/config');
|
||||
const tseslint = require('typescript-eslint');
|
||||
const angular = require('angular-eslint');
|
||||
|
||||
module.exports = defineConfig([
|
||||
{
|
||||
files: ['**/*.ts'],
|
||||
extends: [
|
||||
eslint.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
tseslint.configs.stylistic,
|
||||
angular.configs.tsRecommended,
|
||||
],
|
||||
processor: angular.processInlineTemplates,
|
||||
rules: {
|
||||
'@angular-eslint/directive-selector': [
|
||||
'error',
|
||||
{
|
||||
type: 'attribute',
|
||||
prefix: 'app',
|
||||
style: 'camelCase',
|
||||
},
|
||||
],
|
||||
'@angular-eslint/component-selector': [
|
||||
'error',
|
||||
{
|
||||
type: 'element',
|
||||
prefix: 'app',
|
||||
style: 'kebab-case',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.html'],
|
||||
extends: [
|
||||
angular.configs.templateRecommended,
|
||||
angular.configs.templateAccessibility,
|
||||
],
|
||||
rules: {},
|
||||
}
|
||||
]);
|
||||
@@ -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
+6966
-2194
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
@@ -0,0 +1,10 @@
|
||||
import {ApplicationConfig, mergeApplicationConfig} from '@angular/core';
|
||||
import {provideServerRendering, withRoutes} from '@angular/ssr';
|
||||
import {appConfig} from './app.config';
|
||||
import {serverRoutes} from './app.routes.server';
|
||||
|
||||
const serverConfig: ApplicationConfig = {
|
||||
providers: [provideServerRendering(withRoutes(serverRoutes))],
|
||||
};
|
||||
|
||||
export const config = mergeApplicationConfig(appConfig, serverConfig);
|
||||
@@ -0,0 +1,19 @@
|
||||
import {
|
||||
ApplicationConfig,
|
||||
provideBrowserGlobalErrorListeners,
|
||||
provideZonelessChangeDetection,
|
||||
} from '@angular/core';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { provideHttpClient, withInterceptors, withFetch } from '@angular/common/http';
|
||||
import { authInterceptor } from './core/interceptors/auth.interceptor';
|
||||
|
||||
import { routes } from './app.routes';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideZonelessChangeDetection(),
|
||||
provideBrowserGlobalErrorListeners(),
|
||||
provideRouter(routes),
|
||||
provideHttpClient(withFetch(), withInterceptors([authInterceptor])),
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import {RenderMode, ServerRoute} from '@angular/ssr';
|
||||
|
||||
export const serverRoutes: ServerRoute[] = [
|
||||
{
|
||||
path: '**',
|
||||
renderMode: RenderMode.Client,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Routes } from '@angular/router';
|
||||
import { Home } from './components/home/home';
|
||||
import { Groups } from './components/groups/groups';
|
||||
import { Explore } from './components/explore/explore';
|
||||
import { Profile } from './components/profile/profile';
|
||||
import { Activity } from './components/activity/activity';
|
||||
import { ChallengeDetail } from './components/challenge-detail/challenge-detail';
|
||||
import { GroupDetail } from './components/group-detail/group-detail';
|
||||
import { CreateGroup } from './components/create-group/create-group';
|
||||
import { CreateBet } from './components/create-bet/create-bet';
|
||||
import { Settings } from './components/settings/settings';
|
||||
import { BetHistory } from './components/bet-history/bet-history';
|
||||
|
||||
export const routes: Routes = [
|
||||
{ path: '', component: Home },
|
||||
{ path: 'groups', component: Groups },
|
||||
{ path: 'explore', component: Explore },
|
||||
{ path: 'profile', component: Profile },
|
||||
{ path: 'activity', component: Activity },
|
||||
{ path: 'challenge-detail/:id', component: ChallengeDetail },
|
||||
{ path: 'group-detail/:id', component: GroupDetail },
|
||||
{ path: 'create-group', component: CreateGroup },
|
||||
{ path: 'create-bet', component: CreateBet },
|
||||
{ path: 'settings', component: Settings },
|
||||
{ path: 'bet-history', component: BetHistory },
|
||||
{ path: '**', redirectTo: '' },
|
||||
];
|
||||
@@ -0,0 +1,4 @@
|
||||
/* Activity component styles */
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<div class="min-h-screen pb-32">
|
||||
<header class="fixed top-0 left-0 w-full z-50 bg-background/80 backdrop-blur-md flex justify-between items-center px-6 py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<button class="w-10 h-10 flex items-center justify-center rounded-full hover:bg-surface-container transition-colors" [routerLink]="['/']">
|
||||
<mat-icon class="text-primary">arrow_back</mat-icon>
|
||||
</button>
|
||||
<h1 class="font-headline font-black tracking-tight text-xl text-primary">Activity</h1>
|
||||
</div>
|
||||
<button class="w-10 h-10 flex items-center justify-center rounded-full hover:bg-surface-container transition-colors">
|
||||
<mat-icon class="text-primary">search</mat-icon>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<main class="pt-24 px-6 max-w-2xl mx-auto">
|
||||
<!-- Stats -->
|
||||
<div class="grid grid-cols-2 gap-4 mb-8">
|
||||
<div class="bg-surface-container-low p-6 rounded-2xl flex flex-col justify-between aspect-square border border-black/5">
|
||||
<mat-icon class="text-primary text-3xl">celebration</mat-icon>
|
||||
<div>
|
||||
<p class="text-3xl font-headline font-bold">12</p>
|
||||
<p class="text-sm text-on-surface-variant font-medium">New Alerts</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-secondary-container p-6 rounded-2xl flex flex-col justify-between aspect-square border border-black/5">
|
||||
<mat-icon class="text-secondary text-3xl">trending_up</mat-icon>
|
||||
<div>
|
||||
<p class="text-3xl font-headline font-bold text-secondary">84%</p>
|
||||
<p class="text-sm text-secondary/80 font-medium">Win Rate</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Timeline -->
|
||||
<div class="space-y-12">
|
||||
<section>
|
||||
<h2 class="text-xs font-headline font-extrabold uppercase tracking-widest text-on-surface-variant mb-8 px-2">Recent Activity</h2>
|
||||
<div class="space-y-10">
|
||||
@for (activity of activities(); track $index) {
|
||||
<div class="relative pl-16">
|
||||
<div class="absolute left-0 top-0 w-12 h-12 rounded-full flex items-center justify-center shadow-lg z-10"
|
||||
[ngClass]="{
|
||||
'bg-secondary-container text-secondary': activity.type === 'win',
|
||||
'bg-error-container text-error': activity.type === 'loss',
|
||||
'bg-tertiary-container text-tertiary': activity.type === 'group_joined'
|
||||
}">
|
||||
<mat-icon style="font-variation-settings: 'FILL' 1;">
|
||||
{{ activity.type === 'win' ? 'trophy' : activity.type === 'loss' ? 'trending_down' : 'group_add' }}
|
||||
</mat-icon>
|
||||
</div>
|
||||
<div class="absolute left-6 top-12 bottom-[-40px] w-0.5 bg-surface-container"></div>
|
||||
<div class="bg-surface p-5 rounded-2xl soft-shadow border border-black/5">
|
||||
<p class="leading-relaxed">
|
||||
@if (activity.type === 'win') {
|
||||
You won the <span class="font-bold">{{ activity.title }}!</span> {{ activity.amount }} Arena Points have been credited.
|
||||
} @else if (activity.type === 'loss') {
|
||||
You lost the <span class="font-bold">{{ activity.title }}.</span> {{ activity.amount }} Arena Points have been deducted.
|
||||
} @else {
|
||||
You joined the <span class="font-bold">{{ activity.title }}</span> challenge.
|
||||
}
|
||||
</p>
|
||||
<p class="mt-3 text-[11px] font-medium text-on-surface-variant/50">{{ activity.timestamp }}</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
@@ -0,0 +1,18 @@
|
||||
import {ChangeDetectionStrategy, Component, inject} from '@angular/core';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {MatIconModule} from '@angular/material/icon';
|
||||
import {RouterModule} from '@angular/router';
|
||||
import {toSignal} from '@angular/core/rxjs-interop';
|
||||
import {UserService} from '../../services/user';
|
||||
|
||||
@Component({
|
||||
selector: 'app-activity',
|
||||
imports: [CommonModule, MatIconModule, RouterModule],
|
||||
templateUrl: './activity.html',
|
||||
styleUrl: './activity.css',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class Activity {
|
||||
private userService = inject(UserService);
|
||||
activities = toSignal(this.userService.getActivities(), {initialValue: []});
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/* App component styles */
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<div class="min-h-screen bg-background text-on-background font-sans transition-colors duration-300">
|
||||
<router-outlet></router-outlet>
|
||||
|
||||
<!-- Bottom Navigation Bar -->
|
||||
@if (showBottomNav$ | async) {
|
||||
<nav class="fixed bottom-0 left-0 w-full z-50 flex justify-around items-center px-4 pb-6 pt-2 bg-surface/70 backdrop-blur-xl rounded-t-[24px] shadow-[0_-12px_32px_-8px_rgba(50,41,79,0.08)] border-t border-black/5">
|
||||
<a class="flex flex-col items-center justify-center px-4 py-1 transition-colors rounded-full"
|
||||
routerLink="/home" routerLinkActive="bg-primary/10 text-primary" [routerLinkActiveOptions]="{exact: true}">
|
||||
<mat-icon [style.font-variation-settings]="fillIcon">home</mat-icon>
|
||||
<span class="font-headline font-medium text-[11px]">{{ t('nav.home') }}</span>
|
||||
</a>
|
||||
<a class="flex flex-col items-center justify-center px-4 py-1 transition-colors rounded-full text-on-surface-variant"
|
||||
routerLink="/groups" routerLinkActive="bg-primary/10 text-primary">
|
||||
<mat-icon [style.font-variation-settings]="fillIcon">group</mat-icon>
|
||||
<span class="font-headline font-medium text-[11px]">{{ t('nav.groups') }}</span>
|
||||
</a>
|
||||
<a class="flex flex-col items-center justify-center px-4 py-1 transition-colors rounded-full text-on-surface-variant"
|
||||
routerLink="/create-bet" routerLinkActive="bg-primary/10 text-primary">
|
||||
<mat-icon [style.font-variation-settings]="fillIcon">add_circle</mat-icon>
|
||||
<span class="font-headline font-medium text-[11px]">{{ t('nav.create') }}</span>
|
||||
</a>
|
||||
<a class="flex flex-col items-center justify-center px-4 py-1 transition-colors rounded-full text-on-surface-variant"
|
||||
routerLink="/explore" routerLinkActive="bg-primary/10 text-primary">
|
||||
<mat-icon [style.font-variation-settings]="fillIcon">public</mat-icon>
|
||||
<span class="font-headline font-medium text-[11px]">{{ t('nav.explore') }}</span>
|
||||
</a>
|
||||
<a class="flex flex-col items-center justify-center px-4 py-1 transition-colors rounded-full text-on-surface-variant"
|
||||
routerLink="/profile" routerLinkActive="bg-primary/10 text-primary">
|
||||
<mat-icon [style.font-variation-settings]="fillIcon">person</mat-icon>
|
||||
<span class="font-headline font-medium text-[11px]">{{ t('nav.profile') }}</span>
|
||||
</a>
|
||||
</nav>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,35 @@
|
||||
import {ChangeDetectionStrategy, Component, inject} from '@angular/core';
|
||||
import {Router, RouterOutlet, RouterModule, NavigationEnd} from '@angular/router';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {MatIconModule} from '@angular/material/icon';
|
||||
import {filter, map} from 'rxjs';
|
||||
import {LanguageService} from '../../services/language';
|
||||
import {ThemeService} from '../../services/theme';
|
||||
|
||||
@Component({
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
selector: 'app-root',
|
||||
imports: [CommonModule, RouterOutlet, RouterModule, MatIconModule],
|
||||
templateUrl: './app.html',
|
||||
styleUrl: './app.css',
|
||||
})
|
||||
export class App {
|
||||
private router = inject(Router);
|
||||
private langService = inject(LanguageService);
|
||||
// Iniettato qui per garantire l'inizializzazione del tema al primo caricamento
|
||||
private themeService = inject(ThemeService);
|
||||
fillIcon = "'FILL' 1";
|
||||
|
||||
showBottomNav$ = this.router.events.pipe(
|
||||
filter((event): event is NavigationEnd => event instanceof NavigationEnd),
|
||||
map((event: NavigationEnd) => {
|
||||
const url = event.urlAfterRedirects;
|
||||
const hiddenOn = ['/challenge-detail', '/group-detail'];
|
||||
return !hiddenOn.some(path => url.startsWith(path));
|
||||
})
|
||||
);
|
||||
|
||||
t(key: string): string {
|
||||
return this.langService.translate(key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.soft-shadow {
|
||||
box-shadow: 0 4px 20px -2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
background-color: var(--background);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<div class="min-h-screen pb-20">
|
||||
<!-- Header -->
|
||||
<header class="sticky top-0 z-50 bg-background/80 backdrop-blur-md px-6 py-4 flex items-center gap-4 border-b border-black/5">
|
||||
<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>
|
||||
<h1 class="font-headline font-black tracking-tight text-xl text-primary">Bet History</h1>
|
||||
</header>
|
||||
|
||||
<main class="max-w-xl mx-auto px-6 pt-6 space-y-6">
|
||||
<!-- Search Bar -->
|
||||
<div class="relative group">
|
||||
<div class="absolute inset-y-0 left-4 flex items-center pointer-events-none text-on-surface-variant group-focus-within:text-primary transition-colors">
|
||||
<mat-icon>search</mat-icon>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search bets by title or description..."
|
||||
class="w-full h-14 pl-12 pr-4 rounded-2xl bg-surface-container-low border border-black/5 focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all font-medium text-on-surface"
|
||||
(input)="onSearch($event)"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Stats Summary -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="bg-surface-container-low p-5 rounded-2xl border border-black/5 flex flex-col gap-1">
|
||||
<span class="text-[10px] font-bold text-on-surface-variant uppercase tracking-widest">Total Bets</span>
|
||||
<span class="text-2xl font-headline font-black text-primary">{{ historyChallenges().length }}</span>
|
||||
</div>
|
||||
<div class="bg-surface-container-low p-5 rounded-2xl border border-black/5 flex flex-col gap-1">
|
||||
<span class="text-[10px] font-bold text-on-surface-variant uppercase tracking-widest">Completed</span>
|
||||
<span class="text-2xl font-headline font-black text-secondary">
|
||||
{{ completedCount() }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- History List -->
|
||||
@if (isLoading()) {
|
||||
<div class="flex flex-col items-center justify-center py-12">
|
||||
<div class="w-10 h-10 border-4 border-primary border-t-transparent rounded-full animate-spin"></div>
|
||||
<p class="mt-4 text-on-surface-variant text-sm font-medium">Loading history...</p>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="space-y-4">
|
||||
@for (challenge of historyChallenges(); track challenge.id) {
|
||||
<div class="bg-surface rounded-3xl p-5 soft-shadow border border-black/5 active:scale-[0.98] transition-transform cursor-pointer" [routerLink]="['/challenge-detail', challenge.id]">
|
||||
<div class="flex justify-between items-start mb-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-2xl bg-surface-container flex items-center justify-center">
|
||||
<mat-icon class="text-primary">history</mat-icon>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-headline font-bold text-on-surface">{{ challenge.title }}</h4>
|
||||
<p class="text-xs text-on-surface-variant font-medium">{{ challenge.endDate | date:'mediumDate' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
class="px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-widest"
|
||||
[class.bg-secondary-container]="challenge.status === 'COMPLETED'"
|
||||
[class.text-secondary]="challenge.status === 'COMPLETED'"
|
||||
[class.bg-error-container]="challenge.status === 'EXPIRED'"
|
||||
[class.text-error]="challenge.status === 'EXPIRED'"
|
||||
>
|
||||
{{ challenge.status }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center pt-2 border-t border-black/5 mt-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<mat-icon class="text-sm text-on-surface-variant">groups</mat-icon>
|
||||
<span class="text-xs font-bold text-on-surface-variant">{{ challenge.participants.length }}</span>
|
||||
</div>
|
||||
<div class="text-lg font-headline font-black text-primary">€{{ challenge.stake }}</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (historyChallenges().length === 0) {
|
||||
<div class="flex flex-col items-center justify-center py-20 text-center bg-surface-container-low rounded-3xl border border-dashed border-on-surface-variant/20">
|
||||
<mat-icon class="text-6xl text-on-surface-variant/10 mb-4">history_toggle_off</mat-icon>
|
||||
<h3 class="text-xl font-headline font-bold">No History Found</h3>
|
||||
<p class="text-on-surface-variant mt-2">Try searching for something else or join a new bet!</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</main>
|
||||
</div>
|
||||
@@ -0,0 +1,57 @@
|
||||
import {ChangeDetectionStrategy, Component, inject, signal, computed, OnInit} from '@angular/core';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {MatIconModule} from '@angular/material/icon';
|
||||
import {RouterModule} from '@angular/router';
|
||||
import {BetService} from '../../services/bet';
|
||||
import {Bet} from '../../models/data.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-bet-history',
|
||||
standalone: true,
|
||||
imports: [CommonModule, MatIconModule, RouterModule],
|
||||
templateUrl: './bet-history.html',
|
||||
styleUrl: './bet-history.css',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class BetHistory implements OnInit {
|
||||
private betService = inject(BetService);
|
||||
|
||||
searchQuery = signal('');
|
||||
allBets = signal<Bet[]>([]);
|
||||
isLoading = signal(true);
|
||||
|
||||
ngOnInit() {
|
||||
this.betService.getUserBets().subscribe({
|
||||
next: (bets) => {
|
||||
// Filtriamo per scommesse completate o scadute
|
||||
this.allBets.set(bets.filter(b => b.status === 'COMPLETED' || b.status === 'EXPIRED'));
|
||||
this.isLoading.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Error loading bet history:', err);
|
||||
this.isLoading.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
historyChallenges = computed(() => {
|
||||
const all = this.allBets();
|
||||
const query = this.searchQuery().toLowerCase();
|
||||
|
||||
if (!query) return all;
|
||||
|
||||
return all.filter(c =>
|
||||
c.title.toLowerCase().includes(query) ||
|
||||
(c.description && c.description.toLowerCase().includes(query))
|
||||
);
|
||||
});
|
||||
|
||||
completedCount = computed(() => {
|
||||
return this.historyChallenges().filter(c => c.status === 'COMPLETED').length;
|
||||
});
|
||||
|
||||
onSearch(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
this.searchQuery.set(input.value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/* ChallengeDetail component styles */
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
@@ -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 }
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/* CreateBet component styles */
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
<div class="min-h-screen pb-32">
|
||||
<header class="bg-background/80 backdrop-blur-md fixed top-0 w-full z-50 flex justify-between items-center px-6 py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<button class="hover:opacity-80 transition-opacity" [routerLink]="['/']">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
<h1 class="text-primary font-black tracking-tight font-headline text-xl">The Arena</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<button class="p-2 hover:bg-surface-container rounded-full transition-colors text-on-surface-variant">
|
||||
<mat-icon>search</mat-icon>
|
||||
</button>
|
||||
<button class="text-on-surface-variant font-medium text-sm px-4 py-2 hover:bg-surface-container rounded-full transition-colors" [routerLink]="['/']">Cancel</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="pt-24 px-6 max-w-2xl mx-auto">
|
||||
<div class="mb-8">
|
||||
<h2 class="font-headline font-bold text-3xl tracking-tight mb-2">{{ isEditing ? 'Edit Bet' : 'Create New Bet' }}</h2>
|
||||
<p class="text-on-surface-variant text-base">{{ isEditing ? 'Refine the stakes and update the digital arena.' : 'Define the stakes, set the goal, and invite your rivals to the digital arena.' }}</p>
|
||||
</div>
|
||||
|
||||
<form [formGroup]="betForm" (ngSubmit)="onSubmit()" class="space-y-8">
|
||||
<section class="bg-surface-container-low rounded-2xl p-6 space-y-6 border border-black/5">
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs font-bold uppercase tracking-wider text-on-surface-variant px-1" for="betTitle">Bet Title</label>
|
||||
<input type="text" id="betTitle" formControlName="title" placeholder="e.g., Morning Run Challenge" class="w-full bg-surface-container-highest border-none rounded-full px-6 py-4 focus:ring-2 focus:ring-primary text-on-surface placeholder:text-on-surface-variant/50">
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs font-bold uppercase tracking-wider text-on-surface-variant px-1" for="betDescription">Description (Optional)</label>
|
||||
<textarea id="betDescription" formControlName="description" placeholder="Describe the rules or the prize..." class="w-full bg-surface-container-highest border-none rounded-2xl px-6 py-4 focus:ring-2 focus:ring-primary text-on-surface placeholder:text-on-surface-variant/50" rows="3"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs font-bold uppercase tracking-wider text-on-surface-variant px-1" for="betStake">Stake</label>
|
||||
<div class="relative">
|
||||
<input type="text" id="betStake" formControlName="stake" placeholder="10.00" class="w-full bg-surface-container-highest border-none rounded-full px-6 py-4 focus:ring-2 focus:ring-primary text-on-surface">
|
||||
<span class="absolute right-6 top-1/2 -translate-y-1/2 text-on-surface-variant font-bold">€</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs font-bold uppercase tracking-wider text-on-surface-variant px-1" for="groupId">Group (Optional)</label>
|
||||
<div class="relative">
|
||||
<select id="groupId" formControlName="groupId" class="w-full bg-surface-container-highest border-none rounded-full px-6 py-4 focus:ring-2 focus:ring-primary text-on-surface appearance-none">
|
||||
<option value="">No Group</option>
|
||||
@for (group of availableGroups(); track group.id) {
|
||||
<option [value]="group.id">{{ group.name }}</option>
|
||||
}
|
||||
</select>
|
||||
<mat-icon class="absolute right-5 top-1/2 -translate-y-1/2 text-on-surface-variant pointer-events-none">expand_more</mat-icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between px-1">
|
||||
<div class="space-y-0.5">
|
||||
<label class="text-sm font-bold text-on-surface">Public Bet</label>
|
||||
<p class="text-xs text-on-surface-variant">Visible to everyone in the Explore section</p>
|
||||
</div>
|
||||
<button type="button"
|
||||
(click)="betForm.patchValue({isPublic: !betForm.value.isPublic})"
|
||||
[class.bg-primary]="betForm.value.isPublic"
|
||||
[class.bg-surface-container-highest]="!betForm.value.isPublic"
|
||||
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none">
|
||||
<span [class.translate-x-6]="betForm.value.isPublic"
|
||||
[class.translate-x-1]="!betForm.value.isPublic"
|
||||
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"></span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4">
|
||||
<div class="flex items-center justify-between px-1">
|
||||
<h3 class="font-headline font-bold text-lg">Participants</h3>
|
||||
</div>
|
||||
|
||||
<!-- Search Users -->
|
||||
<div class="relative">
|
||||
<mat-icon class="absolute left-4 top-1/2 -translate-y-1/2 text-on-surface-variant">search</mat-icon>
|
||||
<input type="text"
|
||||
[formControl]="searchControl"
|
||||
placeholder="Search rivals by name or email..."
|
||||
class="w-full bg-surface-container rounded-full pl-12 pr-6 py-3 focus:ring-2 focus:ring-primary text-sm border-none">
|
||||
|
||||
<!-- Search Results Dropdown -->
|
||||
@if (searchResults().length > 0) {
|
||||
<div class="absolute top-full left-0 right-0 mt-2 bg-surface shadow-xl rounded-2xl overflow-hidden z-10 border border-black/5">
|
||||
@for (user of searchResults(); track user.id) {
|
||||
<div (click)="addParticipant(user)"
|
||||
class="flex items-center gap-3 p-3 hover:bg-surface-container-highest cursor-pointer transition-colors">
|
||||
<img [src]="user.avatarUrl || 'assets/default-avatar.png'" class="w-10 h-10 rounded-full object-cover">
|
||||
<div>
|
||||
<p class="text-sm font-bold">{{ user.name }}</p>
|
||||
<p class="text-xs text-on-surface-variant">{{ user.email }}</p>
|
||||
</div>
|
||||
<mat-icon class="ml-auto text-primary">add_circle</mat-icon>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Selected Participants -->
|
||||
<div class="flex gap-4 overflow-x-auto no-scrollbar py-2">
|
||||
@for (user of selectedParticipants(); track user.id) {
|
||||
<div class="flex-shrink-0 text-center space-y-2 group relative">
|
||||
<div class="relative">
|
||||
<img [src]="user.avatarUrl || 'assets/default-avatar.png'" class="w-16 h-16 rounded-full object-cover ring-4 ring-secondary-container" alt="Participant">
|
||||
<button (click)="removeParticipant(user.id)"
|
||||
class="absolute -top-1 -right-1 bg-error text-white w-6 h-6 rounded-full flex items-center justify-center shadow-md">
|
||||
<mat-icon class="text-[14px]">close</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs font-medium">{{ user.name }}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (selectedParticipants().length === 0) {
|
||||
<div class="flex items-center justify-center w-full py-8 text-on-surface-variant text-sm italic">
|
||||
Add at least one participant...
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="pt-4 pb-12">
|
||||
<button [disabled]="betForm.invalid || selectedParticipants().length === 0" class="w-full bg-gradient-to-br from-primary to-primary-container text-white font-headline font-bold text-lg py-5 rounded-full shadow-lg hover:scale-[1.02] active:scale-[0.98] transition-all disabled:opacity-50 disabled:scale-100" type="submit">
|
||||
{{ isEditing ? 'Save Changes' : 'Create Bet' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
</div>
|
||||
@@ -0,0 +1,123 @@
|
||||
import {ChangeDetectionStrategy, Component, inject, OnInit, signal} from '@angular/core';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {MatIconModule} from '@angular/material/icon';
|
||||
import {RouterModule, Router} from '@angular/router';
|
||||
import {ReactiveFormsModule, FormBuilder, FormGroup, Validators, FormControl} from '@angular/forms';
|
||||
import {BetService} from '../../services/bet';
|
||||
import {UserService} from '../../services/user';
|
||||
import {GroupService} from '../../services/group';
|
||||
import {User, Group, CreateBetPayload, BetParticipant} from '../../models/data.models';
|
||||
import {debounceTime, distinctUntilChanged, switchMap, of} from 'rxjs';
|
||||
|
||||
@Component({
|
||||
selector: 'app-create-bet',
|
||||
standalone: true,
|
||||
imports: [CommonModule, MatIconModule, RouterModule, ReactiveFormsModule],
|
||||
templateUrl: './create-bet.html',
|
||||
styleUrl: './create-bet.css',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class CreateBet implements OnInit {
|
||||
private fb = inject(FormBuilder);
|
||||
private router = inject(Router);
|
||||
private betService = inject(BetService);
|
||||
private userService = inject(UserService);
|
||||
private groupService = inject(GroupService);
|
||||
|
||||
betForm: FormGroup = this.fb.group({
|
||||
title: ['', [Validators.required]],
|
||||
description: [''],
|
||||
stake: ['10.00', [Validators.required]],
|
||||
isPublic: [true],
|
||||
groupId: [''],
|
||||
});
|
||||
|
||||
searchControl = new FormControl('');
|
||||
searchResults = signal<User[]>([]);
|
||||
selectedParticipants = signal<User[]>([]);
|
||||
availableGroups = signal<Group[]>([]);
|
||||
|
||||
isEditing = false;
|
||||
editingId: string | null = null;
|
||||
|
||||
ngOnInit() {
|
||||
// Carica gruppi disponibili
|
||||
this.groupService.getGroups().subscribe(groups => {
|
||||
this.availableGroups.set(groups);
|
||||
});
|
||||
|
||||
// Ricerca utenti
|
||||
this.searchControl.valueChanges.pipe(
|
||||
debounceTime(300),
|
||||
distinctUntilChanged(),
|
||||
switchMap(query => {
|
||||
if (!query || query.length < 2) return of([]);
|
||||
return this.userService.searchUsers(query);
|
||||
})
|
||||
).subscribe(users => {
|
||||
this.searchResults.set(users);
|
||||
});
|
||||
|
||||
const navigation = this.router.getCurrentNavigation();
|
||||
const state = navigation?.extras.state as { bet: any }; // Usiamo any per flessibilità durante la transizione
|
||||
|
||||
const historyState = typeof window !== 'undefined' ? window.history.state as { bet?: any } : null;
|
||||
const bet = state?.bet || historyState?.bet;
|
||||
|
||||
if (bet) {
|
||||
this.isEditing = true;
|
||||
this.editingId = bet.id;
|
||||
this.betForm.patchValue({
|
||||
title: bet.title,
|
||||
description: bet.description,
|
||||
stake: bet.stake,
|
||||
isPublic: bet.isPublic,
|
||||
groupId: bet.groupId
|
||||
});
|
||||
// Nota: i partecipanti dovrebbero essere popolati se il backend li ritorna come oggetti User
|
||||
}
|
||||
}
|
||||
|
||||
addParticipant(user: User) {
|
||||
if (!this.selectedParticipants().find(p => p.id === user.id)) {
|
||||
this.selectedParticipants.update(prev => [...prev, user]);
|
||||
}
|
||||
this.searchControl.setValue('');
|
||||
this.searchResults.set([]);
|
||||
}
|
||||
|
||||
removeParticipant(userId: string) {
|
||||
this.selectedParticipants.update(prev => prev.filter(p => p.id !== userId));
|
||||
}
|
||||
|
||||
onSubmit() {
|
||||
if (this.betForm.valid) {
|
||||
const formValue = this.betForm.value;
|
||||
const participants: BetParticipant[] = this.selectedParticipants().map(user => ({
|
||||
user: user.id,
|
||||
role: 'PARTICIPANT'
|
||||
}));
|
||||
|
||||
const payload: CreateBetPayload = {
|
||||
title: formValue.title,
|
||||
description: formValue.description,
|
||||
stake: formValue.stake,
|
||||
isPublic: formValue.isPublic,
|
||||
groupId: formValue.groupId || undefined,
|
||||
participants: participants
|
||||
};
|
||||
|
||||
if (this.isEditing && this.editingId) {
|
||||
this.betService.updateBet(this.editingId, payload).subscribe({
|
||||
next: () => this.router.navigate(['/']),
|
||||
error: (err) => console.error('Error updating bet:', err)
|
||||
});
|
||||
} else {
|
||||
this.betService.createBet(payload).subscribe({
|
||||
next: () => this.router.navigate(['/']),
|
||||
error: (err) => console.error('Error creating bet:', err)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/* CreateGroup component styles */
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
<div class="min-h-screen pb-32">
|
||||
<header class="w-full top-0 sticky z-50 bg-background/80 backdrop-blur-md flex justify-between items-center px-6 py-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<button class="text-primary hover:opacity-80 transition-opacity active:scale-95 transition-transform" [routerLink]="['/groups']">
|
||||
<mat-icon>arrow_back</mat-icon>
|
||||
</button>
|
||||
<h1 class="font-headline font-bold text-lg">{{ isEditing ? 'Edit Group' : 'Create Group' }}</h1>
|
||||
</div>
|
||||
<div class="w-10"></div>
|
||||
</header>
|
||||
|
||||
<main class="px-6 max-w-2xl mx-auto space-y-8 mt-6">
|
||||
<!-- Avatar Selection -->
|
||||
<section class="flex flex-col items-center justify-center">
|
||||
<div class="relative group">
|
||||
<div class="w-32 h-32 rounded-full bg-surface-container-high flex items-center justify-center overflow-hidden transition-all duration-300 ring-4 ring-white shadow-lg">
|
||||
<img [src]="groupAvatar()" class="w-full h-full object-cover opacity-60" alt="Group Avatar">
|
||||
<div class="absolute inset-0 bg-primary/20 flex items-center justify-center group-hover:bg-primary/40 transition-colors cursor-pointer">
|
||||
<mat-icon class="text-white text-3xl">add_a_photo</mat-icon>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute -bottom-2 -right-2 bg-secondary-container p-2 rounded-full shadow-md">
|
||||
<mat-icon class="text-secondary">edit</mat-icon>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-4 text-on-surface-variant text-sm font-medium">Choose a Group Avatar</p>
|
||||
</section>
|
||||
|
||||
<!-- Form -->
|
||||
<form [formGroup]="groupForm" (ngSubmit)="onSubmit()" class="space-y-6">
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-bold ml-2" for="groupName">Group Name</label>
|
||||
<div class="bg-surface-container-low rounded-2xl p-1">
|
||||
<input type="text" id="groupName" formControlName="name" placeholder="Enter group name..." class="w-full bg-transparent border-none focus:ring-0 px-4 py-3 placeholder:text-on-surface-variant/50 font-semibold">
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-bold ml-2" for="groupDesc">Description</label>
|
||||
<div class="bg-surface-container-low rounded-2xl p-1">
|
||||
<input type="text" id="groupDesc" formControlName="description" placeholder="What's this group for?" class="w-full bg-transparent border-none focus:ring-0 px-4 py-3 placeholder:text-on-surface-variant/50">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Privacy Toggle -->
|
||||
<section class="bg-surface-container p-5 rounded-2xl flex items-center justify-between border border-black/5">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-12 h-12 bg-tertiary-container/30 rounded-full flex items-center justify-center">
|
||||
<mat-icon class="text-tertiary">lock</mat-icon>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-bold">Private Group</h3>
|
||||
<p class="text-xs text-on-surface-variant">Only invited members can join</p>
|
||||
</div>
|
||||
</div>
|
||||
<div (click)="groupForm.patchValue({isPrivate: !groupForm.value.isPrivate})"
|
||||
[class.bg-primary]="groupForm.value.isPrivate"
|
||||
[class.bg-surface-container-highest]="!groupForm.value.isPrivate"
|
||||
class="relative inline-flex h-8 w-14 items-center rounded-full transition-colors cursor-pointer focus:outline-none">
|
||||
<span [class.translate-x-7]="groupForm.value.isPrivate"
|
||||
[class.translate-x-1]="!groupForm.value.isPrivate"
|
||||
class="inline-block h-6 w-6 transform rounded-full bg-white transition-transform shadow-md"></span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Invite Friends -->
|
||||
<section class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="font-headline font-extrabold text-lg">Members</h3>
|
||||
</div>
|
||||
|
||||
<!-- Search Users -->
|
||||
<div class="relative">
|
||||
<mat-icon class="absolute left-4 top-1/2 -translate-y-1/2 text-on-surface-variant/50">search</mat-icon>
|
||||
<input type="text"
|
||||
[formControl]="searchControl"
|
||||
placeholder="Search friends by name or email..."
|
||||
class="w-full bg-surface-container-low rounded-full py-3 pl-12 pr-6 border-none focus:ring-2 focus:ring-primary/20">
|
||||
|
||||
<!-- Search Results Dropdown -->
|
||||
@if (searchResults().length > 0) {
|
||||
<div class="absolute top-full left-0 right-0 mt-2 bg-surface shadow-xl rounded-2xl overflow-hidden z-10 border border-black/5">
|
||||
@for (user of searchResults(); track user.id) {
|
||||
<div (click)="addMember(user)"
|
||||
class="flex items-center gap-3 p-3 hover:bg-surface-container-highest cursor-pointer transition-colors">
|
||||
<img [src]="user.avatarUrl || 'assets/default-avatar.png'" class="w-10 h-10 rounded-full object-cover">
|
||||
<div>
|
||||
<p class="text-sm font-bold">{{ user.name }}</p>
|
||||
<p class="text-xs text-on-surface-variant">{{ user.email }}</p>
|
||||
</div>
|
||||
<mat-icon class="ml-auto text-primary">person_add</mat-icon>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Selected Members -->
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
@for (member of selectedMembers(); track member.id) {
|
||||
<div class="flex items-center justify-between p-3 bg-surface rounded-2xl border border-black/5">
|
||||
<div class="flex items-center gap-3">
|
||||
<img [src]="member.avatarUrl || 'assets/default-avatar.png'" class="w-10 h-10 rounded-full object-cover" alt="Member">
|
||||
<p class="font-bold text-sm truncate max-w-[80px]">{{ member.name }}</p>
|
||||
</div>
|
||||
<button type="button" (click)="removeMember(member.id)" class="text-error hover:bg-error/10 p-1 rounded-full transition-colors">
|
||||
<mat-icon class="text-lg">remove_circle_outline</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="pt-4 pb-8">
|
||||
<button type="submit" [disabled]="groupForm.invalid" class="w-full bg-gradient-to-br from-primary to-primary-container text-white py-4 rounded-full font-bold text-lg shadow-lg active:scale-95 transition-all disabled:opacity-50 disabled:scale-100">
|
||||
{{ isEditing ? 'Save Changes' : 'Create Group' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
</div>
|
||||
@@ -0,0 +1,113 @@
|
||||
import {ChangeDetectionStrategy, Component, inject, OnInit, signal, computed} from '@angular/core';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {MatIconModule} from '@angular/material/icon';
|
||||
import {RouterModule, Router} from '@angular/router';
|
||||
import {ReactiveFormsModule, FormBuilder, FormGroup, Validators, FormControl} from '@angular/forms';
|
||||
import {GroupService} from '../../services/group';
|
||||
import {UserService} from '../../services/user';
|
||||
import {User, Group} from '../../models/data.models';
|
||||
import {debounceTime, distinctUntilChanged, switchMap, of} from 'rxjs';
|
||||
|
||||
@Component({
|
||||
selector: 'app-create-group',
|
||||
standalone: true,
|
||||
imports: [CommonModule, MatIconModule, RouterModule, ReactiveFormsModule],
|
||||
templateUrl: './create-group.html',
|
||||
styleUrl: './create-group.css',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class CreateGroup implements OnInit {
|
||||
private fb = inject(FormBuilder);
|
||||
private router = inject(Router);
|
||||
private groupService = inject(GroupService);
|
||||
private userService = inject(UserService);
|
||||
|
||||
groupForm: FormGroup = this.fb.group({
|
||||
name: ['', [Validators.required]],
|
||||
description: [''],
|
||||
isPrivate: [false],
|
||||
avatarUrl: ['https://picsum.photos/seed/avatar/200/200']
|
||||
});
|
||||
|
||||
searchControl = new FormControl('');
|
||||
searchResults = signal<User[]>([]);
|
||||
selectedMembers = signal<User[]>([]);
|
||||
|
||||
groupAvatar = computed(() => this.groupForm.get('avatarUrl')?.value);
|
||||
|
||||
isEditing = false;
|
||||
editingId: number | null = null;
|
||||
|
||||
ngOnInit() {
|
||||
// Ricerca utenti per i membri
|
||||
this.searchControl.valueChanges.pipe(
|
||||
debounceTime(300),
|
||||
distinctUntilChanged(),
|
||||
switchMap(query => {
|
||||
if (!query || query.length < 2) return of([]);
|
||||
return this.userService.searchUsers(query);
|
||||
})
|
||||
).subscribe(users => {
|
||||
this.searchResults.set(users);
|
||||
});
|
||||
|
||||
const navigation = this.router.getCurrentNavigation();
|
||||
const state = navigation?.extras.state as { group: Group };
|
||||
|
||||
const historyState = typeof window !== 'undefined' ? window.history.state as { group?: Group } : null;
|
||||
const group = state?.group || historyState?.group;
|
||||
|
||||
if (group) {
|
||||
this.isEditing = true;
|
||||
this.editingId = group.id;
|
||||
this.groupForm.patchValue({
|
||||
name: group.name,
|
||||
description: group.description,
|
||||
isPrivate: group.isPrivate,
|
||||
avatarUrl: group.avatarUrl
|
||||
});
|
||||
if (group.members) {
|
||||
this.selectedMembers.set(group.members);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addMember(user: User) {
|
||||
if (!this.selectedMembers().find(m => m.id === user.id)) {
|
||||
this.selectedMembers.update(prev => [...prev, user]);
|
||||
}
|
||||
this.searchControl.setValue('');
|
||||
this.searchResults.set([]);
|
||||
}
|
||||
|
||||
removeMember(userId: string) {
|
||||
this.selectedMembers.update(prev => prev.filter(m => m.id !== userId));
|
||||
}
|
||||
|
||||
onSubmit() {
|
||||
if (this.groupForm.valid) {
|
||||
const formValue = this.groupForm.value;
|
||||
const memberIds = this.selectedMembers().map(m => m.id);
|
||||
|
||||
const payload = {
|
||||
name: formValue.name,
|
||||
description: formValue.description,
|
||||
isPrivate: formValue.isPrivate,
|
||||
avatarUrl: formValue.avatarUrl,
|
||||
members: memberIds
|
||||
};
|
||||
|
||||
if (this.isEditing && this.editingId) {
|
||||
this.groupService.updateGroup(this.editingId, payload).subscribe({
|
||||
next: () => this.router.navigate(['/groups']),
|
||||
error: (err) => console.error('Error updating group:', err)
|
||||
});
|
||||
} else {
|
||||
this.groupService.createGroup(payload).subscribe({
|
||||
next: () => this.router.navigate(['/groups']),
|
||||
error: (err) => console.error('Error creating group:', err)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/* Explore component styles */
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
<div class="min-h-screen pb-32">
|
||||
<header class="fixed top-0 left-0 w-full z-50 bg-background/80 backdrop-blur-md flex justify-between items-center px-6 py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-full bg-primary-container flex items-center justify-center overflow-hidden border-2 border-primary/20">
|
||||
<img src="https://picsum.photos/seed/user/100/100" class="w-full h-full object-cover" alt="User Avatar">
|
||||
</div>
|
||||
<span class="text-2xl font-black tracking-tight text-primary font-headline">Arena</span>
|
||||
</div>
|
||||
<button class="w-10 h-10 rounded-full flex items-center justify-center transition-all duration-300 ease-in-out active:scale-95 hover:bg-primary/10">
|
||||
<mat-icon class="text-primary">search</mat-icon>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<main class="pt-24 px-6 max-w-2xl mx-auto space-y-8">
|
||||
<!-- Search -->
|
||||
<section>
|
||||
<div class="relative group">
|
||||
<mat-icon class="absolute left-5 top-1/2 -translate-y-1/2 text-on-surface-variant/50">search</mat-icon>
|
||||
<input type="text" placeholder="Search bets, groups, or friends..." class="w-full bg-surface-container-low py-4 pl-14 pr-6 rounded-full border-none focus:ring-2 focus:ring-primary/20 text-on-surface placeholder:text-on-surface-variant/60 shadow-sm">
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Filter Tabs -->
|
||||
<nav class="flex gap-3 mb-10 overflow-x-auto pb-2 no-scrollbar">
|
||||
<button class="bg-primary text-white px-8 py-3 rounded-full font-semibold text-sm shadow-lg shadow-primary/20 transition-all active:scale-95">All</button>
|
||||
<button class="bg-surface-container text-on-surface-variant px-8 py-3 rounded-full font-semibold text-sm hover:bg-surface-container-high transition-all active:scale-95">Groups</button>
|
||||
<button class="bg-surface-container text-on-surface-variant px-8 py-3 rounded-full font-semibold text-sm hover:bg-surface-container-high transition-all active:scale-95">Public Bets</button>
|
||||
</nav>
|
||||
|
||||
<!-- Public Groups -->
|
||||
<section class="space-y-4">
|
||||
<div class="flex justify-between items-end px-2">
|
||||
<div>
|
||||
<h2 class="font-headline text-2xl font-bold tracking-tight">Public Groups</h2>
|
||||
<p class="text-on-surface-variant text-sm">Join the arena with others</p>
|
||||
</div>
|
||||
<button class="text-primary font-semibold text-sm hover:underline">Vedi tutti</button>
|
||||
</div>
|
||||
<div class="flex gap-4 overflow-x-auto no-scrollbar -mx-6 px-6 py-2">
|
||||
<!-- Group Card 1 -->
|
||||
<div class="flex-none w-64 bg-surface-container-low p-5 rounded-2xl shadow-sm border-b-4 border-primary/30 hover:bg-surface-container transition-colors duration-300">
|
||||
<div class="flex justify-between items-start mb-4">
|
||||
<div class="w-14 h-14 rounded-2xl bg-indigo-100 flex items-center justify-center overflow-hidden">
|
||||
<img src="https://picsum.photos/seed/stadium/200/200" class="w-full h-full object-cover" alt="Stadium">
|
||||
</div>
|
||||
<span class="bg-secondary-container text-secondary px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider">Active Now</span>
|
||||
</div>
|
||||
<h3 class="font-bold text-lg leading-tight mb-1">Champions League Elite</h3>
|
||||
<p class="text-on-surface-variant text-sm mb-4">12.4k members betting live</p>
|
||||
<div class="flex -space-x-2">
|
||||
<img src="https://picsum.photos/seed/p1/50/50" class="w-7 h-7 rounded-full border-2 border-surface" alt="Member">
|
||||
<img src="https://picsum.photos/seed/p2/50/50" class="w-7 h-7 rounded-full border-2 border-surface" alt="Member">
|
||||
<div class="w-7 h-7 rounded-full border-2 border-surface bg-primary text-[10px] text-white flex items-center justify-center font-bold">+8k</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Group Card 2 -->
|
||||
<div class="flex-none w-64 bg-surface-container-low p-5 rounded-2xl shadow-sm border-b-4 border-tertiary/30 hover:bg-surface-container transition-colors duration-300">
|
||||
<div class="flex justify-between items-start mb-4">
|
||||
<div class="w-14 h-14 rounded-2xl bg-tertiary-container flex items-center justify-center overflow-hidden">
|
||||
<img src="https://picsum.photos/seed/basket/200/200" class="w-full h-full object-cover" alt="Basketball">
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="font-bold text-lg leading-tight mb-1">NBA Sharpshooters</h3>
|
||||
<p class="text-on-surface-variant text-sm mb-4">8.2k members discussing picks</p>
|
||||
<div class="flex -space-x-2">
|
||||
<img src="https://picsum.photos/seed/p3/50/50" class="w-7 h-7 rounded-full border-2 border-surface" alt="Member">
|
||||
<img src="https://picsum.photos/seed/p4/50/50" class="w-7 h-7 rounded-full border-2 border-surface" alt="Member">
|
||||
<div class="w-7 h-7 rounded-full border-2 border-surface bg-tertiary text-[10px] text-white flex items-center justify-center font-bold">+2k</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Trending Public Bets -->
|
||||
<section class="space-y-6">
|
||||
<div class="flex justify-between items-end px-2">
|
||||
<div>
|
||||
<h2 class="font-headline text-2xl font-bold tracking-tight">Trending Public Bets</h2>
|
||||
<p class="text-on-surface-variant text-sm">Most popular action in the arena</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (isLoading()) {
|
||||
<div class="flex flex-col items-center justify-center py-12">
|
||||
<div class="w-10 h-10 border-4 border-primary border-t-transparent rounded-full animate-spin"></div>
|
||||
<p class="mt-4 text-on-surface-variant text-sm font-medium">Scanning the arena...</p>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="space-y-4">
|
||||
<!-- Public Bet Cards -->
|
||||
@for (bet of publicBets(); track bet.id) {
|
||||
<div class="bg-surface p-6 rounded-2xl soft-shadow relative overflow-hidden border border-black/5 active:scale-[0.98] transition-transform cursor-pointer"
|
||||
[routerLink]="['/challenge-detail', bet.id]">
|
||||
<div class="flex justify-between items-start mb-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 bg-indigo-50 rounded-full">
|
||||
<mat-icon class="text-primary">public</mat-icon>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-[10px] font-bold uppercase tracking-widest text-on-surface-variant">Public Challenge</p>
|
||||
<h4 class="font-bold">{{ bet.title }}</h4>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<span class="text-xs text-on-surface-variant block mb-1">Stake</span>
|
||||
<span class="font-headline font-bold text-primary">€{{ bet.stake }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-on-surface-variant mb-6 line-clamp-2">{{ bet.description || 'Join this public bet and prove your skills!' }}</p>
|
||||
|
||||
<div class="flex items-center justify-between border-t border-surface-container pt-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex -space-x-1">
|
||||
@for (p of bet.participants.slice(0, 3); track $index) {
|
||||
<img [src]="'https://picsum.photos/seed/' + p.user + '/50/50'" class="w-6 h-6 rounded-full ring-2 ring-surface" alt="User">
|
||||
}
|
||||
</div>
|
||||
<span class="text-xs text-on-surface-variant font-medium">{{ bet.participants.length }} Participants</span>
|
||||
</div>
|
||||
<div class="flex gap-4">
|
||||
<mat-icon class="text-on-surface-variant text-xl">share</mat-icon>
|
||||
<mat-icon class="text-on-surface-variant text-xl">bookmark_border</mat-icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (publicBets().length === 0) {
|
||||
<div class="flex flex-col items-center justify-center py-20 text-center bg-surface-container-low rounded-3xl border border-dashed border-on-surface-variant/20">
|
||||
<mat-icon class="text-6xl text-on-surface-variant/10 mb-4">public_off</mat-icon>
|
||||
<h3 class="text-xl font-headline font-bold">No Public Bets</h3>
|
||||
<p class="text-on-surface-variant mt-2">The arena is quiet right now. Why not create a public bet?</p>
|
||||
<button class="mt-6 bg-primary text-white px-8 py-3 rounded-full font-bold" [routerLink]="['/create-bet']">Create Public Bet</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
@@ -0,0 +1,34 @@
|
||||
import {ChangeDetectionStrategy, Component, inject, signal, OnInit} from '@angular/core';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {MatIconModule} from '@angular/material/icon';
|
||||
import {RouterModule} from '@angular/router';
|
||||
import {BetService} from '../../services/bet';
|
||||
import {Bet} from '../../models/data.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-explore',
|
||||
standalone: true,
|
||||
imports: [CommonModule, MatIconModule, RouterModule],
|
||||
templateUrl: './explore.html',
|
||||
styleUrl: './explore.css',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class Explore implements OnInit {
|
||||
private betService = inject(BetService);
|
||||
|
||||
publicBets = signal<Bet[]>([]);
|
||||
isLoading = signal(true);
|
||||
|
||||
ngOnInit() {
|
||||
this.betService.getPublicBets().subscribe({
|
||||
next: (bets) => {
|
||||
this.publicBets.set(bets);
|
||||
this.isLoading.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Error loading public bets:', err);
|
||||
this.isLoading.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/* GroupDetail component styles */
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
<div class="min-h-screen pb-32">
|
||||
<header class="w-full top-0 sticky z-40 flex items-center justify-between px-6 py-4 bg-background/80 backdrop-blur-md">
|
||||
<div class="flex items-center gap-4">
|
||||
<button class="hover:bg-surface-container transition-colors p-2 rounded-full active:scale-95 duration-200 text-primary" [routerLink]="['/groups']">
|
||||
<mat-icon>arrow_back</mat-icon>
|
||||
</button>
|
||||
<h1 class="font-headline font-bold text-lg">{{ group()?.name || 'Group Detail' }}</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button (click)="editGroup()" class="hover:bg-surface-container transition-colors p-2 rounded-full active:scale-95 duration-200 text-primary">
|
||||
<mat-icon>edit</mat-icon>
|
||||
</button>
|
||||
<button class="hover:bg-surface-container transition-colors p-2 rounded-full active:scale-95 duration-200 text-primary">
|
||||
<mat-icon>more_vert</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="max-w-md mx-auto px-4 space-y-6 pt-4">
|
||||
@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">Loading group details...</p>
|
||||
</div>
|
||||
} @else if (group(); as data) {
|
||||
<!-- Group Hero -->
|
||||
<section class="relative overflow-hidden rounded-2xl bg-surface soft-shadow border border-black/5">
|
||||
<div class="h-40 relative">
|
||||
<img [src]="data.avatarUrl || 'https://picsum.photos/seed/group/400/200'" class="w-full h-full object-cover" alt="Group Banner">
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent"></div>
|
||||
<div class="absolute bottom-4 left-4 right-4 flex justify-between items-end">
|
||||
<div>
|
||||
@if (data.isPrivate) {
|
||||
<span class="inline-flex items-center gap-1 bg-tertiary-container text-tertiary px-3 py-1 rounded-full text-[10px] font-bold tracking-wider mb-2">
|
||||
<mat-icon class="text-[12px]">lock</mat-icon>
|
||||
PRIVATE
|
||||
</span>
|
||||
}
|
||||
<h2 class="text-white font-headline font-extrabold text-2xl tracking-tight">{{ data.name }}</h2>
|
||||
<p class="text-white/80 text-sm font-medium">{{ data.members?.length || 0 }} Members</p>
|
||||
</div>
|
||||
@if (data.members && data.members.length > 0) {
|
||||
<div class="flex -space-x-3">
|
||||
@for (member of data.members.slice(0, 3); track $index) {
|
||||
<img [src]="member.avatarUrl || 'assets/default-avatar.png'" class="w-8 h-8 rounded-full border-2 border-surface object-cover" [alt]="member.name">
|
||||
}
|
||||
@if (data.members.length > 3) {
|
||||
<div class="w-8 h-8 rounded-full bg-primary text-white flex items-center justify-center text-[10px] font-bold border-2 border-surface">+{{ data.members.length - 3 }}</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Description -->
|
||||
<section class="bg-surface-container-low p-6 rounded-2xl border border-black/5">
|
||||
<h3 class="text-xs font-bold text-on-surface-variant uppercase tracking-widest mb-2">About Group</h3>
|
||||
<p class="text-on-surface leading-relaxed">{{ data.description || 'No description provided for this group.' }}</p>
|
||||
</section>
|
||||
|
||||
<!-- Members List -->
|
||||
<section class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="font-headline font-bold text-lg">Members</h3>
|
||||
<span class="text-primary font-medium text-sm">Manage</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-3">
|
||||
@for (member of data.members; track member.id) {
|
||||
<div class="flex items-center justify-between p-3 bg-surface rounded-2xl border border-black/5">
|
||||
<div class="flex items-center gap-3">
|
||||
<img [src]="member.avatarUrl || 'assets/default-avatar.png'" class="w-10 h-10 rounded-full object-cover" alt="Member">
|
||||
<div>
|
||||
<p class="font-bold text-sm">{{ member.name }}</p>
|
||||
<p class="text-[10px] text-on-surface-variant">Score: {{ member.score }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<mat-icon class="text-on-surface-variant/30 text-sm">chevron_right</mat-icon>
|
||||
</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">group_off</mat-icon>
|
||||
<h3 class="text-xl font-headline font-bold">Group Not Found</h3>
|
||||
<p class="text-on-surface-variant mt-2">The group you're looking for doesn't exist or has been removed.</p>
|
||||
<button class="mt-6 text-primary font-bold" [routerLink]="['/groups']">Go Back to Groups</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Active Bets -->
|
||||
<section class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="font-headline font-bold text-lg">Active Bets</h3>
|
||||
<span class="text-primary font-medium text-sm">View all</span>
|
||||
</div>
|
||||
<div class="p-5 rounded-2xl bg-surface-container shadow-sm relative overflow-hidden group active:scale-[0.98] transition-transform border border-black/5">
|
||||
<div class="absolute top-0 right-0 p-3">
|
||||
<span class="bg-tertiary-container text-on-tertiary-container px-3 py-1 rounded-full text-[10px] font-bold">LIVE</span>
|
||||
</div>
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="w-12 h-12 rounded-2xl bg-white flex items-center justify-center text-primary shrink-0">
|
||||
<mat-icon style="font-variation-settings: 'FILL' 1;">footprint</mat-icon>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h4 class="font-headline font-bold text-on-surface leading-tight">10k Step Challenge</h4>
|
||||
<p class="text-on-surface-variant text-xs mt-1">24 Participants • Ends in 4h</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Top Performers -->
|
||||
<section class="space-y-4">
|
||||
<h3 class="font-headline font-bold text-lg">Top Performers</h3>
|
||||
<div class="bg-surface-container-low rounded-2xl p-2 border border-black/5">
|
||||
<div class="flex items-center justify-between p-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="relative">
|
||||
<img src="https://picsum.photos/seed/top1/100/100" class="w-10 h-10 rounded-full object-cover" alt="Top Performer">
|
||||
<div class="absolute -bottom-1 -right-1 bg-secondary text-white w-5 h-5 rounded-full flex items-center justify-center border-2 border-surface-container-low text-[8px] font-black">1</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-headline font-bold text-sm">Alex Rivera</p>
|
||||
<p class="text-[10px] font-medium text-on-surface-variant uppercase tracking-tighter">Current Streak: 8 Wins</p>
|
||||
</div>
|
||||
</div>
|
||||
<mat-icon class="text-secondary" style="font-variation-settings: 'FILL' 1;">local_fire_department</mat-icon>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CTA -->
|
||||
<section class="grid grid-cols-1 gap-3 pb-8">
|
||||
<button class="bg-gradient-to-br from-primary to-primary-container text-white rounded-full py-4 px-6 font-headline font-bold flex items-center justify-center gap-2 shadow-lg active:scale-95 transition-all">
|
||||
<mat-icon>add_circle</mat-icon>
|
||||
Create Group Bet
|
||||
</button>
|
||||
<button class="bg-surface-container-high text-on-surface rounded-full py-4 px-6 font-headline font-bold flex items-center justify-center gap-2 active:scale-95 transition-all">
|
||||
<mat-icon>person_add</mat-icon>
|
||||
Invite Friends
|
||||
</button>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
@@ -0,0 +1,48 @@
|
||||
import {ChangeDetectionStrategy, Component, inject, signal, OnInit} from '@angular/core';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {MatIconModule} from '@angular/material/icon';
|
||||
import {RouterModule, Router, ActivatedRoute} from '@angular/router';
|
||||
import {GroupService} from '../../services/group';
|
||||
import {Group} from '../../models/data.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-group-detail',
|
||||
standalone: true,
|
||||
imports: [CommonModule, MatIconModule, RouterModule],
|
||||
templateUrl: './group-detail.html',
|
||||
styleUrl: './group-detail.css',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class GroupDetail implements OnInit {
|
||||
private router = inject(Router);
|
||||
private route = inject(ActivatedRoute);
|
||||
private groupService = inject(GroupService);
|
||||
|
||||
groupId = this.route.snapshot.paramMap.get('id');
|
||||
group = signal<Group | null>(null);
|
||||
isLoading = signal(true);
|
||||
|
||||
ngOnInit() {
|
||||
if (this.groupId) {
|
||||
this.groupService.getGroupById(Number(this.groupId)).subscribe({
|
||||
next: (data) => {
|
||||
this.group.set(data);
|
||||
this.isLoading.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Error loading group:', err);
|
||||
this.isLoading.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
editGroup() {
|
||||
const data = this.group();
|
||||
if (data) {
|
||||
this.router.navigate(['/create-group'], {
|
||||
state: { group: data }
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/* Groups component styles */
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
<div class="min-h-screen pb-32">
|
||||
<header class="bg-background sticky top-0 z-50 px-6 py-4 flex justify-between items-center shadow-sm">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-full overflow-hidden">
|
||||
<img src="https://picsum.photos/seed/user/100/100" class="w-full h-full object-cover" alt="User Avatar">
|
||||
</div>
|
||||
<h1 class="font-headline font-black tracking-tight text-xl text-primary">{{ t('groups.title') }}</h1>
|
||||
</div>
|
||||
<button class="text-on-surface-variant hover:opacity-80 transition-opacity">
|
||||
<mat-icon>search</mat-icon>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<main class="px-6 pt-4 space-y-6 max-w-2xl mx-auto">
|
||||
<!-- Search & Create -->
|
||||
<section class="space-y-4">
|
||||
<div class="relative">
|
||||
<mat-icon class="absolute left-4 top-1/2 -translate-y-1/2 text-on-surface-variant/50">search</mat-icon>
|
||||
<input type="text" [placeholder]="t('history.search')" class="w-full pl-12 pr-4 py-4 bg-surface-container-low border-none rounded-full focus:ring-2 focus:ring-primary-container text-on-surface">
|
||||
</div>
|
||||
<button class="w-full flex items-center justify-center gap-2 py-4 px-6 bg-gradient-to-br from-primary to-primary-container text-white rounded-full font-bold soft-shadow hover:opacity-90 transition-all active:scale-95" [routerLink]="['/create-group']">
|
||||
<mat-icon>add_circle</mat-icon>
|
||||
<span>{{ t('groups.create') }}</span>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<!-- Groups List -->
|
||||
@if (isLoading()) {
|
||||
<div class="flex flex-col items-center justify-center py-12">
|
||||
<div class="w-10 h-10 border-4 border-primary border-t-transparent rounded-full animate-spin"></div>
|
||||
<p class="mt-4 text-on-surface-variant text-sm font-medium">Loading your groups...</p>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
@for (group of groupsList(); track group.id) {
|
||||
<div class="bg-surface p-6 rounded-2xl soft-shadow border border-black/5 flex flex-col gap-4 relative overflow-hidden group cursor-pointer" [routerLink]="['/group-detail', group.id]">
|
||||
<div class="flex justify-between items-start z-10">
|
||||
<div class="w-14 h-14 rounded-2xl overflow-hidden shadow-md bg-surface-container">
|
||||
<img [src]="group.avatarUrl || 'https://picsum.photos/seed/' + group.id + '/200/200'" class="w-full h-full object-cover" alt="Group Icon">
|
||||
</div>
|
||||
@if (group.isPrivate) {
|
||||
<span class="bg-tertiary-container text-tertiary px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider">Private</span>
|
||||
}
|
||||
</div>
|
||||
<div class="space-y-1 z-10">
|
||||
<h3 class="font-headline text-lg font-bold">{{ group.name }}</h3>
|
||||
<p class="text-on-surface-variant text-sm line-clamp-2">{{ group.description || 'No description' }}</p>
|
||||
</div>
|
||||
<div class="flex items-center justify-between z-10 pt-2">
|
||||
@if (group.members && group.members.length > 0) {
|
||||
<div class="flex -space-x-3">
|
||||
@for (member of group.members.slice(0, 3); track $index) {
|
||||
<img [src]="member.avatarUrl || 'assets/default-avatar.png'" class="w-8 h-8 rounded-full border-2 border-surface" [alt]="member.name">
|
||||
}
|
||||
@if (group.members.length > 3) {
|
||||
<div class="w-8 h-8 rounded-full border-2 border-surface bg-surface-container text-[10px] font-bold flex items-center justify-center text-on-surface-variant">+{{ group.members.length - 3 }}</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<span class="text-primary font-bold text-sm">{{ group.members?.length || 0 }} Members</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (groupsList().length === 0) {
|
||||
<div class="col-span-full py-12 text-center bg-surface-container-low rounded-3xl border border-dashed border-on-surface-variant/20">
|
||||
<mat-icon class="text-4xl text-on-surface-variant/20 mb-2">groups</mat-icon>
|
||||
<p class="text-on-surface-variant font-medium">You haven't joined any groups yet.</p>
|
||||
<button class="mt-4 text-primary font-bold" [routerLink]="['/create-group']">Create your first group</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Social Pulse -->
|
||||
<div class="bg-surface-container-low p-4 rounded-2xl flex items-center gap-4 border border-black/5">
|
||||
<div class="flex -space-x-2">
|
||||
<div class="w-10 h-10 rounded-full bg-secondary-container flex items-center justify-center text-lg">🔥</div>
|
||||
<div class="w-10 h-10 rounded-full border-2 border-surface overflow-hidden">
|
||||
<img src="https://picsum.photos/seed/win/100/100" class="w-full h-full object-cover" alt="Winner">
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="text-xs font-bold"><span class="text-primary">Alex</span> just won a 10x multiplier in <span class="font-bold">The Run Club</span>!</p>
|
||||
</div>
|
||||
<button class="text-tertiary">
|
||||
<mat-icon>chat</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
@@ -0,0 +1,40 @@
|
||||
import {ChangeDetectionStrategy, Component, inject, signal, OnInit} from '@angular/core';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {MatIconModule} from '@angular/material/icon';
|
||||
import {RouterModule} from '@angular/router';
|
||||
import {GroupService} from '../../services/group';
|
||||
import {LanguageService} from '../../services/language';
|
||||
import {Group} from '../../models/data.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-groups',
|
||||
standalone: true,
|
||||
imports: [CommonModule, MatIconModule, RouterModule],
|
||||
templateUrl: './groups.html',
|
||||
styleUrl: './groups.css',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class Groups implements OnInit {
|
||||
private groupService = inject(GroupService);
|
||||
private langService = inject(LanguageService);
|
||||
|
||||
groupsList = signal<Group[]>([]);
|
||||
isLoading = signal(true);
|
||||
|
||||
ngOnInit() {
|
||||
this.groupService.getGroups().subscribe({
|
||||
next: (groups) => {
|
||||
this.groupsList.set(groups);
|
||||
this.isLoading.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Error loading groups:', err);
|
||||
this.isLoading.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
t(key: string): string {
|
||||
return this.langService.translate(key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/* Home component styles */
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<div class="min-h-screen pb-32">
|
||||
<!-- Top Bar -->
|
||||
<header class="fixed top-0 left-0 w-full z-50 bg-background/80 backdrop-blur-md flex justify-between items-center px-6 py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-full overflow-hidden border-2 border-primary-container">
|
||||
<img [src]="userProfile().avatar" alt="User Profile" class="w-full h-full object-cover">
|
||||
</div>
|
||||
<h1 class="font-headline font-black tracking-tight text-xl text-primary">ScommettiApp</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<button class="p-2 text-on-surface-variant hover:opacity-80 transition-opacity">
|
||||
<mat-icon>search</mat-icon>
|
||||
</button>
|
||||
<button class="p-2 text-on-surface-variant hover:opacity-80 transition-opacity" [routerLink]="['/activity']">
|
||||
<mat-icon>notifications</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="mt-20 px-6 max-w-5xl mx-auto space-y-8">
|
||||
<!-- Hero Section -->
|
||||
<section class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div class="md:col-span-2 bg-gradient-to-br from-primary to-primary-dim p-6 rounded-2xl text-white soft-shadow flex flex-col justify-between min-h-[200px]">
|
||||
<div>
|
||||
<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 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-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().score }}</span>
|
||||
<span class="text-sm font-semibold text-secondary">pts</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Active Bets -->
|
||||
<section>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="font-headline font-bold text-xl">{{ t('home.active_bets') }}</h3>
|
||||
<button class="text-primary font-bold text-sm flex items-center gap-1" [routerLink]="['/bet-history']">
|
||||
{{ t('home.view_history') }} <mat-icon class="text-sm">chevron_right</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 overflow-x-auto no-scrollbar pb-1 -mx-6 px-6">
|
||||
<button (click)="setFilter('All')" [class.bg-primary]="selectedFilter() === 'All'" [class.text-white]="selectedFilter() === 'All'" [class.bg-surface-container-high]="selectedFilter() !== 'All'" [class.text-on-surface-variant]="selectedFilter() !== 'All'" class="whitespace-nowrap px-4 py-1.5 rounded-full text-xs font-bold shadow-sm transition-colors">{{ t('home.filter.all') }}</button>
|
||||
<button (click)="setFilter('1 Day')" [class.bg-primary]="selectedFilter() === '1 Day'" [class.text-white]="selectedFilter() === '1 Day'" [class.bg-surface-container-high]="selectedFilter() !== '1 Day'" [class.text-on-surface-variant]="selectedFilter() !== '1 Day'" class="whitespace-nowrap px-4 py-1.5 rounded-full text-xs font-bold hover:bg-surface-container transition-colors">{{ t('home.filter.1day') }}</button>
|
||||
<button (click)="setFilter('5 Days')" [class.bg-primary]="selectedFilter() === '5 Days'" [class.text-white]="selectedFilter() === '5 Days'" [class.bg-surface-container-high]="selectedFilter() !== '5 Days'" [class.text-on-surface-variant]="selectedFilter() !== '5 Days'" class="whitespace-nowrap px-4 py-1.5 rounded-full text-xs font-bold hover:bg-surface-container transition-colors">{{ t('home.filter.5days') }}</button>
|
||||
<button (click)="setFilter('30 Days')" [class.bg-primary]="selectedFilter() === '30 Days'" [class.text-white]="selectedFilter() === '30 Days'" [class.bg-surface-container-high]="selectedFilter() !== '30 Days'" [class.text-on-surface-variant]="selectedFilter() !== '30 Days'" class="whitespace-nowrap px-4 py-1.5 rounded-full text-xs font-bold hover:bg-surface-container transition-colors">{{ t('home.filter.30days') }}</button>
|
||||
<button (click)="setFilter('1 Year')" [class.bg-primary]="selectedFilter() === '1 Year'" [class.text-white]="selectedFilter() === '1 Year'" [class.bg-surface-container-high]="selectedFilter() !== '1 Year'" [class.text-on-surface-variant]="selectedFilter() !== '1 Year'" class="whitespace-nowrap px-4 py-1.5 rounded-full text-xs font-bold hover:bg-surface-container transition-colors">{{ t('home.filter.1year') }}</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mt-6">
|
||||
@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>
|
||||
<h4 class="font-bold">{{ challenge.title }}</h4>
|
||||
<p class="text-xs text-on-surface-variant mt-0.5">{{ challenge.description }}</p>
|
||||
</div>
|
||||
<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="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 {
|
||||
<div class="col-span-full flex flex-col items-center justify-center py-12 text-center bg-surface-container-low rounded-2xl border border-dashed border-on-surface-variant/20">
|
||||
<mat-icon class="text-4xl text-on-surface-variant/20 mb-2">search_off</mat-icon>
|
||||
<p class="text-on-surface-variant font-medium">No bets found for this period</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- FAB -->
|
||||
<button class="fixed bottom-24 right-6 w-16 h-16 rounded-full bg-gradient-to-br from-primary to-primary-container text-white shadow-2xl flex items-center justify-center z-50 transform active:scale-95 transition-transform" [routerLink]="['/create-bet']">
|
||||
<mat-icon class="text-3xl">add</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
@@ -0,0 +1,97 @@
|
||||
import {ChangeDetectionStrategy, Component, inject, signal, computed} from '@angular/core';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {MatIconModule} from '@angular/material/icon';
|
||||
import {RouterModule} from '@angular/router';
|
||||
import {toSignal} from '@angular/core/rxjs-interop';
|
||||
import {BetService} from '../../services/bet';
|
||||
import {UserService} from '../../services/user';
|
||||
import {LanguageService} from '../../services/language';
|
||||
import {Bet, User, ChallengeData, UserProfile} from '../../models/data.models';
|
||||
|
||||
type FilterType = 'All' | '1 Day' | '5 Days' | '30 Days' | '1 Year';
|
||||
|
||||
@Component({
|
||||
selector: 'app-home',
|
||||
imports: [CommonModule, MatIconModule, RouterModule],
|
||||
templateUrl: './home.html',
|
||||
styleUrl: './home.css',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class Home {
|
||||
private betService = inject(BetService);
|
||||
private userService = inject(UserService);
|
||||
private langService = inject(LanguageService);
|
||||
|
||||
private allBets = toSignal(this.betService.getUserBets(), {initialValue: [] as Bet[]});
|
||||
private currentUser = toSignal(this.userService.getCurrentUser());
|
||||
|
||||
userProfile = computed<UserProfile>(() => {
|
||||
const user = this.currentUser();
|
||||
if (!user) return {id: '', name: '', username: '', avatar: '', score: 0, winStreak: 0, totalWins: 0, rankPercentile: 0};
|
||||
return this.mapUserToProfile(user);
|
||||
});
|
||||
|
||||
selectedFilter = signal<FilterType>('All');
|
||||
|
||||
filteredChallenges = computed<ChallengeData[]>(() => {
|
||||
const allChallenges = this.allBets().map(b => this.mapBetToChallenge(b));
|
||||
const filter = this.selectedFilter();
|
||||
|
||||
if (filter === 'All') return allChallenges;
|
||||
|
||||
const now = new Date();
|
||||
const filterDays: Record<string, number> = {
|
||||
'1 Day': 1,
|
||||
'5 Days': 5,
|
||||
'30 Days': 30,
|
||||
'1 Year': 365
|
||||
};
|
||||
|
||||
return allChallenges.filter(c => {
|
||||
const createdDate = new Date(c.createdAt);
|
||||
const diffTime = Math.abs(now.getTime() - createdDate.getTime());
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
return diffDays <= filterDays[filter];
|
||||
});
|
||||
});
|
||||
|
||||
setFilter(filter: FilterType) {
|
||||
this.selectedFilter.set(filter);
|
||||
}
|
||||
|
||||
t(key: string): string {
|
||||
return this.langService.translate(key);
|
||||
}
|
||||
|
||||
private mapUserToProfile(user: User): UserProfile {
|
||||
return {
|
||||
id: String(user.id),
|
||||
name: user.name,
|
||||
username: user.email,
|
||||
avatar: user.avatarUrl,
|
||||
score: user.score,
|
||||
winStreak: 0,
|
||||
totalWins: user.score,
|
||||
rankPercentile: user.rankPercentile ?? 0
|
||||
};
|
||||
}
|
||||
|
||||
private mapBetToChallenge(bet: Bet): ChallengeData {
|
||||
const statusMap: Record<string, 'in-progress' | 'completed' | 'pending'> = {
|
||||
'ACTIVE': 'in-progress',
|
||||
'AWAITING_VALIDATION': 'pending',
|
||||
'COMPLETED': 'completed',
|
||||
'EXPIRED': 'completed'
|
||||
};
|
||||
return {
|
||||
id: bet.id,
|
||||
title: bet.title,
|
||||
description: bet.description ?? '',
|
||||
deadline: bet.endDate ?? '',
|
||||
status: statusMap[bet.status] ?? 'pending',
|
||||
stake: bet.stake,
|
||||
participantsCount: bet.participants.length,
|
||||
createdAt: bet.startDate
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/* Profile component styles */
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
<div class="min-h-screen pb-32">
|
||||
<header class="bg-background sticky top-0 z-50 px-6 py-4 flex justify-between items-center shadow-sm">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-full overflow-hidden bg-primary-container/20">
|
||||
@if (userProfile()?.avatarUrl) {
|
||||
<img [src]="userProfile()?.avatarUrl" class="w-full h-full object-cover" alt="User Avatar">
|
||||
} @else {
|
||||
<mat-icon class="text-primary w-full h-full flex items-center justify-center">person</mat-icon>
|
||||
}
|
||||
</div>
|
||||
<h1 class="font-headline font-black tracking-tight text-xl text-primary">{{ t('nav.profile') }}</h1>
|
||||
</div>
|
||||
<button class="w-10 h-10 flex items-center justify-center rounded-full hover:bg-surface-container transition-colors" [routerLink]="['/settings']">
|
||||
<mat-icon class="text-primary">settings</mat-icon>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<main class="px-6 space-y-8 max-w-2xl mx-auto">
|
||||
@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">Loading your profile...</p>
|
||||
</div>
|
||||
} @else if (userProfile(); as user) {
|
||||
<!-- User Header -->
|
||||
<section class="flex flex-col items-center text-center mt-6">
|
||||
<div class="relative group">
|
||||
<div class="w-32 h-32 rounded-2xl overflow-hidden border-4 border-surface shadow-xl bg-surface-container">
|
||||
<img [src]="user.avatarUrl || 'assets/default-avatar.png'" class="w-full h-full object-cover" alt="User Avatar Large">
|
||||
</div>
|
||||
<div class="absolute -bottom-2 -right-2 bg-secondary-container p-2 rounded-full shadow-lg">
|
||||
<mat-icon class="text-secondary text-sm" style="font-variation-settings: 'FILL' 1;">verified</mat-icon>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<h2 class="text-3xl font-headline font-extrabold tracking-tight">{{ user.name }}</h2>
|
||||
<p class="text-on-surface-variant font-medium mt-1">{{ user.email }}</p>
|
||||
<div class="flex items-center justify-center gap-1.5 mt-3 py-1.5 px-4 bg-amber-50 rounded-full border border-amber-200/50">
|
||||
<mat-icon class="text-amber-500 text-lg" style="font-variation-settings: 'FILL' 1;">stars</mat-icon>
|
||||
<span class="font-headline font-bold text-sm text-amber-700">{{ user.score }} Points</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Stats Dashboard -->
|
||||
<section class="grid grid-cols-3 gap-3">
|
||||
<div class="bg-surface p-4 rounded-2xl flex flex-col items-center justify-center text-center soft-shadow border border-black/5">
|
||||
<span class="text-2xl font-headline font-extrabold text-primary">0</span>
|
||||
<span class="text-[10px] uppercase tracking-widest font-bold text-on-surface-variant mt-1">{{ t('home.total_wins') }}</span>
|
||||
</div>
|
||||
<div class="bg-primary-container p-4 rounded-2xl flex flex-col items-center justify-center text-center shadow-lg shadow-primary/20">
|
||||
<span class="text-2xl font-headline font-extrabold text-white">0</span>
|
||||
<span class="text-[10px] uppercase tracking-widest font-bold text-white/80 mt-1">{{ t('profile.streak') }}</span>
|
||||
</div>
|
||||
<div class="bg-surface p-4 rounded-2xl flex flex-col items-center justify-center text-center soft-shadow border border-black/5">
|
||||
<span class="text-2xl font-headline font-extrabold text-error">0</span>
|
||||
<span class="text-[10px] uppercase tracking-widest font-bold text-on-surface-variant mt-1">{{ t('profile.losses') }}</span>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
<!-- Friend Requests Section -->
|
||||
@if (incomingRequests().length > 0) {
|
||||
<section class="space-y-3">
|
||||
<div class="flex items-center gap-2 px-1">
|
||||
<h3 class="text-lg font-headline font-bold">Richieste di amicizia</h3>
|
||||
<span class="flex items-center justify-center w-5 h-5 rounded-full bg-primary text-on-primary text-[10px] font-black">{{ incomingRequests().length }}</span>
|
||||
</div>
|
||||
<div class="flex gap-3 overflow-x-auto pb-2 no-scrollbar">
|
||||
@for (req of incomingRequests(); track req.id) {
|
||||
<div class="flex-none w-36 bg-surface rounded-2xl p-3 flex flex-col items-center gap-2 border border-black/5 soft-shadow">
|
||||
<div class="w-12 h-12 rounded-full bg-primary-container/20 flex items-center justify-center overflow-hidden">
|
||||
@if (req.fromUser?.avatarUrl) {
|
||||
<img [src]="req.fromUser!.avatarUrl" class="w-full h-full object-cover" [alt]="req.fromUser!.name">
|
||||
} @else {
|
||||
<mat-icon class="text-primary">person</mat-icon>
|
||||
}
|
||||
</div>
|
||||
<p class="text-xs font-semibold text-center truncate w-full">{{ req.fromUser?.name }}</p>
|
||||
@if (respondingToId() === req.id) {
|
||||
<div class="w-5 h-5 border-2 border-primary border-t-transparent rounded-full animate-spin"></div>
|
||||
} @else {
|
||||
<div class="flex gap-2 w-full">
|
||||
<button
|
||||
(click)="acceptRequest(req)"
|
||||
class="flex-1 flex items-center justify-center py-1.5 rounded-xl bg-primary text-on-primary">
|
||||
<mat-icon style="font-size:16px;width:16px;height:16px;">check</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
(click)="rejectRequest(req)"
|
||||
class="flex-1 flex items-center justify-center py-1.5 rounded-xl bg-error-container/30 text-error">
|
||||
<mat-icon style="font-size:16px;width:16px;height:16px;">close</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
<!-- Friends Section -->
|
||||
<section class="space-y-4">
|
||||
<div class="flex justify-between items-center px-1">
|
||||
<div class="flex items-center gap-3">
|
||||
<h3 class="text-lg font-headline font-bold">Amici</h3>
|
||||
<button (click)="openAddFriendSheet()" class="flex items-center justify-center w-8 h-8 rounded-full bg-primary/10 text-primary hover:bg-primary/20 transition-colors">
|
||||
<mat-icon class="text-xl">person_add</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
<button (click)="openFriendsSheet()" class="text-primary text-sm font-bold">Vedi tutti</button>
|
||||
</div>
|
||||
<div class="flex gap-3 overflow-x-auto pb-2 no-scrollbar">
|
||||
@for (friend of friends(); track friend.id) {
|
||||
<div class="flex flex-col items-center gap-1 flex-none">
|
||||
<div class="w-14 h-14 rounded-full border-2 border-primary p-0.5">
|
||||
@if (friend.avatarUrl) {
|
||||
<img [src]="friend.avatarUrl" class="w-full h-full rounded-full object-cover" [alt]="friend.name">
|
||||
} @else {
|
||||
<div class="w-full h-full rounded-full bg-primary-container/20 flex items-center justify-center">
|
||||
<mat-icon class="text-primary">person</mat-icon>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<span class="text-[10px] font-medium text-on-surface-variant">{{ friend.name }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (friends().length === 0) {
|
||||
<p class="text-sm text-on-surface-variant italic">Nessun amico ancora</p>
|
||||
}
|
||||
<button (click)="openFriendsSheet()" class="flex flex-col items-center gap-1 flex-none">
|
||||
<div class="w-14 h-14 rounded-full bg-surface-container flex items-center justify-center border-2 border-transparent border-dashed">
|
||||
<mat-icon class="text-on-surface-variant">search</mat-icon>
|
||||
</div>
|
||||
<span class="text-[10px] font-medium text-on-surface-variant">Cerca</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Recent Arena Battles -->
|
||||
<section class="space-y-4">
|
||||
<div class="flex justify-between items-end px-1">
|
||||
<h3 class="text-lg font-headline font-bold">Recent Arena Battles</h3>
|
||||
<button class="text-primary text-sm font-bold">View All</button>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<!-- Card 1: Win -->
|
||||
<div class="bg-surface-container-low p-4 rounded-2xl flex items-center justify-between group border border-black/5">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-12 h-12 rounded-full bg-secondary-container/20 flex items-center justify-center">
|
||||
<mat-icon class="text-secondary">sports_soccer</mat-icon>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-bold">UCL Final: Real vs City</h4>
|
||||
<p class="text-xs text-on-surface-variant">Yesterday • Over 2.5 Goals</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-secondary font-headline font-extrabold text-sm">+ $240.00</div>
|
||||
<span class="inline-block px-2 py-0.5 bg-secondary-container text-[10px] font-black rounded-full text-secondary uppercase">Win</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Card 2: Loss -->
|
||||
<div class="bg-surface-container-low p-4 rounded-2xl flex items-center justify-between border border-black/5">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-12 h-12 rounded-full bg-tertiary-container/20 flex items-center justify-center">
|
||||
<mat-icon class="text-tertiary">sports_basketball</mat-icon>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-bold">NBA: Lakers vs Celtics</h4>
|
||||
<p class="text-xs text-on-surface-variant">2 days ago • Celtics ML</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-error font-headline font-extrabold text-sm">- $50.00</div>
|
||||
<span class="inline-block px-2 py-0.5 bg-error-container/20 text-[10px] font-black rounded-full text-error uppercase">Loss</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Arena Status -->
|
||||
<section class="bg-surface-container p-6 rounded-2xl relative overflow-hidden border border-black/5">
|
||||
<div class="relative z-10 flex flex-col gap-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<h3 class="font-headline font-bold">Arena Status</h3>
|
||||
<span class="text-primary font-black text-xs uppercase tracking-tighter">Gold Tier</span>
|
||||
</div>
|
||||
<div class="w-full bg-surface-container-highest h-3 rounded-full overflow-hidden">
|
||||
<div class="h-full w-3/4 bg-gradient-to-r from-primary to-primary-container rounded-full"></div>
|
||||
</div>
|
||||
<p class="text-xs text-on-surface-variant leading-relaxed">You are <span class="font-bold text-primary">450 Arena XP</span> away from Diamond Tier rewards.</p>
|
||||
</div>
|
||||
<div class="absolute -top-10 -right-10 w-32 h-32 bg-primary/10 rounded-full blur-3xl"></div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Friends List Bottom Sheet -->
|
||||
@if (showFriendsSheet()) {
|
||||
<div class="fixed inset-0 z-50 flex flex-col justify-end">
|
||||
<div class="absolute inset-0 bg-black/40 backdrop-blur-sm" (click)="closeFriendsSheet()"></div>
|
||||
|
||||
<div
|
||||
class="relative bg-background shadow-2xl flex flex-col transition-all duration-300 ease-out"
|
||||
[class]="friendsSheetExpanded() ? '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)="onFriendsHandleTouchEnd($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">Amici ({{ friends().length }})</h3>
|
||||
<button (click)="closeFriendsSheet()" 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 Input -->
|
||||
<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="Cerca tra i tuoi amici..."
|
||||
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">
|
||||
<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>
|
||||
<mat-icon class="text-secondary flex-none text-lg">people</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>
|
||||
}
|
||||
|
||||
<!-- Add Friend Bottom Sheet -->
|
||||
@if (showAddFriendSheet()) {
|
||||
<div class="fixed inset-0 z-50 flex flex-col justify-end">
|
||||
<!-- Backdrop -->
|
||||
<div class="absolute inset-0 bg-black/40 backdrop-blur-sm" (click)="closeAddFriendSheet()"></div>
|
||||
|
||||
<!-- Sheet -->
|
||||
<div
|
||||
class="relative bg-background shadow-2xl flex flex-col transition-all duration-300 ease-out"
|
||||
[class]="sheetExpanded() ? '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">Aggiungi amico</h3>
|
||||
<button (click)="closeAddFriendSheet()" 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 Input -->
|
||||
<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="Cerca per nome o email..."
|
||||
class="flex-1 bg-transparent text-sm outline-none text-on-surface placeholder:text-on-surface-variant"
|
||||
[ngModel]="searchQuery()"
|
||||
(ngModelChange)="onSearchChange($event)"
|
||||
autofocus
|
||||
>
|
||||
@if (isSearching()) {
|
||||
<div class="w-4 h-4 border-2 border-primary border-t-transparent rounded-full animate-spin flex-none"></div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results -->
|
||||
<div class="overflow-y-auto flex-1 px-6 pb-8 space-y-1">
|
||||
@if (searchResults().length > 0) {
|
||||
@for (user of searchResults(); track user.id) {
|
||||
<button
|
||||
class="w-full flex items-center gap-3 p-3 rounded-2xl transition-colors text-left"
|
||||
[class]="isFriend(user.id) || sentToIds().has(user.id) ? 'opacity-60 cursor-default' : 'hover:bg-surface-container active:bg-surface-container-high cursor-pointer'"
|
||||
[disabled]="isFriend(user.id) || sentToIds().has(user.id) || sendingToId() === user.id"
|
||||
(click)="sendFriendRequest(user)">
|
||||
<div class="w-11 h-11 rounded-full bg-primary-container/20 flex items-center justify-center flex-none overflow-hidden">
|
||||
@if (user.avatarUrl) {
|
||||
<img [src]="user.avatarUrl" class="w-full h-full object-cover" [alt]="user.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">{{ user.name }}</p>
|
||||
<p class="text-xs text-on-surface-variant truncate">{{ user.email }}</p>
|
||||
</div>
|
||||
<div class="flex-none">
|
||||
@if (sendingToId() === user.id) {
|
||||
<div class="w-5 h-5 border-2 border-primary border-t-transparent rounded-full animate-spin"></div>
|
||||
} @else if (sentToIds().has(user.id)) {
|
||||
<mat-icon class="text-on-surface-variant text-lg">check_circle</mat-icon>
|
||||
} @else if (isFriend(user.id)) {
|
||||
<mat-icon class="text-secondary text-lg">people</mat-icon>
|
||||
} @else {
|
||||
<mat-icon class="text-primary text-lg">person_add</mat-icon>
|
||||
}
|
||||
</div>
|
||||
</button>
|
||||
}
|
||||
} @else if (searchQuery().trim() && !isSearching()) {
|
||||
<p class="text-sm text-on-surface-variant text-center py-8">Nessun utente trovato</p>
|
||||
} @else if (!searchQuery().trim()) {
|
||||
<p class="text-sm text-on-surface-variant text-center py-8">Cerca un utente per nome o email</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
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} from '@angular/router';
|
||||
import {debounceTime, distinctUntilChanged, Subject, switchMap} from 'rxjs';
|
||||
import {UserService} from '../../services/user';
|
||||
import {FriendService} from '../../services/friend.service';
|
||||
import {LanguageService} from '../../services/language';
|
||||
import {FriendRequest, User} from '../../models/data.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-profile',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, MatIconModule, RouterModule],
|
||||
templateUrl: './profile.html',
|
||||
styleUrl: './profile.css',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class Profile implements OnInit {
|
||||
private userService = inject(UserService);
|
||||
private friendService = inject(FriendService);
|
||||
private langService = inject(LanguageService);
|
||||
|
||||
userProfile = signal<User | null>(null);
|
||||
friends = signal<User[]>([]);
|
||||
pendingRequests = signal<FriendRequest[]>([]);
|
||||
respondingToId = signal<string | null>(null);
|
||||
isLoading = signal(true);
|
||||
|
||||
// Add friend sheet
|
||||
showAddFriendSheet = signal(false);
|
||||
sheetExpanded = signal(false);
|
||||
searchQuery = signal('');
|
||||
searchResults = signal<User[]>([]);
|
||||
isSearching = signal(false);
|
||||
sendingToId = signal<string | null>(null);
|
||||
sentToIds = signal<Set<string>>(new Set());
|
||||
|
||||
// Friends list sheet
|
||||
showFriendsSheet = signal(false);
|
||||
friendsSheetExpanded = signal(false);
|
||||
friendsSearchQuery = signal('');
|
||||
|
||||
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)
|
||||
);
|
||||
}
|
||||
|
||||
private touchStartY = 0;
|
||||
|
||||
private searchInput$ = new Subject<string>();
|
||||
|
||||
ngOnInit() {
|
||||
this.userService.getCurrentUser().subscribe({
|
||||
next: (user) => {
|
||||
this.userProfile.set(user);
|
||||
this.isLoading.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Error loading profile:', err);
|
||||
this.isLoading.set(false);
|
||||
}
|
||||
});
|
||||
|
||||
this.friendService.getFriends().subscribe({
|
||||
next: (friends) => this.friends.set(friends),
|
||||
error: (err) => console.error('Error loading friends:', err),
|
||||
});
|
||||
|
||||
this.friendService.getPendingRequests().subscribe({
|
||||
next: (requests) => this.pendingRequests.set(requests),
|
||||
error: (err) => console.error('Error loading pending requests:', err),
|
||||
});
|
||||
|
||||
this.searchInput$.pipe(
|
||||
debounceTime(350),
|
||||
distinctUntilChanged(),
|
||||
switchMap((query) => {
|
||||
if (!query.trim()) {
|
||||
this.searchResults.set([]);
|
||||
this.isSearching.set(false);
|
||||
return [];
|
||||
}
|
||||
this.isSearching.set(true);
|
||||
return this.userService.searchUsers(query);
|
||||
})
|
||||
).subscribe({
|
||||
next: (results) => {
|
||||
this.searchResults.set(results);
|
||||
this.isSearching.set(false);
|
||||
},
|
||||
error: () => this.isSearching.set(false),
|
||||
});
|
||||
}
|
||||
|
||||
openAddFriendSheet() {
|
||||
this.searchQuery.set('');
|
||||
this.searchResults.set([]);
|
||||
this.sentToIds.set(new Set());
|
||||
this.sheetExpanded.set(false);
|
||||
this.showAddFriendSheet.set(true);
|
||||
}
|
||||
|
||||
closeAddFriendSheet() {
|
||||
this.showAddFriendSheet.set(false);
|
||||
this.sheetExpanded.set(false);
|
||||
}
|
||||
|
||||
openFriendsSheet() {
|
||||
this.friendsSearchQuery.set('');
|
||||
this.friendsSheetExpanded.set(false);
|
||||
this.showFriendsSheet.set(true);
|
||||
}
|
||||
|
||||
closeFriendsSheet() {
|
||||
this.showFriendsSheet.set(false);
|
||||
this.friendsSheetExpanded.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.sheetExpanded.set(true);
|
||||
} else if (deltaY < -40) {
|
||||
if (this.sheetExpanded()) {
|
||||
this.sheetExpanded.set(false);
|
||||
} else {
|
||||
this.closeAddFriendSheet();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onFriendsHandleTouchEnd(event: TouchEvent) {
|
||||
const deltaY = this.touchStartY - event.changedTouches[0].clientY;
|
||||
if (deltaY > 40) {
|
||||
this.friendsSheetExpanded.set(true);
|
||||
} else if (deltaY < -40) {
|
||||
if (this.friendsSheetExpanded()) {
|
||||
this.friendsSheetExpanded.set(false);
|
||||
} else {
|
||||
this.closeFriendsSheet();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onSearchChange(query: string) {
|
||||
this.searchQuery.set(query);
|
||||
this.searchInput$.next(query);
|
||||
}
|
||||
|
||||
sendFriendRequest(user: User) {
|
||||
this.sendingToId.set(user.id);
|
||||
this.friendService.sendRequest(user.id).subscribe({
|
||||
next: () => {
|
||||
this.sendingToId.set(null);
|
||||
const updated = new Set(this.sentToIds());
|
||||
updated.add(user.id);
|
||||
this.sentToIds.set(updated);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Error sending friend request:', err);
|
||||
this.sendingToId.set(null);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
incomingRequests(): FriendRequest[] {
|
||||
const currentId = this.userProfile()?.id;
|
||||
return this.pendingRequests().filter(r => String(r.toUserId) === String(currentId));
|
||||
}
|
||||
|
||||
acceptRequest(request: FriendRequest) {
|
||||
this.respondingToId.set(request.id);
|
||||
this.friendService.acceptRequest(Number(request.id)).subscribe({
|
||||
next: () => {
|
||||
this.pendingRequests.update(reqs => reqs.filter(r => r.id !== request.id));
|
||||
if (request.fromUser) {
|
||||
this.friends.update(f => [...f, request.fromUser!]);
|
||||
}
|
||||
this.respondingToId.set(null);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Error accepting request:', err);
|
||||
this.respondingToId.set(null);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
rejectRequest(request: FriendRequest) {
|
||||
this.respondingToId.set(request.id);
|
||||
this.friendService.rejectRequest(Number(request.id)).subscribe({
|
||||
next: () => {
|
||||
this.pendingRequests.update(reqs => reqs.filter(r => r.id !== request.id));
|
||||
this.respondingToId.set(null);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Error rejecting request:', err);
|
||||
this.respondingToId.set(null);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
isFriend(userId: string): boolean {
|
||||
return this.friends().some(f => f.id === userId);
|
||||
}
|
||||
|
||||
t(key: string): string {
|
||||
return this.langService.translate(key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/* Settings component styles */
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
<div class="min-h-screen pb-32">
|
||||
<header class="w-full top-0 sticky z-50 bg-background/80 backdrop-blur-md">
|
||||
<div class="flex items-center px-4 h-16 w-full">
|
||||
<div class="flex items-center gap-4">
|
||||
<button class="hover:bg-surface-container transition-colors active:scale-95 duration-150 p-2 rounded-full flex items-center justify-center text-primary" [routerLink]="['/profile']">
|
||||
<mat-icon>arrow_back</mat-icon>
|
||||
</button>
|
||||
<h1 class="font-headline text-lg font-bold text-primary">{{ t('settings.title') }}</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-surface-container-low h-[1px] w-full"></div>
|
||||
</header>
|
||||
|
||||
<main class="max-w-2xl mx-auto px-4 pt-6 pb-32">
|
||||
<div class="space-y-4">
|
||||
<h3 class="font-headline text-xs font-extrabold uppercase tracking-widest text-on-surface-variant px-2 mb-2">{{ t('settings.account') }}</h3>
|
||||
<div class="bg-surface-container-low rounded-2xl overflow-hidden border border-black/5">
|
||||
<button class="w-full flex items-center justify-between px-6 py-5 hover:bg-surface-container transition-colors group">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="bg-primary-container/10 p-2 rounded-full text-primary">
|
||||
<mat-icon>person</mat-icon>
|
||||
</div>
|
||||
<span class="font-semibold">{{ t('settings.account') }}</span>
|
||||
</div>
|
||||
<mat-icon class="text-on-surface-variant group-hover:translate-x-1 transition-transform">chevron_right</mat-icon>
|
||||
</button>
|
||||
<button class="w-full flex items-center justify-between px-6 py-5 hover:bg-surface-container transition-colors group">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="bg-primary-container/10 p-2 rounded-full text-primary">
|
||||
<mat-icon>notifications</mat-icon>
|
||||
</div>
|
||||
<span class="font-semibold">{{ t('settings.notifications') }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="bg-error text-white text-[10px] px-2 py-0.5 rounded-full font-bold">2</span>
|
||||
<mat-icon class="text-on-surface-variant group-hover:translate-x-1 transition-transform">chevron_right</mat-icon>
|
||||
</div>
|
||||
</button>
|
||||
<div class="w-full flex items-center justify-between px-6 py-5">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="bg-primary-container/10 p-2 rounded-full text-primary">
|
||||
<mat-icon>dark_mode</mat-icon>
|
||||
</div>
|
||||
<span class="font-semibold">{{ t('settings.dark_mode') }}</span>
|
||||
</div>
|
||||
<button
|
||||
(click)="themeService.toggleDarkMode()"
|
||||
class="w-12 h-6 rounded-full p-1 flex items-center transition-all duration-300"
|
||||
[class.bg-primary]="themeService.isDarkMode()"
|
||||
[class.bg-surface-container-highest]="!themeService.isDarkMode()"
|
||||
>
|
||||
<div
|
||||
class="w-4 h-4 rounded-full shadow-md transition-transform duration-300"
|
||||
[class.translate-x-6]="themeService.isDarkMode()"
|
||||
[class.bg-white]="!themeService.isDarkMode()"
|
||||
[class.bg-on-primary]="themeService.isDarkMode()"
|
||||
></div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Language Selector -->
|
||||
<div class="w-full flex items-center justify-between px-6 py-5 border-t border-black/5">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="bg-primary-container/10 p-2 rounded-full text-primary">
|
||||
<mat-icon>language</mat-icon>
|
||||
</div>
|
||||
<span class="font-semibold">{{ t('settings.language') }}</span>
|
||||
</div>
|
||||
<div class="flex bg-surface-container-highest p-1 rounded-full">
|
||||
<button
|
||||
(click)="setLanguage('en')"
|
||||
[class.bg-primary]="langService.getLang()() === 'en'"
|
||||
[class.text-white]="langService.getLang()() === 'en'"
|
||||
class="px-4 py-1 rounded-full text-xs font-bold transition-all"
|
||||
>EN</button>
|
||||
<button
|
||||
(click)="setLanguage('it')"
|
||||
[class.bg-primary]="langService.getLang()() === 'it'"
|
||||
[class.text-white]="langService.getLang()() === 'it'"
|
||||
class="px-4 py-1 rounded-full text-xs font-bold transition-all"
|
||||
>IT</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 space-y-4">
|
||||
<h3 class="font-headline text-xs font-extrabold uppercase tracking-widest text-on-surface-variant px-2 mb-2">{{ t('settings.privacy') }}</h3>
|
||||
<div class="bg-surface-container-low rounded-2xl overflow-hidden border border-black/5">
|
||||
<button class="w-full flex items-center justify-between px-6 py-5 hover:bg-surface-container transition-colors group" [routerLink]="['/login']">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="bg-primary-container/10 p-2 rounded-full text-primary">
|
||||
<mat-icon>login</mat-icon>
|
||||
</div>
|
||||
<span class="font-semibold">{{ t('login.title') }}</span>
|
||||
</div>
|
||||
<mat-icon class="text-on-surface-variant group-hover:translate-x-1 transition-transform">chevron_right</mat-icon>
|
||||
</button>
|
||||
<button class="w-full flex items-center justify-between px-6 py-5 hover:bg-surface-container transition-colors group" [routerLink]="['/register']">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="bg-primary-container/10 p-2 rounded-full text-primary">
|
||||
<mat-icon>person_add</mat-icon>
|
||||
</div>
|
||||
<span class="font-semibold">{{ t('register.title') }}</span>
|
||||
</div>
|
||||
<mat-icon class="text-on-surface-variant group-hover:translate-x-1 transition-transform">chevron_right</mat-icon>
|
||||
</button>
|
||||
<button class="w-full flex items-center justify-between px-6 py-5 hover:bg-surface-container transition-colors group">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="bg-primary-container/10 p-2 rounded-full text-primary">
|
||||
<mat-icon>lock</mat-icon>
|
||||
</div>
|
||||
<span class="font-semibold">{{ t('settings.privacy') }}</span>
|
||||
</div>
|
||||
<mat-icon class="text-on-surface-variant group-hover:translate-x-1 transition-transform">chevron_right</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 px-2">
|
||||
<button
|
||||
(click)="logout()"
|
||||
class="w-full py-4 rounded-full border-2 border-error/20 text-error font-bold hover:bg-error/5 active:scale-[0.98] transition-all">
|
||||
{{ t('settings.sign_out') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<footer class="mt-12 text-center">
|
||||
<p class="text-xs font-bold text-on-surface-variant/50 tracking-widest">VERSION 1.2.4</p>
|
||||
<p class="text-[10px] text-on-surface-variant/30 mt-2">© 2024 The Social Kinetic System</p>
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
@@ -0,0 +1,32 @@
|
||||
import {ChangeDetectionStrategy, Component, inject} from '@angular/core';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {MatIconModule} from '@angular/material/icon';
|
||||
import {RouterModule} from '@angular/router';
|
||||
import {ThemeService} from '../../services/theme';
|
||||
import {LanguageService, Lang} from '../../services/language';
|
||||
import {AuthService} from '../../core/services/auth.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-settings',
|
||||
imports: [CommonModule, MatIconModule, RouterModule],
|
||||
templateUrl: './settings.html',
|
||||
styleUrl: './settings.css',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class Settings {
|
||||
themeService = inject(ThemeService);
|
||||
langService = inject(LanguageService);
|
||||
authService = inject(AuthService);
|
||||
|
||||
setLanguage(lang: Lang) {
|
||||
this.langService.setLang(lang);
|
||||
}
|
||||
|
||||
logout() {
|
||||
this.authService.logout();
|
||||
}
|
||||
|
||||
t(key: string): string {
|
||||
return this.langService.translate(key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { HttpInterceptorFn } from '@angular/common/http';
|
||||
import { inject } from '@angular/core';
|
||||
import { from, switchMap } from 'rxjs';
|
||||
import { AuthService } from '../services/auth.service';
|
||||
|
||||
export const authInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
const authService = inject(AuthService);
|
||||
|
||||
return from(authService.getValidToken()).pipe(
|
||||
switchMap(token => {
|
||||
if (!token) return next(req);
|
||||
const authReq = req.clone({
|
||||
headers: req.headers.set('Authorization', `Bearer ${token}`)
|
||||
});
|
||||
return next(authReq);
|
||||
})
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { KEYCLOAK_INSTANCE } from '../tokens/keycloak.token';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class AuthService {
|
||||
private keycloak = inject(KEYCLOAK_INSTANCE);
|
||||
|
||||
async getValidToken(): Promise<string> {
|
||||
try {
|
||||
await this.keycloak.updateToken(30);
|
||||
} catch {
|
||||
this.keycloak.login();
|
||||
return '';
|
||||
}
|
||||
return this.keycloak.token!;
|
||||
}
|
||||
|
||||
get tokenParsed() {
|
||||
return this.keycloak.tokenParsed;
|
||||
}
|
||||
|
||||
logout() {
|
||||
this.keycloak.logout({ redirectUri: window.location.origin });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import {User} from "../../models/data.models";
|
||||
|
||||
/**
|
||||
* 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,64 @@
|
||||
{
|
||||
"app.name": "ScommettiApp",
|
||||
"nav.home": "Home",
|
||||
"nav.groups": "Groups",
|
||||
"nav.create": "Create",
|
||||
"nav.explore": "Explore",
|
||||
"nav.profile": "Profile",
|
||||
"home.welcome": "Welcome Back",
|
||||
"home.active_bets": "Active Bets",
|
||||
"home.view_history": "View History",
|
||||
"home.total_wins": "Total Wins",
|
||||
"home.next_deadline": "Next Deadline",
|
||||
"home.filter.all": "All",
|
||||
"home.filter.1day": "1 Day",
|
||||
"home.filter.5days": "5 Days",
|
||||
"home.filter.30days": "30 Days",
|
||||
"home.filter.1year": "1 Year",
|
||||
"groups.title": "Your Groups",
|
||||
"groups.create": "Create Group",
|
||||
"groups.members": "Members",
|
||||
"profile.balance": "Balance",
|
||||
"home.score": "Score",
|
||||
"profile.win_streak": "Win Streak",
|
||||
"profile.streak": "Streak",
|
||||
"profile.losses": "Losses",
|
||||
"profile.activity": "Recent Activity",
|
||||
"settings.title": "Settings",
|
||||
"settings.language": "Language",
|
||||
"settings.dark_mode": "Dark Mode",
|
||||
"settings.account": "Account Info",
|
||||
"settings.notifications": "Notifications",
|
||||
"settings.privacy": "Privacy",
|
||||
"settings.sign_out": "Sign Out",
|
||||
"history.title": "Bet History",
|
||||
"history.search": "Search bets...",
|
||||
"login.title": "Log In",
|
||||
"login.subtitle": "Log in to your account",
|
||||
"register.title": "Sign Up",
|
||||
"register.subtitle": "Create an account",
|
||||
"common.loading": "Loading...",
|
||||
"common.save": "Save",
|
||||
"common.cancel": "Cancel",
|
||||
"common.back": "Back",
|
||||
"common.search": "Search",
|
||||
"common.edit": "Edit",
|
||||
"common.delete": "Delete",
|
||||
"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"
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"app.name": "ScommettiApp",
|
||||
"nav.home": "Home",
|
||||
"nav.groups": "Gruppi",
|
||||
"nav.create": "Crea",
|
||||
"nav.explore": "Esplora",
|
||||
"nav.profile": "Profilo",
|
||||
"home.welcome": "Bentornato",
|
||||
"home.active_bets": "Scommesse Attive",
|
||||
"home.view_history": "Vedi Cronologia",
|
||||
"home.total_wins": "Vittorie Totali",
|
||||
"home.next_deadline": "Prossima Scadenza",
|
||||
"home.filter.all": "Tutte",
|
||||
"home.filter.1day": "1 Giorno",
|
||||
"home.filter.5days": "5 Giorni",
|
||||
"home.filter.30days": "30 Giorni",
|
||||
"home.filter.1year": "1 Anno",
|
||||
"groups.title": "I Tuoi Gruppi",
|
||||
"groups.create": "Crea Gruppo",
|
||||
"groups.members": "Membri",
|
||||
"profile.balance": "Bilancio",
|
||||
"home.score": "Punteggio",
|
||||
"profile.win_streak": "Striscia Vincente",
|
||||
"profile.streak": "Striscia",
|
||||
"profile.losses": "Perdite",
|
||||
"profile.activity": "Attività Recente",
|
||||
"settings.title": "Impostazioni",
|
||||
"settings.language": "Lingua",
|
||||
"settings.dark_mode": "Modalità Scura",
|
||||
"settings.account": "Info Account",
|
||||
"settings.notifications": "Notifiche",
|
||||
"settings.privacy": "Privacy",
|
||||
"settings.sign_out": "Disconnetti",
|
||||
"history.title": "Cronologia Scommesse",
|
||||
"history.search": "Cerca scommesse...",
|
||||
"login.title": "Accedi",
|
||||
"login.subtitle": "Accedi al tuo account",
|
||||
"register.title": "Registrati",
|
||||
"register.subtitle": "Crea un account",
|
||||
"common.loading": "Caricamento...",
|
||||
"common.save": "Salva",
|
||||
"common.cancel": "Annulla",
|
||||
"common.back": "Indietro",
|
||||
"common.search": "Cerca",
|
||||
"common.edit": "Modifica",
|
||||
"common.delete": "Elimina",
|
||||
"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"
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
// ─── Display Models (UI mock, mantenuti per compatibilità con i componenti esistenti) ───
|
||||
|
||||
export interface ChallengeData {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
deadline: string;
|
||||
status: 'in-progress' | 'completed' | 'pending';
|
||||
stake: string;
|
||||
participantsCount: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface GroupData {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
isPrivate: boolean;
|
||||
avatar: string;
|
||||
memberCount: number;
|
||||
totalPayouts: number;
|
||||
totalBets: number;
|
||||
winRate: number;
|
||||
}
|
||||
|
||||
export interface UserActivity {
|
||||
id: string;
|
||||
type: 'win' | 'loss' | 'join';
|
||||
title: string;
|
||||
amount?: number;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface UserProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
username: string;
|
||||
avatar: string;
|
||||
score: number;
|
||||
winStreak: number;
|
||||
totalWins: number;
|
||||
rankPercentile: number;
|
||||
}
|
||||
|
||||
// ─── Modelli Backend (usati nei servizi HTTP) ────────────────────────────────
|
||||
|
||||
export type BetStatus = 'ACTIVE' | 'AWAITING_VALIDATION' | 'COMPLETED' | 'EXPIRED';
|
||||
export type ParticipantRole = 'PARTICIPANT' | 'OBSERVER';
|
||||
|
||||
export interface BetParticipant {
|
||||
user: User | string | number; // popolato dal backend con l'oggetto User
|
||||
role: ParticipantRole;
|
||||
}
|
||||
|
||||
export interface Bet {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
stake: string;
|
||||
participants: BetParticipant[];
|
||||
status: BetStatus;
|
||||
startDate: string; // ISO 8601
|
||||
endDate?: string;
|
||||
isPublic: boolean;
|
||||
groupId?: string;
|
||||
winner?: number; // PostgreSQL User ID
|
||||
}
|
||||
|
||||
export interface CreateBetPayload {
|
||||
title: string;
|
||||
description?: string;
|
||||
stake: string;
|
||||
participants: BetParticipant[];
|
||||
isPublic?: boolean;
|
||||
groupId?: string;
|
||||
}
|
||||
|
||||
export interface UpdateBetPayload {
|
||||
title?: string;
|
||||
description?: string;
|
||||
stake?: string;
|
||||
status?: BetStatus;
|
||||
endDate?: string;
|
||||
winner?: number | null;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
avatarUrl: string;
|
||||
score: number;
|
||||
rankPercentile?: number;
|
||||
}
|
||||
|
||||
export interface Group {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
isPrivate?: boolean;
|
||||
avatarUrl?: string;
|
||||
members?: User[];
|
||||
}
|
||||
|
||||
export type FriendRequestStatus = 'PENDING' | 'ACCEPTED' | 'REJECTED';
|
||||
|
||||
export interface FriendRequest {
|
||||
id: string;
|
||||
status: FriendRequestStatus;
|
||||
fromUserId: number;
|
||||
toUserId: number;
|
||||
createdAt: string;
|
||||
fromUser?: User;
|
||||
toUser?: User;
|
||||
}
|
||||
|
||||
export interface ActivityItem {
|
||||
type: 'win' | 'loss' | 'bet_created' | 'friend_added' | 'group_joined';
|
||||
title: string;
|
||||
amount?: number;
|
||||
timestamp: string;
|
||||
relatedId?: string;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import {map, Observable} from 'rxjs';
|
||||
import { environment } from '../../environments/environment';
|
||||
import { Bet, CreateBetPayload, UpdateBetPayload } from '../models/data.models';
|
||||
import {SuccessResponse} from "../models/api-responses.model";
|
||||
import {toDate} from "../core/utils/type-guards";
|
||||
|
||||
function mapBet(bet: any): Bet {
|
||||
return {
|
||||
...bet,
|
||||
startDate: toDate(bet.startDate)!,
|
||||
endDate: toDate(bet.endDate)
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class BetService {
|
||||
private http = inject(HttpClient);
|
||||
private readonly base = `${environment.apiUrl}/api/v1/bets`;
|
||||
|
||||
getUserBets(): Observable<Bet[]> {
|
||||
return this.http.get<SuccessResponse<Bet[]>>(`${this.base}/user`).pipe(
|
||||
map(res => res.data.map(mapBet))
|
||||
);
|
||||
}
|
||||
|
||||
getPublicBets(): Observable<Bet[]> {
|
||||
return this.http.get<SuccessResponse<Bet[]>>(`${this.base}/public`).pipe(
|
||||
map(res => res.data.map(mapBet))
|
||||
);
|
||||
}
|
||||
|
||||
getAllBets(): Observable<Bet[]> {
|
||||
return this.http.get<SuccessResponse<Bet[]>>(this.base).pipe(
|
||||
map(res => res.data.map(mapBet))
|
||||
);
|
||||
}
|
||||
|
||||
getBetById(id: string): Observable<Bet> {
|
||||
return this.http.get<SuccessResponse<Bet>>(`${this.base}/${id}`).pipe(
|
||||
map(res => mapBet(res.data))
|
||||
);
|
||||
}
|
||||
|
||||
createBet(payload: CreateBetPayload): Observable<Bet> {
|
||||
return this.http.post<SuccessResponse<Bet[]>>(this.base, payload).pipe(
|
||||
map(res => mapBet(res.data))
|
||||
);
|
||||
}
|
||||
|
||||
updateBet(id: string, payload: UpdateBetPayload): Observable<Bet> {
|
||||
return this.http.put<SuccessResponse<Bet[]>>(`${this.base}/${id}`, payload).pipe(
|
||||
map(res => mapBet(res.data))
|
||||
);
|
||||
}
|
||||
|
||||
deleteBet(id: string): Observable<void> {
|
||||
return this.http.delete<void>(`${this.base}/${id}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { environment } from '../../environments/environment';
|
||||
import { FriendRequest, User } from '../models/data.models';
|
||||
import { SuccessResponse } from '../models/api-responses.model';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class FriendService {
|
||||
private http = inject(HttpClient);
|
||||
private readonly base = `${environment.apiUrl}/api/v1/friend-requests`;
|
||||
|
||||
getFriends(): Observable<User[]> {
|
||||
return this.http.get<SuccessResponse<User[]>>(this.base).pipe(map(res => res.data));
|
||||
}
|
||||
|
||||
getPendingRequests(): Observable<FriendRequest[]> {
|
||||
return this.http.get<SuccessResponse<FriendRequest[]>>(`${this.base}/pending`).pipe(map(res => res.data));
|
||||
}
|
||||
|
||||
sendRequest(identifier: string | number): Observable<FriendRequest> {
|
||||
return this.http.post<FriendRequest>(this.base, { identifier });
|
||||
}
|
||||
|
||||
acceptRequest(id: number): Observable<void> {
|
||||
return this.http.post<void>(`${this.base}/${id}/accept`, {});
|
||||
}
|
||||
|
||||
rejectRequest(id: number): Observable<void> {
|
||||
return this.http.delete<void>(`${this.base}/${id}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import {map, Observable} from 'rxjs';
|
||||
import { environment } from '../../environments/environment';
|
||||
import { Group } from '../models/data.models';
|
||||
import {SuccessResponse} from "../models/api-responses.model";
|
||||
|
||||
export interface CreateGroupPayload {
|
||||
name: string;
|
||||
members?: string[];
|
||||
}
|
||||
|
||||
export interface UpdateGroupPayload {
|
||||
name?: string;
|
||||
description?: string;
|
||||
isPrivate?: boolean;
|
||||
avatarUrl?: string;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class GroupService {
|
||||
private http = inject(HttpClient);
|
||||
private readonly base = `${environment.apiUrl}/api/v1/groups`;
|
||||
|
||||
getGroups(): Observable<Group[]> {
|
||||
return this.http.get<SuccessResponse<Group[]>>(this.base).pipe(
|
||||
map(res => res.data)
|
||||
);
|
||||
}
|
||||
|
||||
getGroupById(id: number): Observable<Group> {
|
||||
return this.http.get<SuccessResponse<Group>>(`${this.base}/${id}`).pipe(
|
||||
map(res => res.data)
|
||||
);
|
||||
}
|
||||
|
||||
createGroup(payload: CreateGroupPayload): Observable<Group> {
|
||||
return this.http.post<SuccessResponse<Group>>(this.base, payload).pipe(
|
||||
map(res => res.data)
|
||||
);
|
||||
}
|
||||
|
||||
updateGroup(id: number, payload: UpdateGroupPayload): Observable<Group> {
|
||||
return this.http.put<SuccessResponse<Group>>(`${this.base}/${id}`, payload).pipe(
|
||||
map(res => res.data)
|
||||
);
|
||||
}
|
||||
|
||||
deleteGroup(id: number): Observable<void> {
|
||||
return this.http.delete<void>(`${this.base}/${id}`);
|
||||
}
|
||||
|
||||
addMembers(groupId: number, userIds: number[]): Observable<Group> {
|
||||
return this.http.put<SuccessResponse<Group>>(`${this.base}/${groupId}/members`, { userIds }).pipe(
|
||||
map(res => res.data)
|
||||
);
|
||||
}
|
||||
|
||||
removeMember(groupId: number, userId: number): Observable<void> {
|
||||
return this.http.delete<void>(`${this.base}/${groupId}/members/${userId}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import {Injectable, signal, inject, PLATFORM_ID} from '@angular/core';
|
||||
import {isPlatformBrowser} from '@angular/common';
|
||||
import en from '../i18n/en.json';
|
||||
import it from '../i18n/it.json';
|
||||
|
||||
export type Lang = 'en' | 'it';
|
||||
|
||||
const TRANSLATIONS: Record<Lang, Record<string, string>> = {
|
||||
en,
|
||||
it,
|
||||
};
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class LanguageService {
|
||||
private platformId = inject(PLATFORM_ID);
|
||||
private isBrowser = isPlatformBrowser(this.platformId);
|
||||
private currentLang = signal<Lang>('en');
|
||||
|
||||
getLang() {
|
||||
return this.currentLang.asReadonly();
|
||||
}
|
||||
|
||||
setLang(lang: Lang) {
|
||||
this.currentLang.set(lang);
|
||||
if (this.isBrowser) {
|
||||
localStorage.setItem('app_lang', lang);
|
||||
}
|
||||
}
|
||||
|
||||
constructor() {
|
||||
if (this.isBrowser) {
|
||||
const savedLang = localStorage.getItem('app_lang') as Lang;
|
||||
if (savedLang && (savedLang === 'en' || savedLang === 'it')) {
|
||||
this.currentLang.set(savedLang);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
translate(key: string): string {
|
||||
const lang = this.currentLang();
|
||||
const translation = TRANSLATIONS[lang][key];
|
||||
return translation || key;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Injectable, signal, effect, inject, PLATFORM_ID } from '@angular/core';
|
||||
import { isPlatformBrowser } from '@angular/common';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ThemeService {
|
||||
private readonly THEME_KEY = 'scommetti-app-theme';
|
||||
private platformId = inject(PLATFORM_ID);
|
||||
private isBrowser = isPlatformBrowser(this.platformId);
|
||||
|
||||
isDarkMode = signal<boolean>(false);
|
||||
|
||||
constructor() {
|
||||
// Initialize theme state
|
||||
this.isDarkMode.set(this.getInitialTheme());
|
||||
|
||||
effect(() => {
|
||||
const dark = this.isDarkMode();
|
||||
if (this.isBrowser) {
|
||||
if (dark) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
localStorage.setItem(this.THEME_KEY, dark ? 'dark' : 'light');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
toggleDarkMode() {
|
||||
this.isDarkMode.update(v => !v);
|
||||
}
|
||||
|
||||
private getInitialTheme(): boolean {
|
||||
if (!this.isBrowser) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const saved = localStorage.getItem(this.THEME_KEY);
|
||||
if (saved) {
|
||||
return saved === 'dark';
|
||||
}
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import {map, Observable} from 'rxjs';
|
||||
import { environment } from '../../environments/environment';
|
||||
import {ActivityItem, User} from '../models/data.models';
|
||||
import {SuccessResponse} from "../models/api-responses.model";
|
||||
|
||||
export interface UpdateProfilePayload {
|
||||
name?: string;
|
||||
avatarUrl?: string;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class UserService {
|
||||
private http = inject(HttpClient);
|
||||
private readonly base = `${environment.apiUrl}/api/v1/users`;
|
||||
|
||||
getCurrentUser(): Observable<User> {
|
||||
return this.http.get<SuccessResponse<User>>(`${this.base}/current`).pipe(
|
||||
map(res => res.data)
|
||||
);
|
||||
}
|
||||
|
||||
getActivities(): Observable<ActivityItem[]> {
|
||||
return this.http.get<ActivityItem[]>(`${this.base}/current/activities`);
|
||||
}
|
||||
|
||||
updateProfile(payload: UpdateProfilePayload): Observable<User> {
|
||||
return this.http.put<SuccessResponse<User>>(`${this.base}/current`, payload).pipe(
|
||||
map(res => res.data)
|
||||
);
|
||||
}
|
||||
|
||||
getUserById(id: number): Observable<User> {
|
||||
return this.http.get<SuccessResponse<User>>(`${this.base}/${id}`).pipe(
|
||||
map(res => res.data)
|
||||
);
|
||||
}
|
||||
|
||||
searchUsers(query: string): Observable<User[]> {
|
||||
return this.http.get<SuccessResponse<User[]>>(`${this.base}/search`, { params: { q: query } }).pipe(
|
||||
map(res => 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',
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
export const environment = {
|
||||
production: false,
|
||||
apiUrl: 'http://localhost:3000',
|
||||
keycloakUrl: 'http://localhost:8080',
|
||||
keycloakRealm: 'scommettiapp',
|
||||
keycloakClientId: 'scommetti-frontend',
|
||||
};
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
declare const GEMINI_API_KEY: string;
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Google AI Studio Angular App</title>
|
||||
<base href="/" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico" />
|
||||
</head>
|
||||
<body>
|
||||
<app-root></app-root>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,11 @@
|
||||
import {
|
||||
BootstrapContext,
|
||||
bootstrapApplication,
|
||||
} from '@angular/platform-browser';
|
||||
import {App} from './app/components/app/app';
|
||||
import {config} from './app/app.config.server';
|
||||
|
||||
const bootstrap = (context: BootstrapContext) =>
|
||||
bootstrapApplication(App, config, context);
|
||||
|
||||
export default bootstrap;
|
||||
@@ -0,0 +1,35 @@
|
||||
import { bootstrapApplication } from '@angular/platform-browser';
|
||||
import { App } from './app/components/app/app';
|
||||
import { appConfig } from './app/app.config';
|
||||
import Keycloak from 'keycloak-js';
|
||||
import { environment } from './environments/environment';
|
||||
import { KEYCLOAK_INSTANCE } from './app/core/tokens/keycloak.token';
|
||||
|
||||
const keycloak = new Keycloak({
|
||||
url: environment.keycloakUrl,
|
||||
realm: environment.keycloakRealm,
|
||||
clientId: environment.keycloakClientId,
|
||||
});
|
||||
|
||||
keycloak.init({
|
||||
onLoad: 'login-required',
|
||||
checkLoginIframe: false,
|
||||
pkceMethod: 'S256',
|
||||
}).then(authenticated => {
|
||||
if (!authenticated) {
|
||||
keycloak.login();
|
||||
return;
|
||||
}
|
||||
|
||||
const extendedConfig = {
|
||||
...appConfig,
|
||||
providers: [
|
||||
...(appConfig.providers || []),
|
||||
{ provide: KEYCLOAK_INSTANCE, useValue: keycloak },
|
||||
]
|
||||
};
|
||||
|
||||
bootstrapApplication(App, extendedConfig).catch((err) => console.error(err));
|
||||
}).catch(err => {
|
||||
console.error('Keycloak initialization failed:', err);
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
AngularNodeAppEngine,
|
||||
createNodeRequestHandler,
|
||||
isMainModule,
|
||||
writeResponseToNodeResponse,
|
||||
} from '@angular/ssr/node';
|
||||
import express from 'express';
|
||||
import {join} from 'node:path';
|
||||
|
||||
const browserDistFolder = join(import.meta.dirname, '../browser');
|
||||
|
||||
const app = express();
|
||||
const angularApp = new AngularNodeAppEngine();
|
||||
|
||||
/**
|
||||
* Example Express Rest API endpoints can be defined here.
|
||||
* Uncomment and define endpoints as necessary.
|
||||
*
|
||||
* Example:
|
||||
* ```ts
|
||||
* app.get('/api/{*splat}', (req, res) => {
|
||||
* // Handle API request
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
|
||||
/**
|
||||
* Serve static files from /browser
|
||||
*/
|
||||
app.use(
|
||||
express.static(browserDistFolder, {
|
||||
maxAge: '1y',
|
||||
index: false,
|
||||
redirect: false,
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Handle all other requests by rendering the Angular application.
|
||||
*/
|
||||
app.use((req, res, next) => {
|
||||
angularApp
|
||||
.handle(req)
|
||||
.then((response) =>
|
||||
response ? writeResponseToNodeResponse(response, res) : next(),
|
||||
)
|
||||
.catch(next);
|
||||
});
|
||||
|
||||
/**
|
||||
* Start the server if this module is the main entry point, or it is ran via PM2.
|
||||
* The server listens on the port defined by the `PORT` environment variable, or defaults to 4000.
|
||||
*/
|
||||
if (isMainModule(import.meta.url) || process.env['pm_id']) {
|
||||
const port = process.env['PORT'] || 4000;
|
||||
app.listen(port, (error) => {
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
console.log(`Node Express server listening on http://localhost:${port}`);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Request handler used by the Angular CLI (for dev-server and during build) or Firebase Cloud Functions.
|
||||
*/
|
||||
export const reqHandler = createNodeRequestHandler(app);
|
||||
@@ -0,0 +1,89 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=Material+Icons&display=swap');
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--color-primary: #4c49c9;
|
||||
--color-primary-container: #9695ff;
|
||||
--color-secondary: #006a26;
|
||||
--color-secondary-container: #6ffb85;
|
||||
--color-tertiary: #8a2ab9;
|
||||
--color-tertiary-container: #dd94ff;
|
||||
--color-error: #b41340;
|
||||
--color-error-container: #f74b6d;
|
||||
--color-background: #faf4ff;
|
||||
--color-surface: #ffffff;
|
||||
--color-on-background: #32294f;
|
||||
--color-on-surface: #32294f;
|
||||
--color-on-surface-variant: #5f557f;
|
||||
--color-surface-container: #f3f0f7;
|
||||
--color-surface-container-low: #f8f6fb;
|
||||
--color-surface-container-high: #edeaf2;
|
||||
--color-surface-container-highest: #e7e4ed;
|
||||
--color-on-primary: #ffffff;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--color-primary: #6366F1;
|
||||
--color-primary-container: #4338CA;
|
||||
--color-secondary: #A855F7;
|
||||
--color-secondary-container: #7E22CE;
|
||||
--color-tertiary: #F472B6;
|
||||
--color-tertiary-container: #BE185D;
|
||||
--color-error: #EF4444;
|
||||
--color-error-container: #991B1B;
|
||||
--color-background: #0F172A;
|
||||
--color-surface: #1E293B;
|
||||
--color-on-background: #F8FAFC;
|
||||
--color-on-surface: #F8FAFC;
|
||||
--color-on-surface-variant: #94A3B8;
|
||||
--color-surface-container: #334155;
|
||||
--color-surface-container-low: #1E293B;
|
||||
--color-surface-container-high: #475569;
|
||||
--color-surface-container-highest: #64748B;
|
||||
--color-on-primary: #ffffff;
|
||||
}
|
||||
|
||||
@theme {
|
||||
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-headline: "Plus Jakarta Sans", sans-serif;
|
||||
|
||||
--color-primary: var(--color-primary);
|
||||
--color-primary-container: var(--color-primary-container);
|
||||
--color-secondary: var(--color-secondary);
|
||||
--color-secondary-container: var(--color-secondary-container);
|
||||
--color-tertiary: var(--color-tertiary);
|
||||
--color-tertiary-container: var(--color-tertiary-container);
|
||||
--color-error: var(--color-error);
|
||||
--color-error-container: var(--color-error-container);
|
||||
--color-background: var(--color-background);
|
||||
--color-surface: var(--color-surface);
|
||||
--color-on-background: var(--color-on-background);
|
||||
--color-on-surface: var(--color-on-surface);
|
||||
--color-on-surface-variant: var(--color-on-surface-variant);
|
||||
--color-surface-container: var(--color-surface-container);
|
||||
--color-surface-container-low: var(--color-surface-container-low);
|
||||
--color-surface-container-high: var(--color-surface-container-high);
|
||||
--color-surface-container-highest: var(--color-surface-container-highest);
|
||||
--color-on-primary: var(--color-on-primary);
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-on-background font-sans transition-colors duration-300;
|
||||
}
|
||||
|
||||
.font-headline {
|
||||
font-family: var(--font-headline);
|
||||
}
|
||||
|
||||
.soft-shadow {
|
||||
box-shadow: 0 12px 32px -8px rgba(50, 41, 79, 0.08);
|
||||
}
|
||||
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/* 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. */
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/app",
|
||||
"types": [
|
||||
"node"
|
||||
]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.spec.ts"]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/* 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. */
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/spec",
|
||||
"types": ["vitest/globals"]
|
||||
},
|
||||
"include": ["src/**/*.d.ts", "src/**/*.spec.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user