Aggiornamento grafica

This commit is contained in:
Lorenzo Sanesi
2026-04-04 01:40:43 +02:00
parent e2fe8b264f
commit 162e0de62a
83 changed files with 16246 additions and 157 deletions
+1
View File
@@ -8,3 +8,4 @@
/backend-node/.env
/frontend-angular/node_modules/
/.claude/
/.ai/mcp/mcp.json
+1 -2
View File
@@ -1,5 +1,4 @@
// eslint-disable-next-line import/no-import-module-exports
import { DataTypes } from 'sequelize';
const { DataTypes } = require('sequelize');
module.exports = sequelize => {
const { User, Group, FriendRequest, Friendship } = sequelize.models;
+13
View File
@@ -13,6 +13,19 @@ module.exports = sequelize => {
type: DataTypes.STRING,
allowNull: false,
},
description: {
type: DataTypes.TEXT,
allowNull: true,
},
isPrivate: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
avatarUrl: {
type: DataTypes.STRING,
allowNull: true,
},
},
{
timestamps: false,
@@ -10,10 +10,16 @@ const createGroupSchema = Joi.object({
'array.min': 'At least one member is required',
'array.base': 'Members must be an array',
}),
description: Joi.string().trim().max(500).optional(),
isPrivate: Joi.boolean().optional(),
avatarUrl: Joi.string().uri().optional(),
});
const updateGroupSchema = Joi.object({
name: Joi.string().trim().min(1).max(100).optional(),
description: Joi.string().trim().max(500).optional(),
isPrivate: Joi.boolean().optional(),
avatarUrl: Joi.string().uri().optional(),
});
const addMembersSchema = Joi.object({
+2 -2
View File
@@ -125,7 +125,7 @@ services:
# Frontend Angular
frontend:
build:
context: ./frontend-angular
context: ./frontend-angular-new
dockerfile: Dockerfile
container_name: scommetti-frontend
environment:
@@ -133,7 +133,7 @@ services:
ports:
- "4200:4200"
volumes:
- ./frontend-angular:/app
- ./frontend-angular-new:/app
- /app/node_modules
- /app/.angular
depends_on:
+9
View File
@@ -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"
+47
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
{
"plugins": {
"@tailwindcss/postcss": {}
}
}
+20
View File
@@ -0,0 +1,20 @@
# Frontend Angular Dockerfile
FROM node:20-alpine
# Set working directory
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm install
# Copy application code
COPY . .
# Expose port
EXPOSE 4200
# Start development server
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
+20
View File
@@ -0,0 +1,20 @@
<div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
</div>
# Run and deploy your AI Studio app
This contains everything you need to run your app locally.
View your app in AI Studio: https://ai.studio/apps/7d951078-743c-4db5-ac20-6a2c625411e7
## Run Locally
**Prerequisites:** Node.js
1. Install dependencies:
`npm install`
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
3. Run the app:
`npm run dev`
+94
View File
@@ -0,0 +1,94 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"cli": {
"packageManager": "npm",
"schematicCollections": [
"angular-eslint"
]
},
"newProjectRoot": "projects",
"projects": {
"app": {
"projectType": "application",
"schematics": {},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"define": {
"GEMINI_API_KEY": "'YOUR_GEMINI_API_KEY'"
},
"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": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular/build:dev-server",
"configurations": {
"production": {
"buildTarget": "app:build:production"
},
"development": {
"buildTarget": "app:build:development"
}
},
"defaultConfiguration": "development"
},
"test": {
"builder": "@angular/build:unit-test"
},
"lint": {
"builder": "@angular-eslint/builder:lint",
"options": {
"lintFilePatterns": [
"src/**/*.ts",
"src/**/*.html"
]
}
}
}
}
}
}
+44
View File
@@ -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: {},
}
]);
+5
View File
@@ -0,0 +1,5 @@
{
"name": "ScommettiApp",
"description": "A social betting and challenge platform to compete with friends and join communities.",
"requestFramePermissions": ["geolocation"]
}
File diff suppressed because it is too large Load Diff
+46
View File
@@ -0,0 +1,46 @@
{
"name": "amici-scommesse",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "ng serve",
"build": "ng build",
"preview": "ng serve --configuration=production"
},
"dependencies": {
"@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": {
"@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,32 @@
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';
@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);
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,82 @@
<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 -->
<div *ngIf="isLoading()" 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>
<div *ngIf="!isLoading()" class="space-y-4">
<div *ngFor="let challenge of historyChallenges()" 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>
<div *ngIf="historyChallenges().length === 0" 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,146 @@
<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">
<div class="flex items-center gap-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>
<h1 class="font-headline font-black tracking-tight text-xl text-primary">The Arena</h1>
</div>
<div class="flex items-center gap-3">
<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>
<mat-icon class="text-on-surface-variant cursor-pointer">search</mat-icon>
<div class="w-8 h-8 rounded-full bg-primary-container overflow-hidden">
<img src="https://picsum.photos/seed/user/100/100" class="w-full h-full object-cover" alt="User Avatar">
</div>
</div>
</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">Loading arena details...</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">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 || 'No description provided.' }}</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">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">Visibility</span>
<span class="text-sm font-headline font-bold">{{ data.isPublic ? 'Public' : 'Private' }}</span>
</div>
</div>
<!-- Participants -->
<section class="space-y-4">
<div class="flex items-center justify-between">
<h3 class="font-headline font-bold">Participants</h3>
<span class="text-sm font-medium text-primary">{{ data.participants.length }} Joined</span>
</div>
<div class="flex flex-wrap gap-3">
<div *ngFor="let p of data.participants" class="flex flex-col items-center gap-1">
<div class="relative">
<img [src]="'https://picsum.photos/seed/' + p.user + '/100/100'" class="h-12 w-12 rounded-full ring-2 ring-primary/20 object-cover" alt="Participant">
<div *ngIf="p.role === 'OBSERVER'" 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">User #{{ p.user }}</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">Challenge Not Found</h3>
<p class="text-on-surface-variant mt-2">The challenge you're looking for doesn't exist or has been removed.</p>
<button class="mt-6 text-primary font-bold" [routerLink]="['/']">Go Back Home</button>
</div>
}
<!-- Live Standings -->
<section class="bg-surface-container p-6 rounded-2xl space-y-6 border border-black/5">
<div class="flex items-center justify-between">
<h3 class="font-headline font-bold">Live Standings</h3>
<mat-icon class="text-primary">leaderboard</mat-icon>
</div>
<div class="space-y-5">
<div class="space-y-2">
<div class="flex justify-between items-end">
<span class="text-sm font-bold">You (Alex)</span>
<span class="text-xs font-medium text-on-surface-variant">16,402 / 20,000</span>
</div>
<div class="h-3 w-full bg-surface-container-highest rounded-full overflow-hidden">
<div class="h-full bg-gradient-to-r from-primary to-primary-container rounded-full" style="width: 82%"></div>
</div>
</div>
<div class="space-y-2">
<div class="flex justify-between items-end">
<span class="text-sm font-medium">Sarah J.</span>
<span class="text-xs font-medium text-on-surface-variant">18,210 / 20,000</span>
</div>
<div class="h-3 w-full bg-surface-container-highest rounded-full overflow-hidden">
<div class="h-full bg-tertiary rounded-full" style="width: 91%"></div>
</div>
</div>
</div>
</section>
<!-- Stats Bento -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="bg-surface-container-low p-6 rounded-2xl flex items-center gap-4 border border-black/5">
<div class="w-12 h-12 rounded-full bg-tertiary-container/20 flex items-center justify-center">
<mat-icon class="text-tertiary">local_fire_department</mat-icon>
</div>
<div>
<p class="text-xs font-medium text-on-surface-variant">Hot Streak</p>
<p class="text-lg font-headline font-bold">3 Wins</p>
</div>
</div>
<div class="bg-surface-container-low p-6 rounded-2xl flex items-center gap-4 border border-black/5">
<div class="w-12 h-12 rounded-full bg-secondary-container/20 flex items-center justify-center">
<mat-icon class="text-secondary">groups</mat-icon>
</div>
<div>
<p class="text-xs font-medium text-on-surface-variant">Activity Level</p>
<p class="text-lg font-headline font-bold">High</p>
</div>
</div>
</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 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">
Update Progress
</button>
<button 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>
Invite Friends
</button>
</div>
</div>
</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 {BetService} from '../../services/bet';
import {Bet} from '../../models/data.models';
@Component({
selector: 'app-challenge-detail',
standalone: true,
imports: [CommonModule, 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);
challengeId = this.route.snapshot.paramMap.get('id');
bet = signal<Bet | null>(null);
isLoading = signal(true);
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);
}
});
}
}
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,126 @@
<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>
<option *ngFor="let group of availableGroups()" [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 -->
<div *ngIf="searchResults().length > 0" class="absolute top-full left-0 right-0 mt-2 bg-surface shadow-xl rounded-2xl overflow-hidden z-10 border border-black/5">
<div *ngFor="let user of searchResults()"
(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">
<div *ngFor="let user of selectedParticipants()" 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>
<div *ngIf="selectedParticipants().length === 0" 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 || 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,116 @@
<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 -->
<div *ngIf="searchResults().length > 0" class="absolute top-full left-0 right-0 mt-2 bg-surface shadow-xl rounded-2xl overflow-hidden z-10 border border-black/5">
<div *ngFor="let user of searchResults()"
(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">
<div *ngFor="let member of selectedMembers()"
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,135 @@
<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>
<div *ngIf="isLoading()" 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>
<div *ngIf="!isLoading()" class="space-y-4">
<!-- Public Bet Cards -->
<div *ngFor="let bet of publicBets()"
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">
<img *ngFor="let p of bet.participants.slice(0, 3)" [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>
<div *ngIf="publicBets().length === 0" 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,136 @@
<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>
<span *ngIf="data.isPrivate" 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>
<div class="flex -space-x-3" *ngIf="data.members && data.members.length > 0">
<img *ngFor="let member of data.members.slice(0, 3)" [src]="member.avatarUrl || 'assets/default-avatar.png'" class="w-8 h-8 rounded-full border-2 border-surface object-cover" [alt]="member.name">
<div *ngIf="data.members.length > 3" 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">
<div *ngFor="let member of data.members" 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,77 @@
<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 -->
<div *ngIf="isLoading()" 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>
<div *ngIf="!isLoading()" class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div *ngFor="let group of groupsList()" 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>
<span *ngIf="group.isPrivate" 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">
<div class="flex -space-x-3" *ngIf="group.members && group.members.length > 0">
<img *ngFor="let member of group.members.slice(0, 3)" [src]="member.avatarUrl || 'assets/default-avatar.png'" class="w-8 h-8 rounded-full border-2 border-surface" [alt]="member.name">
<div *ngIf="group.members.length > 3" 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>
<div *ngIf="groupsList().length === 0" 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,120 @@
<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 items-center justify-between mt-4">
<div class="flex -space-x-3">
<div class="w-10 h-10 rounded-full border-2 border-primary bg-surface-container flex items-center justify-center text-xs text-primary font-bold">JD</div>
<div class="w-10 h-10 rounded-full border-2 border-primary overflow-hidden">
<img src="https://picsum.photos/seed/friend1/100/100" class="w-full h-full object-cover" alt="Friend Avatar">
</div>
<div class="w-10 h-10 rounded-full border-2 border-primary bg-primary-container flex items-center justify-center text-xs">+12</div>
</div>
<span class="bg-white/20 px-4 py-1.5 rounded-full text-sm font-semibold backdrop-blur-md">Top 5% Rank</span>
</div>
</div>
<div class="bg-secondary-container p-6 rounded-2xl soft-shadow flex flex-col justify-between">
<div>
<span class="text-secondary font-bold text-sm">{{ t('profile.balance') }}</span>
<div class="flex items-baseline gap-1 mt-2">
<span class="text-3xl font-headline font-black text-on-secondary-container">${{ userProfile().balance }}</span>
</div>
</div>
<div class="bg-white/40 p-3 rounded-2xl flex items-center gap-3">
<mat-icon class="text-secondary">trending_up</mat-icon>
<p class="text-xs font-semibold text-secondary">{{ t('profile.win_streak') }}: {{ userProfile().winStreak }}</p>
</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 class="flex items-center gap-3">
<div class="w-12 h-12 rounded-2xl bg-secondary/10 flex items-center justify-center">
<mat-icon class="text-secondary">
{{ challenge.goalType === 'steps' ? 'footprint' : challenge.goalType === 'tasks' ? 'task_alt' : 'trending_up' }}
</mat-icon>
</div>
<div>
<h4 class="font-bold">{{ challenge.title }}</h4>
<p class="text-xs text-on-surface-variant">{{ challenge.description }}</p>
</div>
</div>
<span class="px-3 py-1 rounded-full bg-secondary-container text-secondary text-[10px] font-black uppercase tracking-widest">
{{ challenge.status }}
</span>
</div>
<div class="space-y-4">
<div>
<div class="flex justify-between text-xs font-bold mb-1.5">
<span class="text-on-surface-variant">Target</span>
<span class="text-secondary">{{ challenge.target }} {{ challenge.goalType }}</span>
</div>
<div class="w-full h-3 bg-surface-container rounded-full overflow-hidden">
<div class="h-full bg-secondary rounded-full" [style.width.%]="challenge.status === 'completed' ? 100 : challenge.status === 'in-progress' ? 65 : 0"></div>
</div>
</div>
<div class="flex justify-between items-center pt-2">
<div class="text-sm font-bold">Prize Pool</div>
<div class="text-lg font-headline font-black text-primary">${{ challenge.prizePool }}</div>
</div>
</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,99 @@
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: '', balance: 0, winStreak: 0, totalWins: 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,
balance: 1250, // Mock balance
winStreak: 3, // Mock streak
totalWins: user.score
};
}
private mapBetToChallenge(bet: Bet): ChallengeData {
const statusMap: Record<string, 'in-progress' | 'completed' | 'pending'> = {
'ACTIVE': 'in-progress',
'AWAITING_VALIDATION': 'pending',
'COMPLETED': 'completed',
'EXPIRED': 'completed'
};
const prize = parseFloat(bet.stake.replace(/[^0-9.]/g, '')) || 0;
return {
id: bet._id,
title: bet.title,
description: bet.description ?? '',
target: 100, // Default target
deadline: bet.endDate ?? '',
goalType: 'tasks',
status: statusMap[bet.status] ?? 'pending',
prizePool: prize,
participantsCount: bet.participants.length,
createdAt: bet.startDate
};
}
}
@@ -0,0 +1,4 @@
/* Profile component styles */
:host {
display: block;
}
@@ -0,0 +1,149 @@
<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">
<img *ngIf="userProfile()?.avatarUrl" [src]="userProfile()?.avatarUrl" class="w-full h-full object-cover" alt="User Avatar">
<mat-icon *ngIf="!userProfile()?.avatarUrl" 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>
}
<!-- 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 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 class="text-primary text-sm font-bold">Vedi tutti</button>
</div>
<div class="flex gap-3 overflow-x-auto pb-2 no-scrollbar">
<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">
<img src="https://picsum.photos/seed/f1/100/100" class="w-full h-full rounded-full object-cover" alt="Friend Avatar">
</div>
<span class="text-[10px] font-medium text-on-surface-variant">Marco</span>
</div>
<div class="flex flex-col items-center gap-1 flex-none">
<div class="w-14 h-14 rounded-full border-2 border-transparent p-0.5">
<img src="https://picsum.photos/seed/f2/100/100" class="w-full h-full rounded-full object-cover" alt="Friend Avatar">
</div>
<span class="text-[10px] font-medium text-on-surface-variant">Luca</span>
</div>
<div 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>
</div>
</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>
@@ -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 {UserService} from '../../services/user';
import {LanguageService} from '../../services/language';
import {User} from '../../models/data.models';
@Component({
selector: 'app-profile',
standalone: true,
imports: [CommonModule, MatIconModule, RouterModule],
templateUrl: './profile.html',
styleUrl: './profile.css',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class Profile implements OnInit {
private userService = inject(UserService);
private langService = inject(LanguageService);
userProfile = signal<User | null>(null);
isLoading = signal(true);
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);
}
});
}
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,4 @@
import { InjectionToken } from '@angular/core';
import Keycloak from 'keycloak-js';
export const KEYCLOAK_INSTANCE = new InjectionToken<Keycloak>('KeycloakInstance');
@@ -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;
}
+47
View File
@@ -0,0 +1,47 @@
{
"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",
"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"
}
+47
View File
@@ -0,0 +1,47 @@
{
"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",
"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"
}
@@ -0,0 +1,18 @@
export interface SuccessResponse<T = any> {
success: boolean;
message?: string;
data: T;
timestamp: string;
}
export interface ErrorDetail {
field?: string;
message: string;
}
export interface ErrorResponse {
success: boolean;
message: string;
errors?: ErrorDetail[];
timestamp: string;
}
@@ -0,0 +1,121 @@
// ─── Display Models (UI mock, mantenuti per compatibilità con i componenti esistenti) ───
export interface ChallengeData {
id: string;
title: string;
description: string;
target: number;
deadline: string;
goalType: 'steps' | 'tasks' | 'predictions';
status: 'in-progress' | 'completed' | 'pending';
prizePool: number;
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;
balance: number;
winStreak: number;
totalWins: number;
}
// ─── Modelli Backend (usati nei servizi HTTP) ────────────────────────────────
export type BetStatus = 'ACTIVE' | 'AWAITING_VALIDATION' | 'COMPLETED' | 'EXPIRED';
export type ParticipantRole = 'PARTICIPANT' | 'OBSERVER';
export interface BetParticipant {
user: string; // PostgreSQL User ID
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;
}
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;
}
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,33 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
import { FriendRequest } from '../models/data.models';
@Injectable({
providedIn: 'root',
})
export class FriendService {
private http = inject(HttpClient);
private readonly base = `${environment.apiUrl}/api/v1/friend-requests`;
getFriends(): Observable<FriendRequest[]> {
return this.http.get<FriendRequest[]>(this.base);
}
getPendingRequests(): Observable<FriendRequest[]> {
return this.http.get<FriendRequest[]>(`${this.base}/pending`);
}
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,7 @@
export const environment = {
production: true,
apiUrl: '', // da compilare in produzione
keycloakUrl: '',
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',
};
+1
View File
@@ -0,0 +1 @@
declare const GEMINI_API_KEY: string;
+13
View File
@@ -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>
+11
View File
@@ -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;
+35
View File
@@ -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);
});
+68
View File
@@ -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);
+89
View File
@@ -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;
}
+13
View File
@@ -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"]
}
+35
View File
@@ -0,0 +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": {
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"experimentalDecorators": true,
"importHelpers": true,
"target": "ES2022",
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
},
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}
+10
View File
@@ -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"]
}
+278 -57
View File
@@ -4,14 +4,107 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ScommettiApp - Accedi</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
/* ── Tema chiaro (default) ───────────────────────────── */
:root {
--bg: #faf4ff;
--surface: #ffffff;
--border: #e7e4ed;
--input-bg: #f8f6fb;
--primary: #4c49c9;
--primary-hover: #3d3ab0;
--text: #32294f;
--text-secondary: #5f557f;
--text-placeholder:#9e99bc;
--autofill-bg: #f8f6fb;
--autofill-text: #32294f;
--error: #b41340;
--error-bg: rgba(180,19,64,0.08);
--error-border: rgba(180,19,64,0.3);
--success-bg: rgba(0,106,38,0.08);
--warning-bg: rgba(138,42,185,0.08);
--focus-ring: rgba(76,73,201,0.2);
--btn-text: #ffffff;
}
/* ── Tema scuro (preferenza OS) ─────────────────────── */
@media (prefers-color-scheme: dark) {
:root {
--bg: #0F172A;
--surface: #1E293B;
--border: #334155;
--input-bg: #0f172a;
--primary: #6366F1;
--primary-hover: #818CF8;
--text: #F8FAFC;
--text-secondary: #94A3B8;
--text-placeholder:#475569;
--autofill-bg: #0f172a;
--autofill-text: #f1f5f9;
--error: #EF4444;
--error-bg: rgba(239,68,68,0.1);
--error-border: rgba(239,68,68,0.3);
--success-bg: rgba(74,222,128,0.1);
--warning-bg: rgba(251,191,36,0.1);
--focus-ring: rgba(99,102,241,0.2);
--btn-text: #ffffff;
}
}
/* ── Override esplicito via classe ─────────────────── */
html.dark {
--bg: #0F172A;
--surface: #1E293B;
--border: #334155;
--input-bg: #0f172a;
--primary: #6366F1;
--primary-hover: #818CF8;
--text: #F8FAFC;
--text-secondary: #94A3B8;
--text-placeholder:#475569;
--autofill-bg: #0f172a;
--autofill-text: #f1f5f9;
--error: #EF4444;
--error-bg: rgba(239,68,68,0.1);
--error-border: rgba(239,68,68,0.3);
--success-bg: rgba(74,222,128,0.1);
--warning-bg: rgba(251,191,36,0.1);
--focus-ring: rgba(99,102,241,0.2);
--btn-text: #ffffff;
}
html.light {
--bg: #faf4ff;
--surface: #ffffff;
--border: #e7e4ed;
--input-bg: #f8f6fb;
--primary: #4c49c9;
--primary-hover: #3d3ab0;
--text: #32294f;
--text-secondary: #5f557f;
--text-placeholder:#9e99bc;
--autofill-bg: #f8f6fb;
--autofill-text: #32294f;
--error: #b41340;
--error-bg: rgba(180,19,64,0.08);
--error-border: rgba(180,19,64,0.3);
--success-bg: rgba(0,106,38,0.08);
--warning-bg: rgba(138,42,185,0.08);
--focus-ring: rgba(76,73,201,0.2);
--btn-text: #ffffff;
}
body {
font-family: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, sans-serif;
background-color: var(--bg);
color: var(--text);
}
input:-webkit-autofill,
input:-webkit-autofill:hover,
input:-webkit-autofill:focus {
-webkit-text-fill-color: #f1f5f9;
-webkit-box-shadow: 0 0 0px 1000px #0f172a inset;
-webkit-text-fill-color: var(--autofill-text);
-webkit-box-shadow: 0 0 0px 1000px var(--autofill-bg) inset;
transition: background-color 5000s ease-in-out 0s;
}
.fade-in {
@@ -21,40 +114,156 @@
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.theme-toggle {
position: fixed;
top: 1rem;
right: 1rem;
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text-secondary);
transition: background 0.2s, color 0.2s;
}
.theme-toggle:hover { color: var(--primary); }
.sc-input {
width: 100%;
background: var(--input-bg);
border: 1px solid var(--border);
border-radius: 0.75rem;
padding: 0.75rem 1rem;
color: var(--text);
font-size: 0.875rem;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
}
.sc-input::placeholder { color: var(--text-placeholder); }
.sc-input:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px var(--focus-ring);
}
.sc-input-pr { padding-right: 3rem; }
.sc-label {
display: block;
color: var(--text-secondary);
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 0.5rem;
}
.sc-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 1rem;
padding: 2rem;
box-shadow: 0 20px 40px rgba(0,0,0,0.08);
}
.sc-btn-primary {
width: 100%;
background: var(--primary);
color: var(--btn-text);
font-weight: 600;
padding: 0.75rem 1rem;
border-radius: 0.75rem;
font-size: 0.875rem;
transition: background 0.2s;
border: none;
cursor: pointer;
}
.sc-btn-primary:hover { background: var(--primary-hover); }
.sc-btn-primary:active { opacity: 0.9; }
.sc-link { color: var(--primary); font-weight: 500; text-decoration: none; }
.sc-link:hover { text-decoration: underline; }
.sc-icon-btn {
position: absolute;
right: 0.75rem;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
cursor: pointer;
color: var(--text-placeholder);
padding: 0.25rem;
display: flex;
align-items: center;
}
.sc-icon-btn:hover { color: var(--text-secondary); }
.sc-msg {
border-radius: 0.75rem;
padding: 0.75rem 1rem;
margin-bottom: 1.5rem;
font-size: 0.875rem;
}
.sc-msg-error { background: var(--error-bg); border: 1px solid var(--error-border); color: var(--error); }
.sc-msg-success { background: var(--success-bg); border: 1px solid var(--border); color: var(--text); }
.sc-msg-warning { background: var(--warning-bg); border: 1px solid var(--border); color: var(--text); }
.sc-msg-info { background: var(--surface); border: 1px solid var(--border); color: var(--text); }
.sc-checkbox {
width: 1rem;
height: 1rem;
border-radius: 0.25rem;
border: 1px solid var(--border);
background: var(--input-bg);
accent-color: var(--primary);
cursor: pointer;
}
</style>
</head>
<body class="bg-[#0f172a] min-h-screen flex items-center justify-center p-4">
<body class="min-h-screen flex items-center justify-center p-4">
<!-- Toggle tema -->
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Cambia tema" title="Cambia tema">
<span id="theme-icon-sun" style="display:none">
<svg width="18" height="18" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="5" stroke-width="2"/>
<path stroke-width="2" stroke-linecap="round"
d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42
M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
</svg>
</span>
<span id="theme-icon-moon">
<svg width="18" height="18" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-width="2" stroke-linecap="round"
d="M21 12.79A9 9 0 1111.21 3a7 7 0 009.79 9.79z"/>
</svg>
</span>
</button>
<div class="w-full max-w-md fade-in">
<!-- Logo / Header -->
<div class="text-center mb-8">
<div class="inline-flex items-center justify-center w-16 h-16 bg-cyan-500 rounded-2xl mb-4 shadow-lg shadow-cyan-500/20">
<svg class="w-9 h-9 text-[#0f172a]" viewBox="0 0 24 24" fill="currentColor">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-2xl mb-4 shadow-lg" style="background:var(--primary)">
<svg class="w-9 h-9" style="color:var(--btn-text)" viewBox="0 0 24 24" fill="currentColor">
<rect x="2" y="2" width="9" height="9" rx="2"/>
<rect x="13" y="2" width="9" height="9" rx="2"/>
<rect x="2" y="13" width="9" height="9" rx="2"/>
<rect x="13" y="13" width="9" height="9" rx="2"/>
<circle cx="6.5" cy="6.5" r="1.2" fill="#06b6d4"/>
<circle cx="17.5" cy="6.5" r="1.2" fill="#06b6d4"/>
<circle cx="6.5" cy="17.5" r="1.2" fill="#06b6d4"/>
<circle cx="17.5" cy="17.5" r="1.2" fill="#06b6d4"/>
<circle cx="6.5" cy="6.5" r="1.2" fill="var(--bg)"/>
<circle cx="17.5" cy="6.5" r="1.2" fill="var(--bg)"/>
<circle cx="6.5" cy="17.5" r="1.2" fill="var(--bg)"/>
<circle cx="17.5" cy="17.5" r="1.2" fill="var(--bg)"/>
</svg>
</div>
<h1 class="text-2xl font-bold text-white tracking-tight">ScommettiApp</h1>
<p class="text-[#94a3b8] text-sm mt-1">Sfida i tuoi amici</p>
<h1 class="text-2xl font-bold tracking-tight" style="color:var(--text)">ScommettiApp</h1>
<p class="text-sm mt-1" style="color:var(--text-secondary)">Sfida i tuoi amici</p>
</div>
<!-- Card -->
<div class="bg-[#1e293b] border border-[#334155] rounded-2xl p-8 shadow-xl">
<h2 class="text-lg font-semibold text-white mb-6">Accedi al tuo account</h2>
<div class="sc-card">
<h2 class="text-lg font-semibold mb-6" style="color:var(--text)">Accedi al tuo account</h2>
<!-- Messaggio globale (errore / info) -->
<#if message?has_content>
<div class="rounded-xl p-4 mb-6 text-sm
<#if message.type == 'error'>bg-red-500/10 border border-red-500/30 text-red-400
<#elseif message.type == 'success'>bg-green-500/10 border border-green-500/30 text-green-400
<#elseif message.type == 'warning'>bg-yellow-500/10 border border-yellow-500/30 text-yellow-400
<#else>bg-blue-500/10 border border-blue-500/30 text-blue-400</#if>">
<div class="sc-msg
<#if message.type == 'error'>sc-msg-error
<#elseif message.type == 'success'>sc-msg-success
<#elseif message.type == 'warning'>sc-msg-warning
<#else>sc-msg-info</#if>">
${message.summary?no_esc}
</div>
</#if>
@@ -63,7 +272,7 @@
<!-- Campo username / email -->
<div class="mb-5">
<label for="username" class="block text-[#cbd5e1] text-sm font-medium mb-2">
<label for="username" class="sc-label">
<#if realm.loginWithEmailAllowed && !realm.registrationEmailAsUsername>
Email o Username
<#elseif realm.loginWithEmailAllowed>
@@ -77,7 +286,7 @@
id="username"
name="username"
value="${(login.username!'')}"
class="w-full bg-[#0f172a] border border-[#334155] rounded-xl px-4 py-3 text-[#f1f5f9] placeholder-[#475569] focus:outline-none focus:border-cyan-500 transition-colors text-sm"
class="sc-input"
placeholder="<#if realm.loginWithEmailAllowed>nome@esempio.it<#else>il tuo username</#if>"
autocomplete="username"
autofocus
@@ -86,15 +295,13 @@
<!-- Campo password -->
<div class="mb-5">
<label for="password" class="block text-[#cbd5e1] text-sm font-medium mb-2">
Password
</label>
<label for="password" class="sc-label">Password</label>
<div class="relative">
<input
type="password"
id="password"
name="password"
class="w-full bg-[#0f172a] border border-[#334155] rounded-xl px-4 py-3 pr-12 text-[#f1f5f9] placeholder-[#475569] focus:outline-none focus:border-cyan-500 transition-colors text-sm"
class="sc-input sc-input-pr"
placeholder="La tua password"
autocomplete="current-password"
/>
@@ -102,7 +309,7 @@
type="button"
onclick="togglePassword('password', this)"
tabindex="-1"
class="absolute right-3 top-1/2 -translate-y-1/2 text-[#475569] hover:text-[#94a3b8] transition-colors p-1"
class="sc-icon-btn"
>
<svg id="eye-icon-password" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
@@ -123,65 +330,79 @@
id="rememberMe"
name="rememberMe"
<#if login.rememberMe??>checked</#if>
class="w-4 h-4 rounded border-[#334155] bg-[#0f172a] accent-cyan-500 cursor-pointer"
class="sc-checkbox"
/>
<span class="text-[#94a3b8] text-sm">Ricordami</span>
<span class="text-sm" style="color:var(--text-secondary)">Ricordami</span>
</label>
<#else>
<span></span>
</#if>
<#if realm.resetPasswordAllowed>
<a href="${url.loginResetCredentialsUrl}"
class="text-cyan-400 text-sm hover:text-cyan-300 transition-colors">
<a href="${url.loginResetCredentialsUrl}" class="sc-link text-sm">
Password dimenticata?
</a>
</#if>
</div>
<!-- Bottone accedi -->
<button
type="submit"
class="w-full bg-cyan-500 hover:bg-cyan-400 active:bg-cyan-600 text-[#0f172a] font-semibold py-3 rounded-xl transition-colors text-sm"
>
Accedi
</button>
<button type="submit" class="sc-btn-primary">Accedi</button>
</form>
<!-- Link registrazione -->
<#if realm.registrationAllowed>
<p class="text-center text-[#94a3b8] mt-6 text-sm">
<p class="text-center mt-6 text-sm" style="color:var(--text-secondary)">
Non hai un account?
<a href="${url.registrationUrl}"
class="text-cyan-400 hover:text-cyan-300 transition-colors font-medium ml-1">
Registrati
</a>
<a href="${url.registrationUrl}" class="sc-link ml-1">Registrati</a>
</p>
</#if>
</div>
</div>
<script>
// ── Inizializzazione tema ───────────────────────────────────
(function () {
var saved = localStorage.getItem('kc-theme');
var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
var isDark = saved ? saved === 'dark' : prefersDark;
document.documentElement.classList.add(isDark ? 'dark' : 'light');
updateToggleIcon(isDark);
})();
function updateToggleIcon(isDark) {
document.getElementById('theme-icon-sun').style.display = isDark ? 'inline' : 'none';
document.getElementById('theme-icon-moon').style.display = isDark ? 'none' : 'inline';
}
function toggleTheme() {
var html = document.documentElement;
var isDark = html.classList.contains('dark');
html.classList.replace(isDark ? 'dark' : 'light', isDark ? 'light' : 'dark');
localStorage.setItem('kc-theme', isDark ? 'light' : 'dark');
updateToggleIcon(!isDark);
}
// ── Toggle visibilità password ──────────────────────────────
function togglePassword(inputId, btn) {
const input = document.getElementById(inputId);
const isHidden = input.type === 'password';
var input = document.getElementById(inputId);
var isHidden = input.type === 'password';
input.type = isHidden ? 'text' : 'password';
const svg = btn.querySelector('svg');
var svg = btn.querySelector('svg');
if (isHidden) {
svg.innerHTML = `
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7
a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243
M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29
M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7
a10.025 10.025 0 01-4.132 5.411m0 0L21 21"/>`;
svg.innerHTML =
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"' +
' d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7' +
' a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243' +
' M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29' +
' M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7' +
' a10.025 10.025 0 01-4.132 5.411m0 0L21 21"/>';
} else {
svg.innerHTML = `
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7
-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>`;
svg.innerHTML =
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"' +
' d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>' +
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"' +
' d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7' +
' -1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>';
}
}
</script>
+280 -96
View File
@@ -4,14 +4,105 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ScommettiApp - Registrati</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
/* ── Tema chiaro (default) ───────────────────────────── */
:root {
--bg: #faf4ff;
--surface: #ffffff;
--border: #e7e4ed;
--input-bg: #f8f6fb;
--primary: #4c49c9;
--primary-hover: #3d3ab0;
--text: #32294f;
--text-secondary: #5f557f;
--text-placeholder:#9e99bc;
--autofill-bg: #f8f6fb;
--autofill-text: #32294f;
--error: #b41340;
--error-bg: rgba(180,19,64,0.08);
--error-border: rgba(180,19,64,0.3);
--success-bg: rgba(0,106,38,0.08);
--warning-bg: rgba(138,42,185,0.08);
--focus-ring: rgba(76,73,201,0.2);
--btn-text: #ffffff;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0F172A;
--surface: #1E293B;
--border: #334155;
--input-bg: #0f172a;
--primary: #6366F1;
--primary-hover: #818CF8;
--text: #F8FAFC;
--text-secondary: #94A3B8;
--text-placeholder:#475569;
--autofill-bg: #0f172a;
--autofill-text: #f1f5f9;
--error: #EF4444;
--error-bg: rgba(239,68,68,0.1);
--error-border: rgba(239,68,68,0.3);
--success-bg: rgba(74,222,128,0.1);
--warning-bg: rgba(251,191,36,0.1);
--focus-ring: rgba(99,102,241,0.2);
--btn-text: #ffffff;
}
}
html.dark {
--bg: #0F172A;
--surface: #1E293B;
--border: #334155;
--input-bg: #0f172a;
--primary: #6366F1;
--primary-hover: #818CF8;
--text: #F8FAFC;
--text-secondary: #94A3B8;
--text-placeholder:#475569;
--autofill-bg: #0f172a;
--autofill-text: #f1f5f9;
--error: #EF4444;
--error-bg: rgba(239,68,68,0.1);
--error-border: rgba(239,68,68,0.3);
--success-bg: rgba(74,222,128,0.1);
--warning-bg: rgba(251,191,36,0.1);
--focus-ring: rgba(99,102,241,0.2);
--btn-text: #ffffff;
}
html.light {
--bg: #faf4ff;
--surface: #ffffff;
--border: #e7e4ed;
--input-bg: #f8f6fb;
--primary: #4c49c9;
--primary-hover: #3d3ab0;
--text: #32294f;
--text-secondary: #5f557f;
--text-placeholder:#9e99bc;
--autofill-bg: #f8f6fb;
--autofill-text: #32294f;
--error: #b41340;
--error-bg: rgba(180,19,64,0.08);
--error-border: rgba(180,19,64,0.3);
--success-bg: rgba(0,106,38,0.08);
--warning-bg: rgba(138,42,185,0.08);
--focus-ring: rgba(76,73,201,0.2);
--btn-text: #ffffff;
}
body {
font-family: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, sans-serif;
background-color: var(--bg);
color: var(--text);
}
input:-webkit-autofill,
input:-webkit-autofill:hover,
input:-webkit-autofill:focus {
-webkit-text-fill-color: #f1f5f9;
-webkit-box-shadow: 0 0 0px 1000px #0f172a inset;
-webkit-text-fill-color: var(--autofill-text);
-webkit-box-shadow: 0 0 0px 1000px var(--autofill-bg) inset;
transition: background-color 5000s ease-in-out 0s;
}
.fade-in {
@@ -21,43 +112,149 @@
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.field-error {
border-color: #f87171 !important;
.theme-toggle {
position: fixed;
top: 1rem;
right: 1rem;
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text-secondary);
transition: background 0.2s, color 0.2s;
}
.theme-toggle:hover { color: var(--primary); }
.sc-input {
width: 100%;
background: var(--input-bg);
border: 1px solid var(--border);
border-radius: 0.75rem;
padding: 0.75rem 1rem;
color: var(--text);
font-size: 0.875rem;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
}
.sc-input::placeholder { color: var(--text-placeholder); }
.sc-input:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px var(--focus-ring);
}
.sc-input-error { border-color: var(--error) !important; }
.sc-input-pr { padding-right: 3rem; }
.sc-label {
display: block;
color: var(--text-secondary);
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 0.5rem;
}
.sc-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 1rem;
padding: 2rem;
box-shadow: 0 20px 40px rgba(0,0,0,0.08);
}
.sc-btn-primary {
width: 100%;
background: var(--primary);
color: var(--btn-text);
font-weight: 600;
padding: 0.75rem 1rem;
border-radius: 0.75rem;
font-size: 0.875rem;
transition: background 0.2s;
border: none;
cursor: pointer;
}
.sc-btn-primary:hover { background: var(--primary-hover); }
.sc-btn-primary:active { opacity: 0.9; }
.sc-link { color: var(--primary); font-weight: 500; text-decoration: none; }
.sc-link:hover { text-decoration: underline; }
.sc-icon-btn {
position: absolute;
right: 0.75rem;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
cursor: pointer;
color: var(--text-placeholder);
padding: 0.25rem;
display: flex;
align-items: center;
}
.sc-icon-btn:hover { color: var(--text-secondary); }
.sc-msg {
border-radius: 0.75rem;
padding: 0.75rem 1rem;
margin-bottom: 1.5rem;
font-size: 0.875rem;
}
.sc-msg-error { background: var(--error-bg); border: 1px solid var(--error-border); color: var(--error); }
.sc-msg-success { background: var(--success-bg); border: 1px solid var(--border); color: var(--text); }
.sc-msg-warning { background: var(--warning-bg); border: 1px solid var(--border); color: var(--text); }
.sc-msg-info { background: var(--surface); border: 1px solid var(--border); color: var(--text); }
.sc-field-error { color: var(--error); font-size: 0.75rem; margin-top: 0.25rem; }
</style>
</head>
<body class="bg-[#0f172a] min-h-screen flex items-center justify-center p-4 py-10">
<body class="min-h-screen flex items-center justify-center p-4 py-10">
<!-- Toggle tema -->
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Cambia tema" title="Cambia tema">
<span id="theme-icon-sun" style="display:none">
<svg width="18" height="18" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="5" stroke-width="2"/>
<path stroke-width="2" stroke-linecap="round"
d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42
M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
</svg>
</span>
<span id="theme-icon-moon">
<svg width="18" height="18" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-width="2" stroke-linecap="round"
d="M21 12.79A9 9 0 1111.21 3a7 7 0 009.79 9.79z"/>
</svg>
</span>
</button>
<div class="w-full max-w-md fade-in">
<!-- Logo / Header -->
<div class="text-center mb-8">
<div class="inline-flex items-center justify-center w-16 h-16 bg-cyan-500 rounded-2xl mb-4 shadow-lg shadow-cyan-500/20">
<svg class="w-9 h-9 text-[#0f172a]" viewBox="0 0 24 24" fill="currentColor">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-2xl mb-4 shadow-lg" style="background:var(--primary)">
<svg class="w-9 h-9" style="color:var(--btn-text)" viewBox="0 0 24 24" fill="currentColor">
<rect x="2" y="2" width="9" height="9" rx="2"/>
<rect x="13" y="2" width="9" height="9" rx="2"/>
<rect x="2" y="13" width="9" height="9" rx="2"/>
<rect x="13" y="13" width="9" height="9" rx="2"/>
<circle cx="6.5" cy="6.5" r="1.2" fill="#06b6d4"/>
<circle cx="17.5" cy="6.5" r="1.2" fill="#06b6d4"/>
<circle cx="6.5" cy="17.5" r="1.2" fill="#06b6d4"/>
<circle cx="17.5" cy="17.5" r="1.2" fill="#06b6d4"/>
<circle cx="6.5" cy="6.5" r="1.2" fill="var(--bg)"/>
<circle cx="17.5" cy="6.5" r="1.2" fill="var(--bg)"/>
<circle cx="6.5" cy="17.5" r="1.2" fill="var(--bg)"/>
<circle cx="17.5" cy="17.5" r="1.2" fill="var(--bg)"/>
</svg>
</div>
<h1 class="text-2xl font-bold text-white tracking-tight">ScommettiApp</h1>
<p class="text-[#94a3b8] text-sm mt-1">Crea il tuo account</p>
<h1 class="text-2xl font-bold tracking-tight" style="color:var(--text)">ScommettiApp</h1>
<p class="text-sm mt-1" style="color:var(--text-secondary)">Crea il tuo account</p>
</div>
<!-- Card -->
<div class="bg-[#1e293b] border border-[#334155] rounded-2xl p-8 shadow-xl">
<h2 class="text-lg font-semibold text-white mb-6">Registrazione</h2>
<div class="sc-card">
<h2 class="text-lg font-semibold mb-6" style="color:var(--text)">Registrazione</h2>
<!-- Messaggio globale -->
<#if message?has_content>
<div class="rounded-xl p-4 mb-6 text-sm
<#if message.type == 'error'>bg-red-500/10 border border-red-500/30 text-red-400
<#elseif message.type == 'success'>bg-green-500/10 border border-green-500/30 text-green-400
<#elseif message.type == 'warning'>bg-yellow-500/10 border border-yellow-500/30 text-yellow-400
<#else>bg-blue-500/10 border border-blue-500/30 text-blue-400</#if>">
<div class="sc-msg
<#if message.type == 'error'>sc-msg-error
<#elseif message.type == 'success'>sc-msg-success
<#elseif message.type == 'warning'>sc-msg-warning
<#else>sc-msg-info</#if>">
${message.summary?no_esc}
</div>
</#if>
@@ -67,106 +264,86 @@
<!-- Nome e Cognome affiancati -->
<div class="grid grid-cols-2 gap-4 mb-5">
<div>
<label for="firstName" class="block text-[#cbd5e1] text-sm font-medium mb-2">
Nome
</label>
<label for="firstName" class="sc-label">Nome</label>
<input
type="text"
id="firstName"
name="firstName"
value="${(register.firstName!'')}"
class="w-full bg-[#0f172a] border rounded-xl px-4 py-3 text-[#f1f5f9] placeholder-[#475569] focus:outline-none focus:border-cyan-500 transition-colors text-sm
<#if messagesPerField.existsError('firstName')>field-error border-red-400<#else>border-[#334155]</#if>"
class="sc-input <#if messagesPerField.existsError('firstName')>sc-input-error</#if>"
placeholder="Mario"
autocomplete="given-name"
/>
<#if messagesPerField.existsError('firstName')>
<p class="text-red-400 text-xs mt-1">${messagesPerField.get('firstName')?no_esc}</p>
<p class="sc-field-error">${messagesPerField.get('firstName')?no_esc}</p>
</#if>
</div>
<div>
<label for="lastName" class="block text-[#cbd5e1] text-sm font-medium mb-2">
Cognome
</label>
<label for="lastName" class="sc-label">Cognome</label>
<input
type="text"
id="lastName"
name="lastName"
value="${(register.lastName!'')}"
class="w-full bg-[#0f172a] border rounded-xl px-4 py-3 text-[#f1f5f9] placeholder-[#475569] focus:outline-none focus:border-cyan-500 transition-colors text-sm
<#if messagesPerField.existsError('lastName')>field-error border-red-400<#else>border-[#334155]</#if>"
class="sc-input <#if messagesPerField.existsError('lastName')>sc-input-error</#if>"
placeholder="Rossi"
autocomplete="family-name"
/>
<#if messagesPerField.existsError('lastName')>
<p class="text-red-400 text-xs mt-1">${messagesPerField.get('lastName')?no_esc}</p>
<p class="sc-field-error">${messagesPerField.get('lastName')?no_esc}</p>
</#if>
</div>
</div>
<!-- Email -->
<div class="mb-5">
<label for="email" class="block text-[#cbd5e1] text-sm font-medium mb-2">
Email
</label>
<label for="email" class="sc-label">Email</label>
<input
type="email"
id="email"
name="email"
value="${(register.email!'')}"
class="w-full bg-[#0f172a] border rounded-xl px-4 py-3 text-[#f1f5f9] placeholder-[#475569] focus:outline-none focus:border-cyan-500 transition-colors text-sm
<#if messagesPerField.existsError('email')>field-error border-red-400<#else>border-[#334155]</#if>"
class="sc-input <#if messagesPerField.existsError('email')>sc-input-error</#if>"
placeholder="nome@esempio.it"
autocomplete="email"
/>
<#if messagesPerField.existsError('email')>
<p class="text-red-400 text-xs mt-1">${messagesPerField.get('email')?no_esc}</p>
<p class="sc-field-error">${messagesPerField.get('email')?no_esc}</p>
</#if>
</div>
<!-- Username (solo se non usa email come username) -->
<#if !realm.registrationEmailAsUsername>
<div class="mb-5">
<label for="username" class="block text-[#cbd5e1] text-sm font-medium mb-2">
Username
</label>
<label for="username" class="sc-label">Username</label>
<input
type="text"
id="username"
name="username"
value="${(register.username!'')}"
class="w-full bg-[#0f172a] border rounded-xl px-4 py-3 text-[#f1f5f9] placeholder-[#475569] focus:outline-none focus:border-cyan-500 transition-colors text-sm
<#if messagesPerField.existsError('username')>field-error border-red-400<#else>border-[#334155]</#if>"
class="sc-input <#if messagesPerField.existsError('username')>sc-input-error</#if>"
placeholder="il tuo username"
autocomplete="username"
/>
<#if messagesPerField.existsError('username')>
<p class="text-red-400 text-xs mt-1">${messagesPerField.get('username')?no_esc}</p>
<p class="sc-field-error">${messagesPerField.get('username')?no_esc}</p>
</#if>
</div>
</#if>
<!-- Password -->
<div class="mb-5">
<label for="password" class="block text-[#cbd5e1] text-sm font-medium mb-2">
Password
</label>
<label for="password" class="sc-label">Password</label>
<div class="relative">
<input
type="password"
id="password"
name="password"
class="w-full bg-[#0f172a] border rounded-xl px-4 py-3 pr-12 text-[#f1f5f9] placeholder-[#475569] focus:outline-none focus:border-cyan-500 transition-colors text-sm
<#if messagesPerField.existsError('password')>field-error border-red-400<#else>border-[#334155]</#if>"
class="sc-input sc-input-pr <#if messagesPerField.existsError('password')>sc-input-error</#if>"
placeholder="Almeno 8 caratteri"
autocomplete="new-password"
/>
<button
type="button"
onclick="togglePassword('password', this)"
tabindex="-1"
class="absolute right-3 top-1/2 -translate-y-1/2 text-[#475569] hover:text-[#94a3b8] transition-colors p-1"
>
<button type="button" onclick="togglePassword('password', this)" tabindex="-1" class="sc-icon-btn">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
@@ -177,31 +354,23 @@
</button>
</div>
<#if messagesPerField.existsError('password')>
<p class="text-red-400 text-xs mt-1">${messagesPerField.get('password')?no_esc}</p>
<p class="sc-field-error">${messagesPerField.get('password')?no_esc}</p>
</#if>
</div>
<!-- Conferma password -->
<div class="mb-7">
<label for="password-confirm" class="block text-[#cbd5e1] text-sm font-medium mb-2">
Conferma password
</label>
<label for="password-confirm" class="sc-label">Conferma password</label>
<div class="relative">
<input
type="password"
id="password-confirm"
name="password-confirm"
class="w-full bg-[#0f172a] border rounded-xl px-4 py-3 pr-12 text-[#f1f5f9] placeholder-[#475569] focus:outline-none focus:border-cyan-500 transition-colors text-sm
<#if messagesPerField.existsError('password-confirm')>field-error border-red-400<#else>border-[#334155]</#if>"
class="sc-input sc-input-pr <#if messagesPerField.existsError('password-confirm')>sc-input-error</#if>"
placeholder="Ripeti la password"
autocomplete="new-password"
/>
<button
type="button"
onclick="togglePassword('password-confirm', this)"
tabindex="-1"
class="absolute right-3 top-1/2 -translate-y-1/2 text-[#475569] hover:text-[#94a3b8] transition-colors p-1"
>
<button type="button" onclick="togglePassword('password-confirm', this)" tabindex="-1" class="sc-icon-btn">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
@@ -212,51 +381,66 @@
</button>
</div>
<#if messagesPerField.existsError('password-confirm')>
<p class="text-red-400 text-xs mt-1">${messagesPerField.get('password-confirm')?no_esc}</p>
<p class="sc-field-error">${messagesPerField.get('password-confirm')?no_esc}</p>
</#if>
</div>
<!-- Bottone registrati -->
<button
type="submit"
class="w-full bg-cyan-500 hover:bg-cyan-400 active:bg-cyan-600 text-[#0f172a] font-semibold py-3 rounded-xl transition-colors text-sm"
>
Crea account
</button>
<button type="submit" class="sc-btn-primary">Crea account</button>
</form>
<!-- Link accedi -->
<p class="text-center text-[#94a3b8] mt-6 text-sm">
<p class="text-center mt-6 text-sm" style="color:var(--text-secondary)">
Hai già un account?
<a href="${url.loginUrl}"
class="text-cyan-400 hover:text-cyan-300 transition-colors font-medium ml-1">
Accedi
</a>
<a href="${url.loginUrl}" class="sc-link ml-1">Accedi</a>
</p>
</div>
</div>
<script>
// ── Inizializzazione tema ───────────────────────────────────
(function () {
var saved = localStorage.getItem('kc-theme');
var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
var isDark = saved ? saved === 'dark' : prefersDark;
document.documentElement.classList.add(isDark ? 'dark' : 'light');
updateToggleIcon(isDark);
})();
function updateToggleIcon(isDark) {
document.getElementById('theme-icon-sun').style.display = isDark ? 'inline' : 'none';
document.getElementById('theme-icon-moon').style.display = isDark ? 'none' : 'inline';
}
function toggleTheme() {
var html = document.documentElement;
var isDark = html.classList.contains('dark');
html.classList.replace(isDark ? 'dark' : 'light', isDark ? 'light' : 'dark');
localStorage.setItem('kc-theme', isDark ? 'light' : 'dark');
updateToggleIcon(!isDark);
}
// ── Toggle visibilità password ──────────────────────────────
function togglePassword(inputId, btn) {
const input = document.getElementById(inputId);
const isHidden = input.type === 'password';
var input = document.getElementById(inputId);
var isHidden = input.type === 'password';
input.type = isHidden ? 'text' : 'password';
const svg = btn.querySelector('svg');
var svg = btn.querySelector('svg');
if (isHidden) {
svg.innerHTML = `
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7
a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243
M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29
M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7
a10.025 10.025 0 01-4.132 5.411m0 0L21 21"/>`;
svg.innerHTML =
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"' +
' d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7' +
' a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243' +
' M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29' +
' M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7' +
' a10.025 10.025 0 01-4.132 5.411m0 0L21 21"/>';
} else {
svg.innerHTML = `
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7
-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>`;
svg.innerHTML =
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"' +
' d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>' +
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"' +
' d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7' +
' -1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>';
}
}
</script>