Aggiornamento grafica
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
@@ -0,0 +1 @@
|
||||
declare const GEMINI_API_KEY: string;
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Google AI Studio Angular App</title>
|
||||
<base href="/" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico" />
|
||||
</head>
|
||||
<body>
|
||||
<app-root></app-root>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,11 @@
|
||||
import {
|
||||
BootstrapContext,
|
||||
bootstrapApplication,
|
||||
} from '@angular/platform-browser';
|
||||
import {App} from './app/components/app/app';
|
||||
import {config} from './app/app.config.server';
|
||||
|
||||
const bootstrap = (context: BootstrapContext) =>
|
||||
bootstrapApplication(App, config, context);
|
||||
|
||||
export default bootstrap;
|
||||
@@ -0,0 +1,35 @@
|
||||
import { bootstrapApplication } from '@angular/platform-browser';
|
||||
import { App } from './app/components/app/app';
|
||||
import { appConfig } from './app/app.config';
|
||||
import Keycloak from 'keycloak-js';
|
||||
import { environment } from './environments/environment';
|
||||
import { KEYCLOAK_INSTANCE } from './app/core/tokens/keycloak.token';
|
||||
|
||||
const keycloak = new Keycloak({
|
||||
url: environment.keycloakUrl,
|
||||
realm: environment.keycloakRealm,
|
||||
clientId: environment.keycloakClientId,
|
||||
});
|
||||
|
||||
keycloak.init({
|
||||
onLoad: 'login-required',
|
||||
checkLoginIframe: false,
|
||||
pkceMethod: 'S256',
|
||||
}).then(authenticated => {
|
||||
if (!authenticated) {
|
||||
keycloak.login();
|
||||
return;
|
||||
}
|
||||
|
||||
const extendedConfig = {
|
||||
...appConfig,
|
||||
providers: [
|
||||
...(appConfig.providers || []),
|
||||
{ provide: KEYCLOAK_INSTANCE, useValue: keycloak },
|
||||
]
|
||||
};
|
||||
|
||||
bootstrapApplication(App, extendedConfig).catch((err) => console.error(err));
|
||||
}).catch(err => {
|
||||
console.error('Keycloak initialization failed:', err);
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
AngularNodeAppEngine,
|
||||
createNodeRequestHandler,
|
||||
isMainModule,
|
||||
writeResponseToNodeResponse,
|
||||
} from '@angular/ssr/node';
|
||||
import express from 'express';
|
||||
import {join} from 'node:path';
|
||||
|
||||
const browserDistFolder = join(import.meta.dirname, '../browser');
|
||||
|
||||
const app = express();
|
||||
const angularApp = new AngularNodeAppEngine();
|
||||
|
||||
/**
|
||||
* Example Express Rest API endpoints can be defined here.
|
||||
* Uncomment and define endpoints as necessary.
|
||||
*
|
||||
* Example:
|
||||
* ```ts
|
||||
* app.get('/api/{*splat}', (req, res) => {
|
||||
* // Handle API request
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
|
||||
/**
|
||||
* Serve static files from /browser
|
||||
*/
|
||||
app.use(
|
||||
express.static(browserDistFolder, {
|
||||
maxAge: '1y',
|
||||
index: false,
|
||||
redirect: false,
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Handle all other requests by rendering the Angular application.
|
||||
*/
|
||||
app.use((req, res, next) => {
|
||||
angularApp
|
||||
.handle(req)
|
||||
.then((response) =>
|
||||
response ? writeResponseToNodeResponse(response, res) : next(),
|
||||
)
|
||||
.catch(next);
|
||||
});
|
||||
|
||||
/**
|
||||
* Start the server if this module is the main entry point, or it is ran via PM2.
|
||||
* The server listens on the port defined by the `PORT` environment variable, or defaults to 4000.
|
||||
*/
|
||||
if (isMainModule(import.meta.url) || process.env['pm_id']) {
|
||||
const port = process.env['PORT'] || 4000;
|
||||
app.listen(port, (error) => {
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
console.log(`Node Express server listening on http://localhost:${port}`);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Request handler used by the Angular CLI (for dev-server and during build) or Firebase Cloud Functions.
|
||||
*/
|
||||
export const reqHandler = createNodeRequestHandler(app);
|
||||
@@ -0,0 +1,89 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=Material+Icons&display=swap');
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--color-primary: #4c49c9;
|
||||
--color-primary-container: #9695ff;
|
||||
--color-secondary: #006a26;
|
||||
--color-secondary-container: #6ffb85;
|
||||
--color-tertiary: #8a2ab9;
|
||||
--color-tertiary-container: #dd94ff;
|
||||
--color-error: #b41340;
|
||||
--color-error-container: #f74b6d;
|
||||
--color-background: #faf4ff;
|
||||
--color-surface: #ffffff;
|
||||
--color-on-background: #32294f;
|
||||
--color-on-surface: #32294f;
|
||||
--color-on-surface-variant: #5f557f;
|
||||
--color-surface-container: #f3f0f7;
|
||||
--color-surface-container-low: #f8f6fb;
|
||||
--color-surface-container-high: #edeaf2;
|
||||
--color-surface-container-highest: #e7e4ed;
|
||||
--color-on-primary: #ffffff;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--color-primary: #6366F1;
|
||||
--color-primary-container: #4338CA;
|
||||
--color-secondary: #A855F7;
|
||||
--color-secondary-container: #7E22CE;
|
||||
--color-tertiary: #F472B6;
|
||||
--color-tertiary-container: #BE185D;
|
||||
--color-error: #EF4444;
|
||||
--color-error-container: #991B1B;
|
||||
--color-background: #0F172A;
|
||||
--color-surface: #1E293B;
|
||||
--color-on-background: #F8FAFC;
|
||||
--color-on-surface: #F8FAFC;
|
||||
--color-on-surface-variant: #94A3B8;
|
||||
--color-surface-container: #334155;
|
||||
--color-surface-container-low: #1E293B;
|
||||
--color-surface-container-high: #475569;
|
||||
--color-surface-container-highest: #64748B;
|
||||
--color-on-primary: #ffffff;
|
||||
}
|
||||
|
||||
@theme {
|
||||
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-headline: "Plus Jakarta Sans", sans-serif;
|
||||
|
||||
--color-primary: var(--color-primary);
|
||||
--color-primary-container: var(--color-primary-container);
|
||||
--color-secondary: var(--color-secondary);
|
||||
--color-secondary-container: var(--color-secondary-container);
|
||||
--color-tertiary: var(--color-tertiary);
|
||||
--color-tertiary-container: var(--color-tertiary-container);
|
||||
--color-error: var(--color-error);
|
||||
--color-error-container: var(--color-error-container);
|
||||
--color-background: var(--color-background);
|
||||
--color-surface: var(--color-surface);
|
||||
--color-on-background: var(--color-on-background);
|
||||
--color-on-surface: var(--color-on-surface);
|
||||
--color-on-surface-variant: var(--color-on-surface-variant);
|
||||
--color-surface-container: var(--color-surface-container);
|
||||
--color-surface-container-low: var(--color-surface-container-low);
|
||||
--color-surface-container-high: var(--color-surface-container-high);
|
||||
--color-surface-container-highest: var(--color-surface-container-highest);
|
||||
--color-on-primary: var(--color-on-primary);
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-on-background font-sans transition-colors duration-300;
|
||||
}
|
||||
|
||||
.font-headline {
|
||||
font-family: var(--font-headline);
|
||||
}
|
||||
|
||||
.soft-shadow {
|
||||
box-shadow: 0 12px 32px -8px rgba(50, 41, 79, 0.08);
|
||||
}
|
||||
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
Reference in New Issue
Block a user