Compare commits

..
10 Commits
160 changed files with 28397 additions and 325 deletions
+56
View File
@@ -0,0 +1,56 @@
# ==========================================================================
# ScommettiApp - variabili d'ambiente per docker-compose (root)
#
# Copia questo file in ".env" nella stessa cartella e compila i valori.
# Il file ".env" reale NON va committato (è già in .gitignore).
# ==========================================================================
# --------------------------------------------------------------------------
# Rete / porte pubblicate sull'host
# --------------------------------------------------------------------------
# IP su cui pubblicare le porte dei container sull'host della VPS.
# Caddy gira in un CT Proxmox separato (non nella stessa rete Docker), quindi
# deve raggiungere questi servizi via IP dell'host + porta pubblicata.
# Imposta qui l'IP con cui la VPS è raggiungibile dal CT di Caddy (es. IP
# della rete interna/bridge Proxmox), NON 0.0.0.0: pubblicare su 0.0.0.0
# espone i servizi anche a internet, bypassando Caddy (e occhio: le regole
# iptables di Docker bypassano ufw se non configurato apposta - vedi note
# nel Caddyfile.example).
BIND_IP=10.0.0.10
BACKEND_PORT=3000
FRONTEND_PORT=8081
KEYCLOAK_PORT=8080
# --------------------------------------------------------------------------
# Applicazione
# --------------------------------------------------------------------------
NODE_ENV=production
# --------------------------------------------------------------------------
# PostgreSQL (dati app: users, groups, friendships, ...)
# --------------------------------------------------------------------------
POSTGRES_DB=amici_scommesse
POSTGRES_USER=admin
POSTGRES_PASSWORD=change-me-strong-password
# --------------------------------------------------------------------------
# MongoDB (bets)
# --------------------------------------------------------------------------
MONGO_INITDB_ROOT_USERNAME=root
MONGO_INITDB_ROOT_PASSWORD=change-me-strong-password
MONGO_INITDB_DATABASE=amici-scommesse
# --------------------------------------------------------------------------
# Keycloak
# --------------------------------------------------------------------------
# Dominio pubblico con cui Caddy espone l'app (usato da Keycloak per
# costruire URL assoluti corretti dietro il reverse proxy).
DOMAIN=scommetti.example.com
KEYCLOAK_ADMIN=admin
KEYCLOAK_ADMIN_PASSWORD=change-me-strong-password
KEYCLOAK_DB=keycloak
KEYCLOAK_DB_USER=keycloak
KEYCLOAK_DB_PASSWORD=change-me-strong-password
+25
View File
@@ -0,0 +1,25 @@
name: Deploy stack su Proxmox
on:
push:
branches:
- master
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Deploy via SSH (git pull + docker compose up -d --build)
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
port: ${{ secrets.DEPLOY_PORT }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
set -e
cd "${{ secrets.DEPLOY_PATH }}"
git fetch origin master
git reset --hard origin/master
docker compose up -d --build --remove-orphans
docker image prune -f
+14
View File
@@ -0,0 +1,14 @@
/.idea/
/.vscode/
/node_modules/
/.env
/.angular/
/frontend-angular/.angular/
/backend-node/node_modules/
/backend-node/.env
/frontend-angular/node_modules/
/.claude/
/.ai/mcp/mcp.json
/frontend-angular-old/.angular/
/frontend-angular-old/node_modules/
/.ai/
+2 -2
View File
@@ -22,7 +22,7 @@ COPY package*.json ./
FROM base AS dependencies FROM base AS dependencies
# Install ALL dependencies (including devDependencies for build) # Install ALL dependencies (including devDependencies for build)
RUN npm ci RUN npm install
# ======================================== # ========================================
# Stage 3: Development # Stage 3: Development
@@ -50,7 +50,7 @@ CMD ["npm", "start"]
FROM base AS production-dependencies FROM base AS production-dependencies
# Install only production dependencies # Install only production dependencies
RUN npm ci --only=production && \ RUN npm install --only=production && \
npm cache clean --force npm cache clean --force
# ======================================== # ========================================
+1 -5
View File
@@ -19,10 +19,6 @@ module.exports = {
uri: uri:
process.env.MONGO_URI || process.env.MONGO_URI ||
'mongodb://localhost:27017/amici-scommesse', 'mongodb://localhost:27017/amici-scommesse',
options: { options: {},
user: 'root',
pass: 'root@servovio',
authSource: 'admin',
},
}, },
}; };
@@ -5,6 +5,24 @@ class BetsController {
this.betsService = betsService; this.betsService = betsService;
} }
async getUserBets(req, res, next) {
try {
const bets = await this.betsService.getUserBets(req.userId);
return ResponseHandler.success(res, bets, 'User bets retrieved successfully');
} catch (error) {
next(error);
}
}
async getPublicBets(req, res, next) {
try {
const bets = await this.betsService.getPublicBetsExcludingUser(req.userId);
return ResponseHandler.success(res, bets, 'Public bets retrieved successfully');
} catch (error) {
next(error);
}
}
async getAllBets(req, res, next) { async getAllBets(req, res, next) {
try { try {
const bets = await this.betsService.getAllBets(); const bets = await this.betsService.getAllBets();
@@ -7,7 +7,7 @@ class GroupsController {
async getAllGroups(req, res, next) { async getAllGroups(req, res, next) {
try { try {
const groups = await this.groupsService.getAllGroups(); const groups = await this.groupsService.getAllGroups(req.userId);
return ResponseHandler.success(res, groups, 'Groups retrieved successfully'); return ResponseHandler.success(res, groups, 'Groups retrieved successfully');
} catch (error) { } catch (error) {
next(error); next(error);
@@ -25,7 +25,7 @@ class GroupsController {
async createGroup(req, res, next) { async createGroup(req, res, next) {
try { try {
const group = await this.groupsService.createGroup(req.body); const group = await this.groupsService.createGroup(req.body, req.userId);
return ResponseHandler.created(res, group, 'Group created successfully'); return ResponseHandler.created(res, group, 'Group created successfully');
} catch (error) { } catch (error) {
next(error); next(error);
@@ -52,10 +52,7 @@ class GroupsController {
async addMembers(req, res, next) { async addMembers(req, res, next) {
try { try {
const group = await this.groupsService.addMembersToGroup( const group = await this.groupsService.addMembersToGroup(req.params.id, req.body.userIds);
req.params.id,
req.body.userIds
);
return ResponseHandler.success(res, group, 'Members added successfully'); return ResponseHandler.success(res, group, 'Members added successfully');
} catch (error) { } catch (error) {
next(error); next(error);
@@ -1,38 +0,0 @@
const ResponseHandler = require('../shared/response.handler');
class InitialDataController {
constructor({ usersService, friendsService, groupsService, betsService }) {
this.usersService = usersService;
this.friendsService = friendsService;
this.groupsService = groupsService;
this.betsService = betsService;
}
async getInitialData(req, res, next) {
try {
const currentUser = await this.usersService.getUserById(req.userId);
const users = await this.usersService.getAllUsers();
const friends = await this.friendsService.getFriendsByUserId(req.userId);
const friendRequests = await this.friendsService.getPendingRequests(req.userId);
const groups = await this.groupsService.getAllGroups();
const userBets = await this.betsService.getUserBets(req.userId);
const publicBets = await this.betsService.getPublicBetsExcludingUser(req.userId);
const data = {
currentUser,
users,
friends,
friendRequests,
groups,
userBets,
publicBets,
};
return ResponseHandler.success(res, data, 'Initial data retrieved successfully');
} catch (error) {
next(error);
}
}
}
module.exports = InitialDataController;
@@ -5,6 +5,28 @@ class UsersController {
this.usersService = usersService; this.usersService = usersService;
} }
async getCurrentUser(req, res, next) {
try {
const user = await this.usersService.getCurrentUserWithRank(req.userId);
return ResponseHandler.success(res, user, 'User retrieved successfully');
} catch (error) {
next(error);
}
}
async searchUsers(req, res, next) {
try {
const { q } = req.query;
if (!q || !q.trim()) {
return ResponseHandler.badRequest(res, 'Query parameter "q" is required');
}
const users = await this.usersService.searchUsers(q.trim(), req.userId);
return ResponseHandler.success(res, users, 'Users found');
} catch (error) {
next(error);
}
}
async updateCurrentUser(req, res, next) { async updateCurrentUser(req, res, next) {
try { try {
const user = await this.usersService.updateUserProfile(req.userId, req.body); const user = await this.usersService.updateUserProfile(req.userId, req.body);
+6 -17
View File
@@ -18,13 +18,13 @@ function setupContainer() {
const modelsDir = path.join(__dirname, '../models'); const modelsDir = path.join(__dirname, '../models');
fs.readdirSync(modelsDir) fs.readdirSync(modelsDir)
.filter( .filter(
(file) => file =>
file.indexOf('.') !== 0 && file.indexOf('.') !== 0 &&
file.slice(-3) === '.js' && file.slice(-3) === '.js' &&
file !== 'bet.model.js' && file !== 'bet.model.js' &&
file !== 'associations.js' file !== 'associations.js'
) )
.forEach((file) => { .forEach(file => {
require(path.join(modelsDir, file))(sequelize); require(path.join(modelsDir, file))(sequelize);
}); });
@@ -38,27 +38,19 @@ function setupContainer() {
}); });
container.register({ container.register({
usersRepository: asClass( usersRepository: asClass(require('../repositories/postgres/users.repository')).scoped(),
require('../repositories/postgres/users.repository') groupsRepository: asClass(require('../repositories/postgres/groups.repository')).scoped(),
).scoped(),
groupsRepository: asClass(
require('../repositories/postgres/groups.repository')
).scoped(),
friendshipsRepository: asClass( friendshipsRepository: asClass(
require('../repositories/postgres/friendships.repository') require('../repositories/postgres/friendships.repository')
).scoped(), ).scoped(),
friendRequestsRepository: asClass( friendRequestsRepository: asClass(
require('../repositories/postgres/friendRequests.repository') require('../repositories/postgres/friendRequests.repository')
).scoped(), ).scoped(),
betsRepository: asClass( betsRepository: asClass(require('../repositories/mongo/bets.repository')).scoped(),
require('../repositories/mongo/bets.repository')
).scoped(),
}); });
container.register({ container.register({
dataStitchingService: asClass( dataStitchingService: asClass(require('../services/data-stitching.service')).scoped(),
require('../services/data-stitching.service')
).scoped(),
usersService: asClass(require('../services/users.service')).scoped(), usersService: asClass(require('../services/users.service')).scoped(),
groupsService: asClass(require('../services/groups.service')).scoped(), groupsService: asClass(require('../services/groups.service')).scoped(),
friendsService: asClass(require('../services/friends.service')).scoped(), friendsService: asClass(require('../services/friends.service')).scoped(),
@@ -70,9 +62,6 @@ function setupContainer() {
groupsController: asClass(require('../controllers/groups.controller')).scoped(), groupsController: asClass(require('../controllers/groups.controller')).scoped(),
usersController: asClass(require('../controllers/users.controller')).scoped(), usersController: asClass(require('../controllers/users.controller')).scoped(),
friendsController: asClass(require('../controllers/friends.controller')).scoped(), friendsController: asClass(require('../controllers/friends.controller')).scoped(),
initialDataController: asClass(
require('../controllers/initialData.controller')
).scoped(),
}); });
return container; return container;
+2
View File
@@ -11,6 +11,7 @@ DROP TABLE IF EXISTS users CASCADE;
-- users table -- users table
CREATE TABLE users ( CREATE TABLE users (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
"keycloakId" VARCHAR(255) UNIQUE,
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
"email" VARCHAR(255) NOT NULL UNIQUE, "email" VARCHAR(255) NOT NULL UNIQUE,
"avatarUrl" VARCHAR(255) NOT NULL, "avatarUrl" VARCHAR(255) NOT NULL,
@@ -63,6 +64,7 @@ CREATE TABLE friend_requests (
-- User <-> Group many-to-many join table -- User <-> Group many-to-many join table
CREATE TABLE "UserGroups" ( CREATE TABLE "UserGroups" (
id SERIAL PRIMARY KEY,
"userId" INTEGER NOT NULL, "userId" INTEGER NOT NULL,
"groupId" INTEGER NOT NULL, "groupId" INTEGER NOT NULL,
CONSTRAINT usergroups_user_fk FOREIGN KEY ("userId") REFERENCES users(id) ON DELETE CASCADE, CONSTRAINT usergroups_user_fk FOREIGN KEY ("userId") REFERENCES users(id) ON DELETE CASCADE,
+67
View File
@@ -0,0 +1,67 @@
const jwksClient = require('jwks-rsa');
const jwt = require('jsonwebtoken');
const client = jwksClient({
jwksUri:
process.env.KEYCLOAK_JWKS_URI ||
'http://localhost:8080/realms/scommettiapp/protocol/openid-connect/certs',
cache: true,
rateLimit: true,
jwksRequestsPerMinute: 5,
});
function getKey(header, callback) {
client.getSigningKey(header.kid, (err, key) => {
if (err) return callback(err);
callback(null, key.getPublicKey());
});
}
module.exports = sequelize => {
const { User } = sequelize.models;
return (req, res, next) => {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ success: false, message: 'Token mancante o non valido' });
}
const token = authHeader.slice(7);
jwt.verify(token, getKey, { algorithms: ['RS256'] }, async (err, decoded) => {
if (err) {
return res.status(401).json({ success: false, message: 'Token non valido o scaduto' });
}
try {
const keycloakId = decoded.sub;
const name =
decoded.name ||
`${decoded.given_name || ''} ${decoded.family_name || ''}`.trim() ||
decoded.preferred_username ||
'Utente';
const email = decoded.email || `${keycloakId.slice(0, 8)}@scommetti.app`;
const avatarUrl = `https://picsum.photos/seed/${keycloakId.slice(0, 8)}/200/200`;
// Prima cerca per keycloakId (caso normale dopo il primo login)
let user = await User.findOne({ where: { keycloakId } });
if (!user) {
// Migrazione: l'utente potrebbe esistere già per email
// (creato prima che keycloakId fosse aggiunto al modello)
user = await User.findOne({ where: { email } });
if (user) {
await user.update({ keycloakId });
} else {
user = await User.create({ keycloakId, name, email, avatarUrl, score: 0 });
}
}
req.userId = user.id;
next();
} catch (error) {
next(error);
}
});
};
};
+41 -17
View File
@@ -1,21 +1,45 @@
module.exports = (sequelize) => { const { DataTypes } = require('sequelize');
const { User, Group, FriendRequest, Friendship } = sequelize.models;
// User <-> Group: Many-to-Many module.exports = sequelize => {
// A User can be in many Groups, and a Group can have many Users (members). const { User, Group, FriendRequest, Friendship } = sequelize.models;
const UserGroup = sequelize.define('UserGroup', {}, { timestamps: false });
User.belongsToMany(Group, { through: UserGroup, foreignKey: 'userId' });
Group.belongsToMany(User, { through: UserGroup, as: 'members', foreignKey: 'groupId' });
// Friendship is a direct link between two users. // User <-> Group: Many-to-Many
// user1Id will always be less than user2Id to ensure uniqueness. // Il modello dichiara esplicitamente la colonna id per evitare che sync() la cerchi
Friendship.belongsTo(User, { as: 'user1', foreignKey: 'user1Id' }); // sulla tabella UserGroups senza trovarla (causando errori su RETURNING "id").
Friendship.belongsTo(User, { as: 'user2', foreignKey: 'user2Id' }); const UserGroup = sequelize.define(
'UserGroup',
{
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
userId: { type: DataTypes.INTEGER, allowNull: false },
groupId: { type: DataTypes.INTEGER, allowNull: false },
},
{ timestamps: false }
);
User.belongsToMany(Group, { through: UserGroup, foreignKey: 'userId', otherKey: 'groupId' });
Group.belongsToMany(User, {
through: UserGroup,
as: 'members',
foreignKey: 'groupId',
otherKey: 'userId',
});
// User <-> FriendRequest: One-to-Many // Friendship is a direct link between two users.
// A User can send many friend requests and receive many friend requests. // user1Id will always be less than user2Id to ensure uniqueness.
User.hasMany(FriendRequest, { as: 'sentRequests', foreignKey: 'fromUserId', onDelete: 'CASCADE' }); Friendship.belongsTo(User, { as: 'user1', foreignKey: 'user1Id' });
User.hasMany(FriendRequest, { as: 'receivedRequests', foreignKey: 'toUserId', onDelete: 'CASCADE' }); Friendship.belongsTo(User, { as: 'user2', foreignKey: 'user2Id' });
FriendRequest.belongsTo(User, { as: 'fromUser', foreignKey: 'fromUserId' });
FriendRequest.belongsTo(User, { as: 'toUser', foreignKey: 'toUserId' }); // User <-> FriendRequest: One-to-Many
// A User can send many friend requests and receive many friend requests.
User.hasMany(FriendRequest, {
as: 'sentRequests',
foreignKey: 'fromUserId',
onDelete: 'CASCADE',
});
User.hasMany(FriendRequest, {
as: 'receivedRequests',
foreignKey: 'toUserId',
onDelete: 'CASCADE',
});
FriendRequest.belongsTo(User, { as: 'fromUser', foreignKey: 'fromUserId' });
FriendRequest.belongsTo(User, { as: 'toUser', foreignKey: 'toUserId' });
}; };
+28 -15
View File
@@ -1,21 +1,34 @@
const { DataTypes } = require('sequelize'); const { DataTypes } = require('sequelize');
module.exports = (sequelize) => { module.exports = sequelize => {
const FriendRequest = sequelize.define('FriendRequest', { const FriendRequest = sequelize.define(
id: { 'FriendRequest',
type: DataTypes.INTEGER, {
autoIncrement: true, id: {
primaryKey: true type: DataTypes.INTEGER,
autoIncrement: true,
primaryKey: true,
},
status: {
type: DataTypes.STRING,
defaultValue: 'PENDING',
allowNull: false,
},
// Foreign keys 'fromUserId' and 'toUserId' are added via associations
}, },
status: { {
type: DataTypes.STRING, // Uses default `createdAt` and `updatedAt` timestamps from Sequelize
defaultValue: 'PENDING', tableName: 'friend_requests',
allowNull: false
} }
// Foreign keys 'fromUserId' and 'toUserId' are added via associations );
}, {
// Uses default `createdAt` and `updatedAt` timestamps from Sequelize FriendRequest.prototype.toJSON = function () {
tableName: 'friend_requests' const values = { ...this.get() };
}); if (values.id !== undefined) values.id = String(values.id);
if (values.fromUserId !== undefined) values.fromUserId = String(values.fromUserId);
if (values.toUserId !== undefined) values.toUserId = String(values.toUserId);
return values;
};
return FriendRequest; return FriendRequest;
}; };
+37 -13
View File
@@ -1,19 +1,43 @@
const { DataTypes } = require('sequelize'); const { DataTypes } = require('sequelize');
module.exports = (sequelize) => { module.exports = sequelize => {
const Group = sequelize.define('Group', { const Group = sequelize.define(
id: { 'Group',
type: DataTypes.INTEGER, {
autoIncrement: true, id: {
primaryKey: true type: DataTypes.INTEGER,
autoIncrement: true,
primaryKey: true,
},
name: {
type: DataTypes.STRING,
allowNull: false,
},
description: {
type: DataTypes.TEXT,
allowNull: true,
},
isPrivate: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
avatarUrl: {
type: DataTypes.STRING,
allowNull: true,
},
}, },
name: { {
type: DataTypes.STRING, timestamps: false,
allowNull: false tableName: 'groups',
} }
}, { );
timestamps: false,
tableName: 'groups' Group.prototype.toJSON = function () {
}); const values = { ...this.get() };
if (values.id !== undefined) values.id = String(values.id);
return values;
};
return Group; return Group;
}; };
+46 -30
View File
@@ -1,35 +1,51 @@
const { DataTypes } = require('sequelize'); const { DataTypes } = require('sequelize');
module.exports = (sequelize) => { module.exports = sequelize => {
const User = sequelize.define('User', { const User = sequelize.define(
id: { 'User',
type: DataTypes.INTEGER, {
autoIncrement: true, id: {
primaryKey: true type: DataTypes.INTEGER,
autoIncrement: true,
primaryKey: true,
},
keycloakId: {
type: DataTypes.STRING,
allowNull: true,
unique: true,
},
name: {
type: DataTypes.STRING,
allowNull: false,
},
email: {
type: DataTypes.STRING,
allowNull: false,
unique: true,
validate: {
isEmail: true,
},
},
avatarUrl: {
type: DataTypes.STRING,
allowNull: false,
},
score: {
type: DataTypes.INTEGER,
defaultValue: 0,
},
}, },
name: { {
type: DataTypes.STRING, timestamps: false,
allowNull: false tableName: 'users',
}, }
email: { );
type: DataTypes.STRING,
allowNull: false, User.prototype.toJSON = function () {
unique: true, const values = { ...this.get() };
validate: { if (values.id !== undefined) values.id = String(values.id);
isEmail: true return values;
} };
},
avatarUrl: {
type: DataTypes.STRING,
allowNull: false
},
score: {
type: DataTypes.INTEGER,
defaultValue: 0
},
}, {
timestamps: false,
tableName: 'users'
});
return User; return User;
}; };
+205
View File
@@ -13,6 +13,8 @@
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^4.19.2", "express": "^4.19.2",
"joi": "^17.12.1", "joi": "^17.12.1",
"jsonwebtoken": "^9.0.2",
"jwks-rsa": "^3.1.0",
"mongoose": "^8.4.0", "mongoose": "^8.4.0",
"pg": "^8.11.5", "pg": "^8.11.5",
"pg-hstore": "^2.3.4", "pg-hstore": "^2.3.4",
@@ -294,6 +296,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/jsonwebtoken": {
"version": "9.0.10",
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
"integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
"license": "MIT",
"dependencies": {
"@types/ms": "*",
"@types/node": "*"
}
},
"node_modules/@types/ms": { "node_modules/@types/ms": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
@@ -653,6 +665,12 @@
"node": ">=16.20.1" "node": ">=16.20.1"
} }
}, },
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/bytes": { "node_modules/bytes": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -1003,6 +1021,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/ee-first": { "node_modules/ee-first": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -2640,6 +2667,15 @@
"@sideway/pinpoint": "^2.0.0" "@sideway/pinpoint": "^2.0.0"
} }
}, },
"node_modules/jose": {
"version": "4.15.9",
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-yaml": { "node_modules/js-yaml": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
@@ -2687,6 +2723,94 @@
"json5": "lib/cli.js" "json5": "lib/cli.js"
} }
}, },
"node_modules/jsonwebtoken": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
"license": "MIT",
"dependencies": {
"jws": "^4.0.1",
"lodash.includes": "^4.3.0",
"lodash.isboolean": "^3.0.3",
"lodash.isinteger": "^4.0.4",
"lodash.isnumber": "^3.0.3",
"lodash.isplainobject": "^4.0.6",
"lodash.isstring": "^4.0.1",
"lodash.once": "^4.0.0",
"ms": "^2.1.1",
"semver": "^7.5.4"
},
"engines": {
"node": ">=12",
"npm": ">=6"
}
},
"node_modules/jsonwebtoken/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jwks-rsa": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-3.2.2.tgz",
"integrity": "sha512-BqTyEDV+lS8F2trk3A+qJnxV5Q9EqKCBJOPti3W97r7qTympCZjb7h2X6f2kc+0K3rsSTY1/6YG2eaXKoj497w==",
"license": "MIT",
"dependencies": {
"@types/jsonwebtoken": "^9.0.4",
"debug": "^4.3.4",
"jose": "^4.15.4",
"limiter": "^1.1.5",
"lru-memoizer": "^2.2.0"
},
"engines": {
"node": ">=14"
}
},
"node_modules/jwks-rsa/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/jwks-rsa/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/kareem": { "node_modules/kareem": {
"version": "2.6.3", "version": "2.6.3",
"resolved": "https://registry.npmjs.org/kareem/-/kareem-2.6.3.tgz", "resolved": "https://registry.npmjs.org/kareem/-/kareem-2.6.3.tgz",
@@ -2720,6 +2844,11 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/limiter": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz",
"integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA=="
},
"node_modules/locate-path": { "node_modules/locate-path": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@@ -2742,6 +2871,48 @@
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/lodash.clonedeep": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
"integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==",
"license": "MIT"
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
"license": "MIT"
},
"node_modules/lodash.isinteger": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
"license": "MIT"
},
"node_modules/lodash.isnumber": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
"license": "MIT"
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"license": "MIT"
},
"node_modules/lodash.isstring": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
"license": "MIT"
},
"node_modules/lodash.merge": { "node_modules/lodash.merge": {
"version": "4.6.2", "version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -2749,6 +2920,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
},
"node_modules/lower-case": { "node_modules/lower-case": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz",
@@ -2758,6 +2935,28 @@
"tslib": "^2.0.3" "tslib": "^2.0.3"
} }
}, },
"node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"license": "ISC",
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/lru-memoizer": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-2.3.0.tgz",
"integrity": "sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==",
"license": "MIT",
"dependencies": {
"lodash.clonedeep": "^4.5.0",
"lru-cache": "6.0.0"
}
},
"node_modules/math-intrinsics": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -4763,6 +4962,12 @@
"node": ">=0.4" "node": ">=0.4"
} }
}, },
"node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"license": "ISC"
},
"node_modules/yocto-queue": { "node_modules/yocto-queue": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+2
View File
@@ -30,6 +30,8 @@
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^4.19.2", "express": "^4.19.2",
"joi": "^17.12.1", "joi": "^17.12.1",
"jsonwebtoken": "^9.0.2",
"jwks-rsa": "^3.1.0",
"mongoose": "^8.4.0", "mongoose": "^8.4.0",
"pg": "^8.11.5", "pg": "^8.11.5",
"pg-hstore": "^2.3.4", "pg-hstore": "^2.3.4",
@@ -1,5 +1,5 @@
const { NotFoundError, DatabaseError } = require('../../shared/errors');
const { Op } = require('sequelize'); const { Op } = require('sequelize');
const { NotFoundError, DatabaseError } = require('../../shared/errors');
class FriendRequestsRepository { class FriendRequestsRepository {
constructor({ sequelize }) { constructor({ sequelize }) {
@@ -30,7 +30,7 @@ class FriendRequestsRepository {
try { try {
return await this.FriendRequest.findAll({ return await this.FriendRequest.findAll({
where: { where: {
toUserId: userId, [Op.or]: [{ toUserId: userId }, { fromUserId: userId }],
status: 'PENDING', status: 'PENDING',
}, },
include: [ include: [
@@ -39,10 +39,7 @@ class FriendRequestsRepository {
], ],
}); });
} catch (error) { } catch (error) {
throw new DatabaseError( throw new DatabaseError(`Failed to fetch pending requests for user ${userId}`, error);
`Failed to fetch pending requests for user ${userId}`,
error
);
} }
} }
@@ -64,11 +61,12 @@ class FriendRequestsRepository {
async create(fromUserId, toUserId) { async create(fromUserId, toUserId) {
try { try {
return await this.FriendRequest.create({ const created = await this.FriendRequest.create({
fromUserId, fromUserId,
toUserId, toUserId,
status: 'PENDING', status: 'PENDING',
}); });
return await this.findById(created.id);
} catch (error) { } catch (error) {
throw new DatabaseError('Failed to create friend request', error); throw new DatabaseError('Failed to create friend request', error);
} }
@@ -1,3 +1,4 @@
const { Op } = require('sequelize');
const { NotFoundError, DatabaseError } = require('../../shared/errors'); const { NotFoundError, DatabaseError } = require('../../shared/errors');
class GroupsRepository { class GroupsRepository {
@@ -15,6 +16,29 @@ class GroupsRepository {
} }
} }
async findByUserId(userId) {
try {
return await this.Group.findAll({
include: [
{
model: this.User,
as: 'members',
through: { attributes: [] },
},
],
where: {
id: {
[Op.in]: this.sequelize.literal(
`(SELECT "groupId" FROM "UserGroups" WHERE "userId" = ${parseInt(userId, 10)})`
),
},
},
});
} catch (error) {
throw new DatabaseError('Failed to fetch groups for user', error);
}
}
async findById(id, includeMembers = false) { async findById(id, includeMembers = false) {
try { try {
const options = {}; const options = {};
@@ -1,3 +1,4 @@
const { Op } = require('sequelize');
const { NotFoundError, DatabaseError } = require('../../shared/errors'); const { NotFoundError, DatabaseError } = require('../../shared/errors');
class UsersRepository { class UsersRepository {
@@ -6,6 +7,23 @@ class UsersRepository {
this.User = sequelize.models.User; this.User = sequelize.models.User;
} }
async search(query, excludeUserId) {
try {
return await this.User.findAll({
where: {
id: { [Op.ne]: excludeUserId },
[Op.or]: [
{ name: { [Op.iLike]: `%${query}%` } },
{ email: { [Op.iLike]: `%${query}%` } },
],
},
limit: 20,
});
} catch (error) {
throw new DatabaseError('Failed to search users', error);
}
}
async findAll(options = {}) { async findAll(options = {}) {
try { try {
return await this.User.findAll(options); return await this.User.findAll(options);
@@ -14,6 +32,27 @@ class UsersRepository {
} }
} }
async count() {
try {
return await this.User.count();
} catch (error) {
throw new DatabaseError('Failed to count users', error);
}
}
async countWithScoreAbove(score, excludeId) {
try {
return await this.User.count({
where: {
score: { [Op.gt]: score },
id: { [Op.ne]: excludeId },
},
});
} catch (error) {
throw new DatabaseError('Failed to count users above score', error);
}
}
async findById(id, options = {}) { async findById(id, options = {}) {
try { try {
const user = await this.User.findByPk(id, options); const user = await this.User.findByPk(id, options);
+24 -25
View File
@@ -1,31 +1,30 @@
const express = require('express'); const express = require('express');
// Il router è ora una factory function che riceve l'istanza di sequelize // Il router è ora una factory function che riceve l'istanza di sequelize
module.exports = (sequelize) => { module.exports = sequelize => {
const router = express.Router(); const router = express.Router();
// --- Middleware per simulare un utente loggato ---
const { User } = sequelize.models;
const MOCK_USER_NAME = 'Mario Rossi';
let MOCK_USER_ID = null;
router.use(async (req, res, next) => { // --- Middleware per simulare un utente loggato ---
if (!MOCK_USER_ID) { const { User } = sequelize.models;
const user = await User.findOne({ where: { name: MOCK_USER_NAME } }); const MOCK_USER_NAME = 'Mario Rossi';
if (user) { let MOCK_USER_ID = null;
MOCK_USER_ID = user.id;
}
}
req.userId = MOCK_USER_ID;
next();
});
// --- Monta i router da file separati --- router.use(async (req, res, next) => {
router.use(require('./initial-data.routes')(sequelize)); if (!MOCK_USER_ID) {
router.use('/bets', require('./bets.routes')(sequelize)); const user = await User.findOne({ where: { name: MOCK_USER_NAME } });
router.use('/groups', require('./groups.routes')(sequelize)); if (user) {
router.use('/friend-requests', require('./friends.routes')(sequelize)); MOCK_USER_ID = user.id;
router.use('/users', require('./users.routes')(sequelize)); }
}
return router; req.userId = MOCK_USER_ID;
next();
});
// --- Monta i router da file separati ---
router.use('/bets', require('./bets.routes')(sequelize));
router.use('/groups', require('./groups.routes')(sequelize));
router.use('/friend-requests', require('./friends.routes')(sequelize));
router.use('/users', require('./users.routes')(sequelize));
return router;
}; };
@@ -1,59 +0,0 @@
const express = require('express');
const router = express.Router();
const { Op } = require('sequelize');
// Modello Mongoose per le Scommesse
const Bet = require('../models/bet.model');
module.exports = (sequelize) => {
const { User, Friendship, FriendRequest } = sequelize.models;
const { populateBetsWithUsers } = require('../utils/populateBets')(sequelize);
// GET /api/initial-data
router.get('/initial-data', async (req, res) => {
try {
// --- Query PostgreSQL ---
const currentUser = await User.findByPk(req.userId);
const users = await User.findAll();
const friendships = await Friendship.findAll({
where: { [Op.or]: [{ user1Id: req.userId }, { user2Id: req.userId }] },
include: [{ model: User, as: 'user1' }, { model: User, as: 'user2' }]
});
const friends = friendships.map(f => (f.user1Id === req.userId ? f.user2 : f.user1));
const receivedFriendRequests = await FriendRequest.findAll({
where: { toUserId: req.userId, status: 'PENDING' },
include: [{ model: User, as: 'fromUser' }, { model: User, as: 'toUser' }]
});
const sentFriendRequests = await FriendRequest.findAll({
where: { fromUserId: req.userId, status: 'PENDING' },
include: [{ model: User, as: 'fromUser' }, { model: User, as: 'toUser' }]
});
const userGroups = await currentUser.getGroups({
include: [{ model: User, as: 'members', through: { attributes: [] } }]
});
// --- Query MongoDB ---
const mongoBets = await Bet.find({ 'participants.user': req.userId }).sort({ startDate: -1 });
const mongoPublicBets = await Bet.find({ isPublic: true, 'participants.user': { $ne: req.userId } });
// --- Data Stitching ---
const bets = await populateBetsWithUsers(mongoBets);
const publicBets = await populateBetsWithUsers(mongoPublicBets);
res.json({
currentUser, users, friends, receivedFriendRequests, sentFriendRequests,
bets, publicBets, groups: userGroups
});
} catch (error) {
console.error('Error fetching initial data:', error);
res.status(500).json({ message: 'Error fetching initial data', error: error.message });
}
});
return router;
};
+5 -4
View File
@@ -1,14 +1,13 @@
const express = require('express'); const express = require('express');
const { validate } = require('../../middleware/validation'); const { validate } = require('../../middleware/validation');
const { const { createBetSchema, updateBetSchema } = require('../../shared/validators/schemas/bet.schema');
createBetSchema,
updateBetSchema,
} = require('../../shared/validators/schemas/bet.schema');
module.exports = ({ betsController }) => { module.exports = ({ betsController }) => {
const router = express.Router(); const router = express.Router();
const controller = { const controller = {
getUserBets: (req, res, next) => betsController.getUserBets(req, res, next),
getPublicBets: (req, res, next) => betsController.getPublicBets(req, res, next),
getAllBets: (req, res, next) => betsController.getAllBets(req, res, next), getAllBets: (req, res, next) => betsController.getAllBets(req, res, next),
getBetById: (req, res, next) => betsController.getBetById(req, res, next), getBetById: (req, res, next) => betsController.getBetById(req, res, next),
createBet: (req, res, next) => betsController.createBet(req, res, next), createBet: (req, res, next) => betsController.createBet(req, res, next),
@@ -16,6 +15,8 @@ module.exports = ({ betsController }) => {
deleteBet: (req, res, next) => betsController.deleteBet(req, res, next), deleteBet: (req, res, next) => betsController.deleteBet(req, res, next),
}; };
router.get('/user', controller.getUserBets);
router.get('/public', controller.getPublicBets);
router.get('/', controller.getAllBets); router.get('/', controller.getAllBets);
router.get('/:id', controller.getBetById); router.get('/:id', controller.getBetById);
router.post('/', validate(createBetSchema), controller.createBet); router.post('/', validate(createBetSchema), controller.createBet);
+1 -3
View File
@@ -1,6 +1,6 @@
const express = require('express'); const express = require('express');
module.exports = (container) => { module.exports = container => {
const router = express.Router(); const router = express.Router();
const controllers = { const controllers = {
@@ -8,14 +8,12 @@ module.exports = (container) => {
betsController: container.resolve('betsController'), betsController: container.resolve('betsController'),
usersController: container.resolve('usersController'), usersController: container.resolve('usersController'),
friendsController: container.resolve('friendsController'), friendsController: container.resolve('friendsController'),
initialDataController: container.resolve('initialDataController'),
}; };
router.use('/groups', require('./groups.routes')(controllers)); router.use('/groups', require('./groups.routes')(controllers));
router.use('/bets', require('./bets.routes')(controllers)); router.use('/bets', require('./bets.routes')(controllers));
router.use('/users', require('./users.routes')(controllers)); router.use('/users', require('./users.routes')(controllers));
router.use('/friend-requests', require('./friends.routes')(controllers)); router.use('/friend-requests', require('./friends.routes')(controllers));
router.use('/initial-data', require('./initialData.routes')(controllers));
return router; return router;
}; };
@@ -1,13 +0,0 @@
const express = require('express');
module.exports = ({ initialDataController }) => {
const router = express.Router();
const controller = {
getInitialData: (req, res, next) => initialDataController.getInitialData(req, res, next),
};
router.get('/', controller.getInitialData);
return router;
};
+4
View File
@@ -6,9 +6,13 @@ module.exports = ({ usersController }) => {
const router = express.Router(); const router = express.Router();
const controller = { const controller = {
getCurrentUser: (req, res, next) => usersController.getCurrentUser(req, res, next),
updateCurrentUser: (req, res, next) => usersController.updateCurrentUser(req, res, next), updateCurrentUser: (req, res, next) => usersController.updateCurrentUser(req, res, next),
searchUsers: (req, res, next) => usersController.searchUsers(req, res, next),
}; };
router.get('/search', controller.searchUsers);
router.get('/current', controller.getCurrentUser);
router.put('/current', validate(updateUserSchema), controller.updateCurrentUser); router.put('/current', validate(updateUserSchema), controller.updateCurrentUser);
return router; return router;
+7 -7
View File
@@ -4,8 +4,7 @@ const cors = require('cors');
const config = require('./config'); const config = require('./config');
const setupContainer = require('./di/container'); const setupContainer = require('./di/container');
const errorHandler = require('./middleware/errorHandler'); const errorHandler = require('./middleware/errorHandler');
const mockAuthMiddleware = require('./middleware/auth.mock'); const keycloakAuthMiddleware = require('./middleware/auth.keycloak');
const { seedDatabase } = require('./seed');
const legacyApiRoutes = require('./routes/api'); const legacyApiRoutes = require('./routes/api');
@@ -21,20 +20,22 @@ const sequelize = container.resolve('sequelize');
mongoose mongoose
.connect(config.database.mongo.uri, config.database.mongo.options) .connect(config.database.mongo.uri, config.database.mongo.options)
.then(() => console.log('Connected to MongoDB')) .then(() => console.log('Connected to MongoDB'))
.catch((err) => { .catch(err => {
console.error('MongoDB connection error:', err); console.error('MongoDB connection error:', err);
process.exit(1); process.exit(1);
}); });
app.use('/api', mockAuthMiddleware(sequelize)); app.use('/api', keycloakAuthMiddleware(sequelize));
app.use('/api/legacy', legacyApiRoutes(sequelize)); app.use('/api/legacy', legacyApiRoutes(sequelize));
const v1Routes = require('./routes/v1'); const v1Routes = require('./routes/v1');
app.use('/api/v1', v1Routes(container)); app.use('/api/v1', v1Routes(container));
app.use('/api', v1Routes(container)); app.use('/api', v1Routes(container));
const healthRoutes = require('./routes/health.routes'); const healthRoutes = require('./routes/health.routes');
app.use('/', healthRoutes({ sequelize, mongoose })); app.use('/', healthRoutes({ sequelize, mongoose }));
app.use(errorHandler); app.use(errorHandler);
@@ -43,11 +44,10 @@ sequelize
.authenticate() .authenticate()
.then(() => { .then(() => {
console.log('Connected to PostgreSQL'); console.log('Connected to PostgreSQL');
return sequelize.sync({ force: true }); return sequelize.sync({ alter: true });
}) })
.then(() => { .then(() => {
console.log('PostgreSQL models synchronized'); console.log('PostgreSQL models synchronized');
return seedDatabase(sequelize);
}) })
.then(() => { .then(() => {
app.listen(config.port, () => { app.listen(config.port, () => {
@@ -58,7 +58,7 @@ sequelize
console.log(`Health check available at /health`); console.log(`Health check available at /health`);
}); });
}) })
.catch((err) => { .catch(err => {
console.error('PostgreSQL connection or sync error:', err); console.error('PostgreSQL connection or sync error:', err);
process.exit(1); process.exit(1);
}); });
+8 -19
View File
@@ -1,13 +1,8 @@
const { ValidationError, ConflictError } = require('../shared/errors');
const { Op } = require('sequelize'); const { Op } = require('sequelize');
const { ValidationError, ConflictError } = require('../shared/errors');
class FriendsService { class FriendsService {
constructor({ constructor({ friendshipsRepository, friendRequestsRepository, usersRepository, sequelize }) {
friendshipsRepository,
friendRequestsRepository,
usersRepository,
sequelize,
}) {
this.friendshipsRepository = friendshipsRepository; this.friendshipsRepository = friendshipsRepository;
this.friendRequestsRepository = friendRequestsRepository; this.friendRequestsRepository = friendRequestsRepository;
this.usersRepository = usersRepository; this.usersRepository = usersRepository;
@@ -33,9 +28,7 @@ class FriendsService {
}); });
if (!toUser) { if (!toUser) {
throw new ValidationError( throw new ValidationError('User not found with provided name or email');
'User not found with provided name or email'
);
} }
} }
@@ -43,18 +36,12 @@ class FriendsService {
throw new ValidationError('Cannot send friend request to yourself'); throw new ValidationError('Cannot send friend request to yourself');
} }
const friendshipExists = await this.friendshipsRepository.exists( const friendshipExists = await this.friendshipsRepository.exists(fromUserId, toUser.id);
fromUserId,
toUser.id
);
if (friendshipExists) { if (friendshipExists) {
throw new ConflictError('You are already friends with this user'); throw new ConflictError('You are already friends with this user');
} }
const existingRequest = await this.friendRequestsRepository.findExisting( const existingRequest = await this.friendRequestsRepository.findExisting(fromUserId, toUser.id);
fromUserId,
toUser.id
);
if (existingRequest) { if (existingRequest) {
throw new ConflictError('Friend request already exists'); throw new ConflictError('Friend request already exists');
} }
@@ -73,6 +60,8 @@ class FriendsService {
throw new ValidationError('This request has already been processed'); throw new ValidationError('This request has already been processed');
} }
const newFriend = request.fromUser;
const t = await this.sequelize.transaction(); const t = await this.sequelize.transaction();
try { try {
await this.friendRequestsRepository.updateStatus(requestId, 'ACCEPTED'); await this.friendRequestsRepository.updateStatus(requestId, 'ACCEPTED');
@@ -80,7 +69,7 @@ class FriendsService {
await this.friendRequestsRepository.delete(requestId); await this.friendRequestsRepository.delete(requestId);
await t.commit(); await t.commit();
return { message: 'Friend request accepted successfully' }; return { message: 'Friend request accepted successfully', newFriend };
} catch (error) { } catch (error) {
await t.rollback(); await t.rollback();
throw error; throw error;
+8 -15
View File
@@ -6,39 +6,32 @@ class GroupsService {
this.usersRepository = usersRepository; this.usersRepository = usersRepository;
} }
async getAllGroups() { async getAllGroups(userId) {
return await this.groupsRepository.findAll({ return await this.groupsRepository.findByUserId(userId);
include: [
{
model: this.usersRepository.User,
as: 'members',
through: { attributes: [] },
},
],
});
} }
async getGroupById(groupId) { async getGroupById(groupId) {
return await this.groupsRepository.findById(groupId, true); return await this.groupsRepository.findById(groupId, true);
} }
async createGroup(groupData) { async createGroup(groupData, creatorId) {
const { name, members } = groupData; const { name, members } = groupData;
if (!name || name.trim() === '') { if (!name || name.trim() === '') {
throw new ValidationError('Group name is required'); throw new ValidationError('Group name is required');
} }
if (!members || !Array.isArray(members) || members.length === 0) { const memberList = Array.isArray(members) ? [...members] : [];
throw new ValidationError('At least one member is required'); if (!memberList.includes(creatorId)) {
memberList.push(creatorId);
} }
for (const userId of members) { for (const userId of memberList) {
await this.usersRepository.findById(userId); await this.usersRepository.findById(userId);
} }
const group = await this.groupsRepository.create({ name }); const group = await this.groupsRepository.create({ name });
return await this.groupsRepository.addMembers(group.id, members); return await this.groupsRepository.addMembers(group.id, memberList);
} }
async updateGroup(groupId, updateData) { async updateGroup(groupId, updateData) {
+15
View File
@@ -13,6 +13,21 @@ class UsersService {
return await this.usersRepository.findById(id); return await this.usersRepository.findById(id);
} }
async getCurrentUserWithRank(id) {
const user = await this.usersRepository.findById(id);
const userJson = user.toJSON ? user.toJSON() : { ...user };
const totalUsers = await this.usersRepository.count();
const usersAbove = await this.usersRepository.countWithScoreAbove(userJson.score, id);
const rankPercentile = totalUsers > 1
? Math.max(1, Math.round((usersAbove / totalUsers) * 100))
: 1;
return { ...userJson, rankPercentile };
}
async searchUsers(query, excludeUserId) {
return await this.usersRepository.search(query, excludeUserId);
}
async updateUserProfile(id, updateData) { async updateUserProfile(id, updateData) {
const { name, email, avatarUrl } = updateData; const { name, email, avatarUrl } = updateData;
@@ -10,10 +10,16 @@ const createGroupSchema = Joi.object({
'array.min': 'At least one member is required', 'array.min': 'At least one member is required',
'array.base': 'Members must be an array', 'array.base': 'Members must be an array',
}), }),
description: Joi.string().trim().max(500).optional(),
isPrivate: Joi.boolean().optional(),
avatarUrl: Joi.string().uri().optional(),
}); });
const updateGroupSchema = Joi.object({ const updateGroupSchema = Joi.object({
name: Joi.string().trim().min(1).max(100).optional(), name: Joi.string().trim().min(1).max(100).optional(),
description: Joi.string().trim().max(500).optional(),
isPrivate: Joi.boolean().optional(),
avatarUrl: Joi.string().uri().optional(),
}); });
const addMembersSchema = Joi.object({ const addMembersSchema = Joi.object({
+99 -30
View File
@@ -1,37 +1,45 @@
version: '3.8' version: '3.8'
# Stack di produzione per la VPS, pensato per stare dietro a Caddy che gira
# su un CT Proxmox separato (routing per path sullo stesso dominio: "/" al
# frontend, "/api" al backend, "/auth" a Keycloak - vedi Caddyfile.example).
#
# Le variabili sono lette da un file ".env" nella stessa cartella
# (copia .env.example -> .env e compila i valori prima di avviare lo stack).
#
# Solo backend, frontend e keycloak pubblicano una porta sull'host (quella
# che deve raggiungere Caddy): postgres, mongodb e keycloak-db restano
# raggiungibili solo dalla rete Docker interna.
services: services:
# PostgreSQL Database # PostgreSQL Database (dati app)
postgres: postgres:
image: postgres:15-alpine image: postgres:15-alpine
container_name: scommetti-postgres container_name: scommetti-postgres
environment: environment:
POSTGRES_DB: amici_scommesse POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: admin POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: admin POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
ports:
- "5432:5432"
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
- ./backend-node/init.sql:/docker-entrypoint-initdb.d/init.sql - ./backend-node/init.sql:/docker-entrypoint-initdb.d/init.sql
networks: networks:
- scommetti-network - scommetti-network
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U admin -d amici_scommesse"] test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
restart: unless-stopped
# MongoDB Database # MongoDB Database (bets)
mongodb: mongodb:
image: mongo:7 image: mongo:7
container_name: scommetti-mongodb container_name: scommetti-mongodb
environment: environment:
MONGO_INITDB_ROOT_USERNAME: root MONGO_INITDB_ROOT_USERNAME: ${MONGO_INITDB_ROOT_USERNAME}
MONGO_INITDB_ROOT_PASSWORD: root@servovio MONGO_INITDB_ROOT_PASSWORD: ${MONGO_INITDB_ROOT_PASSWORD}
MONGO_INITDB_DATABASE: amici-scommesse MONGO_INITDB_DATABASE: ${MONGO_INITDB_DATABASE}
ports:
- "27017:27017"
volumes: volumes:
- mongodb_data:/data/db - mongodb_data:/data/db
networks: networks:
@@ -41,52 +49,111 @@ services:
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
restart: unless-stopped
# Keycloak Database
keycloak-db:
image: postgres:15-alpine
container_name: scommetti-keycloak-db
environment:
POSTGRES_DB: ${KEYCLOAK_DB}
POSTGRES_USER: ${KEYCLOAK_DB_USER}
POSTGRES_PASSWORD: ${KEYCLOAK_DB_PASSWORD}
volumes:
- keycloak_db_data:/var/lib/postgresql/data
networks:
- scommetti-network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${KEYCLOAK_DB_USER} -d ${KEYCLOAK_DB}"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
# Keycloak Auth Server
keycloak:
image: quay.io/keycloak/keycloak:23.0.0
container_name: scommetti-keycloak
command: start --import-realm
environment:
KC_DB: postgres
KC_DB_URL: jdbc:postgresql://keycloak-db:5432/${KEYCLOAK_DB}
KC_DB_USERNAME: ${KEYCLOAK_DB_USER}
KC_DB_PASSWORD: ${KEYCLOAK_DB_PASSWORD}
KEYCLOAK_ADMIN: ${KEYCLOAK_ADMIN}
KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD}
KC_HEALTH_ENABLED: "true"
KC_METRICS_ENABLED: "true"
KC_HTTP_ENABLED: "true"
# Keycloak è raggiunto tramite Caddy su https://${DOMAIN}/auth/*: gli
# diciamo di servire tutto sotto /auth e di fidarsi degli header
# X-Forwarded-* impostati dal proxy (TLS termina su Caddy, "edge").
KC_HTTP_RELATIVE_PATH: /auth
KC_HOSTNAME: ${DOMAIN}
KC_HOSTNAME_PATH: /auth
KC_HOSTNAME_STRICT: "true"
KC_HOSTNAME_STRICT_HTTPS: "true"
KC_PROXY: edge
ports:
- "${BIND_IP}:${KEYCLOAK_PORT}:8080"
volumes:
- ./keycloak/realm-export.json:/opt/keycloak/data/import/realm.json:ro
- ./keycloak/themes/scommettiapp:/opt/keycloak/themes/scommettiapp:ro
depends_on:
keycloak-db:
condition: service_healthy
networks:
- scommetti-network
healthcheck:
# Con KC_HTTP_RELATIVE_PATH=/auth l'health endpoint si sposta sotto
# /auth/health/ready (verificato: /health/ready da solo risponde 404).
test: ["CMD-SHELL", "exec 3<>/dev/tcp/localhost/8080 && printf 'GET /auth/health/ready HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3 && timeout 5 cat <&3 | grep -q 'UP'"]
interval: 15s
timeout: 15s
retries: 10
start_period: 90s
restart: unless-stopped
# Backend Node.js # Backend Node.js
backend: backend:
build: build:
context: ./backend-node context: ./backend-node
dockerfile: Dockerfile dockerfile: Dockerfile
target: production
container_name: scommetti-backend container_name: scommetti-backend
environment: environment:
NODE_ENV: development NODE_ENV: ${NODE_ENV}
PORT: 3000 PORT: 3000
POSTGRES_URI: postgres://admin:admin@postgres:5432/amici_scommesse POSTGRES_URI: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
MONGO_URI: mongodb://mongodb:27017/amici-scommesse MONGO_URI: mongodb://${MONGO_INITDB_ROOT_USERNAME}:${MONGO_INITDB_ROOT_PASSWORD}@mongodb:27017/${MONGO_INITDB_DATABASE}?authSource=admin
KEYCLOAK_JWKS_URI: http://keycloak:8080/auth/realms/scommettiapp/protocol/openid-connect/certs
ports: ports:
- "3000:3000" - "${BIND_IP}:${BACKEND_PORT}:3000"
volumes:
- ./backend-node:/app
- /app/node_modules
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
mongodb: mongodb:
condition: service_healthy condition: service_healthy
keycloak:
condition: service_healthy
networks: networks:
- scommetti-network - scommetti-network
command: npm start
restart: unless-stopped restart: unless-stopped
# Frontend Angular # Frontend Angular (build statica servita da nginx)
frontend: frontend:
build: build:
context: ./frontend-angular context: ./frontend-angular
dockerfile: Dockerfile dockerfile: Dockerfile
target: production
container_name: scommetti-frontend container_name: scommetti-frontend
environment:
NODE_ENV: development
ports: ports:
- "4200:4200" - "${BIND_IP}:${FRONTEND_PORT}:80"
volumes:
- ./frontend-angular:/app
- /app/node_modules
- /app/.angular
depends_on: depends_on:
- backend - backend
- keycloak
networks: networks:
- scommetti-network - scommetti-network
command: npm run dev
restart: unless-stopped restart: unless-stopped
volumes: volumes:
@@ -94,6 +161,8 @@ volumes:
driver: local driver: local
mongodb_data: mongodb_data:
driver: local driver: local
keycloak_db_data:
driver: local
networks: networks:
scommetti-network: scommetti-network:
+400
View File
@@ -0,0 +1,400 @@
# Changelog - Frontend Angular
Tracciamento modifiche specifiche del frontend Angular.
---
## ✅ Completato
### Setup Iniziale
- [x] Inizializzazione progetto Angular 20
- [x] Configurazione Vite come bundler
- [x] Configurazione Tailwind CSS
- [x] Setup TypeScript 5.8
- [x] Configurazione RxJS 7.8
### Architettura & Struttura
#### Servizi
- [x] **ApiService** (`services/api.service.ts`)
- Comunicazione HTTP con backend
- Base URL configurabile
- Metodi per tutte le entità (users, groups, bets, friends)
- Gestione errori HTTP
- [x] **DataService** (`services/data.service.ts`)
- State management con RxJS signals
- Cache dati utente corrente
- Cache amici, gruppi, scommesse
- Metodi per refresh dati
- Observable streams per reactive updates
#### Modelli TypeScript
- [x] **Bet Model** (`models/bet.model.ts`)
- Interface Bet completa
- Enum BetStatus (ACTIVE, AWAITING_VALIDATION, COMPLETED)
- Enum ParticipantRole (PARTICIPANT, OBSERVER)
- Type BetParticipant
- [x] **User Model** (inline nei servizi per ora)
- [x] **Group Model** (inline nei servizi per ora)
### Componenti Implementati
#### App Component
- [x] **AppComponent** (`app.component.ts`)
- Layout principale con navigation bar
- Gestione routing tra viste con signal
- Bottom navigation responsive
- 5 sezioni: Home, Scopri, Crea Scommessa, Gruppi, Profilo
- FAB centrale per creazione scommessa
#### Home Section
- [x] **HomeComponent** (`components/home/home.component.ts`)
- Dashboard principale
- Visualizzazione scommesse attive/passate
- Toggle tra filtri temporali e per gruppo
- Integrazione con sotto-componenti
- [x] **BetsByTimeComponent** (`components/home/bets-by-time/bets-by-time.component.ts`)
- Filtro scommesse per periodo temporale
- Periodi: Oggi, Questa Settimana, Questo Mese
- Indicatore visuale periodo selezionato
- Fetch dati da API con filtri
- [x] **BetsByGroupComponent** (`components/home/bets-by-group/bets-by-group.component.ts`)
- Filtro scommesse per gruppo
- Selezione gruppo da dropdown
- Opzione "Tutti i Gruppi"
- Fetch dati da API per gruppo specifico
#### Discover Section
- [x] **DiscoverComponent** (`components/discover/discover.component.ts`)
- Esplorazione scommesse pubbliche
- Lista scommesse scopribili
- Possibilità di entrare come observer
#### Create Bet Section
- [x] **CreateBetComponent** (`components/create-bet/create-bet.component.ts`)
- Form creazione nuova scommessa
- Campi: titolo, descrizione, posta in gioco, data fine, gruppo, visibilità
- Validazione form
- Event emitter per chiusura
- Integrazione con API
#### Groups Section
- [x] **GroupsComponent** (`components/groups/groups.component.ts`)
- Lista tutti i gruppi dell'utente
- Navigazione verso dettaglio gruppo
- Creazione nuovo gruppo
- Badge contatore membri
- [x] **GroupDetailComponent** (`components/groups/group-detail/group-detail.component.ts`)
- Dettaglio singolo gruppo
- Lista membri con avatar
- Aggiunta membri al gruppo
- Rimozione membri (se admin)
- Eliminazione gruppo
#### Profile Section
- [x] **ProfileComponent** (`components/profile/profile.component.ts`)
- Visualizzazione profilo utente
- Avatar, nome, email, punteggio
- Statistiche personali
- Lista richieste di amicizia pending
- Bottone modifica profilo
- [x] **EditProfileComponent** (`components/profile/edit-profile/edit-profile.component.ts`)
- Form modifica dati profilo
- Campi: nome, email, avatar URL
- Validazione input
- Save/Cancel actions
#### Shared Components
- [x] **BetCardComponent** (`components/bet-card/bet-card.component.ts`)
- Card riutilizzabile per visualizzare scommesse
- Badge stato scommessa (Attiva, In Attesa, Completata)
- Informazioni partecipanti con avatar
- Posta in gioco
- Date inizio/fine
- Responsive design
### UI/UX
#### Design System
- [x] Tema dark con palette:
- Background: slate-900
- Cards: slate-800
- Accenti: cyan-400/cyan-500
- Testo: white/slate-300
- [x] Tailwind CSS utilities
- [x] Responsive layout mobile-first
- [x] Icone SVG per navigation
#### Navigation
- [x] Bottom navigation bar sticky
- [x] 5 tab con icone e label
- [x] Indicatore visuale tab attiva (color cyan)
- [x] FAB centrale per azione primaria (Crea Scommessa)
- [x] Hover states e transitions
#### Interazioni
- [x] Click handlers per navigazione
- [x] Form submissions
- [x] Toggle filters
- [x] Dropdown selects
- [x] Event emitters tra parent/child components
### Configurazione
- [x] **package.json** - Dipendenze e scripts
- [x] **angular.json** - Configurazione Angular CLI
- [x] **tsconfig.json** - TypeScript configuration
- [x] **tailwind.config.js** (implicito con Tailwind)
- [x] **vite.config** (gestito da Angular)
---
## 📋 Da Fare
### Funzionalità Core Mancanti
#### Scommesse
- [ ] Visualizzazione dettaglio scommessa completo
- Modal o pagina dedicata
- Cronologia eventi
- Commenti (se implementati)
- [ ] Modifica scommessa esistente
- [ ] Eliminazione scommessa con conferma
- [ ] Dichiarazione vincitore
- [ ] Sistema di votazione vincitore
- [ ] Join scommessa pubblica come observer
- [ ] Leave scommessa
- [ ] Invita amici a scommessa
#### Profilo & Amici
- [ ] Ricerca utenti
- [ ] Invio richiesta di amicizia (UI)
- [ ] Accetta/rifiuta richiesta amicizia (UI)
- [ ] Lista amici visualizzata nel profilo
- [ ] Visualizzazione profilo altri utenti
- [ ] Statistiche avanzate profilo
- Win rate
- Streak
- Classifica generale
- [ ] Notifiche
#### Gruppi
- [ ] Modifica info gruppo
- [ ] Invita utenti al gruppo
- [ ] Gestione permessi gruppo (admin/membro)
- [ ] Statistiche gruppo
- [ ] Ricerca membri
- [ ] Leave gruppo
### UX Improvements
#### Stati & Feedback
- [ ] Loading states per tutti i componenti
- Skeleton loaders
- Spinners
- Progress indicators
- [ ] Error handling UI
- Toast notifications
- Error messages inline
- Retry mechanisms
- [ ] Empty states
- Nessuna scommessa
- Nessun amico
- Nessun gruppo
- [ ] Success confirmations
- Toast per azioni completate
- Feedback visuale
#### Animazioni & Transizioni
- [ ] Animazioni transizione tra viste
- [ ] Micro-interazioni su bottoni
- [ ] Slide/fade effects per modal
- [ ] Loading animations
- [ ] Skeleton loading per cards
#### Interattività
- [ ] Pull-to-refresh
- [ ] Infinite scroll per liste lunghe
- [ ] Swipe gestures (elimina, archivia)
- [ ] Drag and drop (riordino)
- [ ] Conferme azioni critiche
- Modal di conferma eliminazione
- Double-tap protection
#### Filtri & Ricerca
- [ ] Ricerca scommesse per titolo
- [ ] Filtri avanzati scommesse
- Per stato
- Per partecipanti
- Per data
- Per posta in gioco
- [ ] Ordinamento scommesse
- Per data
- Per popolarità
- Per punteggio
- [ ] Salvataggio preferenze filtri
### Componenti da Creare
- [ ] **BetDetailModal/Page** - Dettaglio completo scommessa
- [ ] **ToastNotification** - Notifiche toast
- [ ] **ConfirmDialog** - Dialog conferma generica
- [ ] **SearchBar** - Barra di ricerca riutilizzabile
- [ ] **UserCard** - Card utente riutilizzabile
- [ ] **EmptyState** - Componente empty state generico
- [ ] **LoadingSpinner** - Spinner riutilizzabile
- [ ] **AvatarUpload** - Upload avatar con preview
- [ ] **DatePicker** - Selettore date customizzato
- [ ] **FriendsList** - Lista amici con azioni
- [ ] **NotificationsList** - Lista notifiche
- [ ] **StatsCard** - Card statistiche riutilizzabile
### Routing & Navigation
- [ ] Implementare Angular Router
- Route per ogni vista
- Route parametriche (es. /group/:id)
- Query parameters per filtri
- Route guards
- [ ] Deep linking
- [ ] Browser back button handling
- [ ] Route transition animations
### State Management
- [ ] Migliorare DataService
- Gestione stato più robusta
- Cache invalidation strategy
- Optimistic updates
- Rollback on error
- [ ] Considerare NgRx o Signals API avanzate
- [ ] Persistent state (localStorage)
- [ ] Sync state across tabs
### Forms & Validation
- [ ] Reactive forms everywhere
- [ ] Validatori custom
- [ ] Error messages user-friendly
- [ ] Form dirty/pristine tracking
- [ ] Unsaved changes warning
- [ ] Auto-save drafts
### Testing
- [ ] Unit test per componenti
- [ ] Unit test per servizi
- [ ] Integration test
- [ ] E2E test con Cypress/Playwright
- [ ] Coverage almeno 80%
- [ ] Visual regression testing
### Performance
- [ ] Lazy loading components
- [ ] Virtual scrolling per liste lunghe
- [ ] Image lazy loading
- [ ] OnPush change detection
- [ ] Memoization expensive computations
- [ ] Bundle size optimization
- [ ] Tree shaking
- [ ] Code splitting
### Accessibilità (a11y)
- [ ] ARIA labels
- [ ] Keyboard navigation
- [ ] Focus management
- [ ] Screen reader support
- [ ] Contrast ratios WCAG AA
- [ ] Semantic HTML
### PWA Features
- [ ] Service Worker
- [ ] Offline mode
- [ ] App manifest
- [ ] Install prompt
- [ ] Push notifications
- [ ] Background sync
### Internazionalizzazione
- [ ] i18n setup (Angular i18n)
- [ ] Traduzioni IT/EN
- [ ] Date/number formatting locale-aware
- [ ] RTL support (se necessario)
### DevOps & Build
- [ ] Environment files (dev, staging, prod)
- [ ] Build optimization per production
- [ ] Source maps
- [ ] Linting (ESLint)
- [ ] Formatting (Prettier)
- [ ] Pre-commit hooks (Husky)
- [ ] CI/CD pipeline
### Monitoring & Analytics
- [ ] Error tracking (Sentry)
- [ ] Analytics (Google Analytics / Plausible)
- [ ] Performance monitoring
- [ ] User behavior tracking
---
## 🐛 Bug Conosciuti
- [ ] Nessun handling errori API in caso di network failure
- [ ] Refresh dati non automatico (solo manuale al mount)
- [ ] Possibili race conditions su multiple API calls
---
## 🔧 Miglioramenti Tecnici Proposti
- [ ] Separare models in file dedicati
- [ ] Creare barrel exports per organizzazione
- [ ] Introdurre design patterns (Repository, Facade)
- [ ] Standardizzare naming conventions
- [ ] Constants file per magic strings
- [ ] Utility functions file
- [ ] Typed HTTP responses
- [ ] Error types customizzati
- [ ] Logging service
- [ ] Configuration service
---
## Note Tecniche
### Dipendenze Principali
- **@angular/core**: ^20.3.0
- **@angular/common**: ^20.3.0
- **@angular/platform-browser**: ^20.3.0
- **rxjs**: ^7.8.2
- **tailwindcss**: latest
- **typescript**: ~5.8.2
- **vite**: ^6.2.0
### Scripts NPM
```bash
npm run dev # Avvia dev server (porta 4200)
npm run build # Build production
npm run preview # Preview build production
```
### Porte
- Dev server: `4200`
- API backend: `3000`
### Browser Target
- Modern browsers (Chrome, Firefox, Safari, Edge)
- ES2022+ support required
+20
View File
@@ -0,0 +1,20 @@
# Frontend Angular Dockerfile
FROM node:20-alpine
# Set working directory
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm install
# Copy application code
COPY . .
# Expose port
EXPOSE 4200
# Start development server
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
+20
View File
@@ -0,0 +1,20 @@
<div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
</div>
# Run and deploy your AI Studio app
This contains everything you need to run your app locally.
View your app in AI Studio: https://ai.studio/apps/drive/1LXZWZLxW6p8pFAQ_ECq5pcD0utESimfc
## Run Locally
**Prerequisites:** Node.js
1. Install dependencies:
`npm install`
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
3. Run the app:
`npm run dev`
+52
View File
@@ -0,0 +1,52 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "",
"projects": {
"app": {
"projectType": "application",
"root": "",
"sourceRoot": "./",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"outputPath": {
"base": "./dist",
"browser": "."
},
"browser": "index.tsx",
"tsConfig": "tsconfig.json"
},
"configurations": {
"production": {
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular/build:dev-server",
"options": {
"port": 4200
},
"configurations": {
"production": {
"buildTarget": "app:build:production"
},
"development": {
"buildTarget": "app:build:development"
}
},
"defaultConfiguration": "development"
}
}
}
}
}
+63
View File
@@ -0,0 +1,63 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="utf-8" />
<title>Amici Scommesse</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
<script src="https://cdn.tailwindcss.com"></script>
<style>
/* Custom scrollbar for a better look */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: #1e293b; /* slate-800 */
}
::-webkit-scrollbar-thumb {
background: #475569; /* slate-600 */
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #64748b; /* slate-500 */
}
/* For filter chips horizontal scroll */
.hide-scrollbar::-webkit-scrollbar {
display: none;
}
.hide-scrollbar {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
/* Simple fade-in animation */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-fade-in {
animation: fadeIn 0.3s ease-out forwards;
}
</style>
<script type="importmap">
{
"imports": {
"rxjs": "https://aistudiocdn.com/rxjs@^7.8.2?conditions=es2015",
"rxjs/operators": "https://aistudiocdn.com/rxjs@^7.8.2/operators?conditions=es2015",
"rxjs/ajax": "https://aistudiocdn.com/rxjs@^7.8.2/ajax?conditions=es2015",
"rxjs/webSocket": "https://aistudiocdn.com/rxjs@^7.8.2/webSocket?conditions=es2015",
"rxjs/testing": "https://aistudiocdn.com/rxjs@^7.8.2/testing?conditions=es2015",
"rxjs/fetch": "https://aistudiocdn.com/rxjs@^7.8.2/fetch?conditions=es2015",
"@angular/compiler": "https://next.esm.sh/@angular/compiler@^20.3.4?external=rxjs",
"@angular/platform-browser": "https://next.esm.sh/@angular/platform-browser@^20.3.4?external=rxjs",
"@angular/common": "https://next.esm.sh/@angular/common@^20.3.4?external=rxjs",
"@angular/core": "https://next.esm.sh/@angular/core@^20.3.4?external=rxjs",
"@angular/common/http": "https://next.esm.sh/@angular/common@^20.3.4/http?external=rxjs"
}
}
</script>
</head>
<body class="bg-slate-900 text-slate-100 antialiased">
<app-root></app-root>
</body>
</html>
+36
View File
@@ -0,0 +1,36 @@
import '@angular/compiler';
import { bootstrapApplication } from '@angular/platform-browser';
import { provideZonelessChangeDetection } from '@angular/core';
import { provideHttpClient, withFetch, withInterceptors } from '@angular/common/http';
import Keycloak from 'keycloak-js';
import { AppComponent } from './src/app.component';
import { KEYCLOAK_INSTANCE } from './src/core/tokens/keycloak.token';
import { authInterceptor } from './src/core/interceptors/auth.interceptor';
const keycloak = new Keycloak({
url: 'http://localhost:8080',
realm: 'scommettiapp',
clientId: 'scommetti-frontend',
});
keycloak.init({
onLoad: 'login-required',
checkLoginIframe: false,
pkceMethod: 'S256',
}).then(authenticated => {
if (!authenticated) {
keycloak.login();
return;
}
bootstrapApplication(AppComponent, {
providers: [
provideZonelessChangeDetection(),
provideHttpClient(withFetch(), withInterceptors([authInterceptor])),
{ provide: KEYCLOAK_INSTANCE, useValue: keycloak },
],
}).catch(err => console.error(err));
}).catch(err => {
console.error('Keycloak initialization failed:', err);
});
+5
View File
@@ -0,0 +1,5 @@
{
"name": "Amici Scommesse",
"description": "Un'app per creare e gestire scommesse tra amici. Tieni traccia delle scommesse, crea gruppi, e partecipa a sfide pubbliche per guadagnare punti gloria.",
"requestFramePermissions": []
}
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
{
"name": "amici-scommesse",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "ng serve",
"build": "ng build",
"preview": "ng serve --configuration=production"
},
"dependencies": {
"rxjs": "^7.8.2",
"@angular/compiler": "^20.3.0",
"@angular/platform-browser": "^20.3.0",
"@angular/common": "^20.3.0",
"@angular/core": "^20.3.0",
"@angular/build": "^20.3.0",
"@angular/cli": "^20.3.0",
"@angular/compiler-cli": "^20.3.0",
"keycloak-js": "^24.0.0",
"tailwindcss": "latest"
},
"devDependencies": {
"@types/node": "^22.14.0",
"typescript": "~5.8.2",
"vite": "^6.2.0"
}
}
@@ -0,0 +1,55 @@
<div class="h-screen w-screen flex flex-col">
<main class="flex-grow overflow-hidden">
@switch (currentView()) {
@case ('home') { <app-home></app-home> }
@case ('discover') { <app-discover></app-discover> }
@case ('create-bet') { <app-create-bet (close)="onBetActionClosed()"></app-create-bet> }
@case ('groups') { <app-groups></app-groups> }
@case ('profile') { <app-profile></app-profile> }
}
</main>
<!-- Navigation -->
<nav class="flex-shrink-0 bg-slate-800 border-t border-slate-700">
<div class="flex justify-around items-center h-20">
<!-- Nav Item: Home -->
<button (click)="setView('home')" [class]="'flex flex-col items-center justify-center w-full transition-colors ' + (currentView() === 'home' ? 'text-cyan-400' : 'text-slate-400 hover:text-white')">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
<span class="text-xs mt-1">Home</span>
</button>
<!-- Nav Item: Discover -->
<button (click)="setView('discover')" [class]="'flex flex-col items-center justify-center w-full transition-colors ' + (currentView() === 'discover' ? 'text-cyan-400' : 'text-slate-400 hover:text-white')">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M4 2a2 2 0 00-2 2v12a2 2 0 002 2h12a2 2 0 002-2V4a2 2 0 00-2-2H4zm.293 8.293a1 1 0 011.414 0L8 12.586l2.293-2.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
<span class="text-xs mt-1">Scopri</span>
</button>
<!-- Nav Item: Add Bet (Central) -->
<button (click)="setView('create-bet')" class="w-20 h-20 -mt-10 bg-cyan-500 rounded-full flex items-center justify-center text-white shadow-lg hover:bg-cyan-600 transition-transform transform hover:scale-110">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
</svg>
</button>
<!-- Nav Item: Groups -->
<button (click)="setView('groups')" [class]="'flex flex-col items-center justify-center w-full transition-colors ' + (currentView() === 'groups' ? 'text-cyan-400' : 'text-slate-400 hover:text-white')">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.653-.124-1.282-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.653.124-1.282.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
<span class="text-xs mt-1">Gruppi</span>
</button>
<!-- Nav Item: Profile -->
<button (click)="setView('profile')" [class]="'flex flex-col items-center justify-center w-full transition-colors ' + (currentView() === 'profile' ? 'text-cyan-400' : 'text-slate-400 hover:text-white')">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
<span class="text-xs mt-1">Profilo</span>
</button>
</div>
</nav>
</div>
+48
View File
@@ -0,0 +1,48 @@
import { Component, ChangeDetectionStrategy, signal, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { HomeComponent } from './features/home/home.component';
import { DiscoverComponent } from './features/discover/discover.component';
import { GroupsComponent } from './features/groups/groups.component';
import { ProfileComponent } from './features/profile/profile.component';
import { CreateBetComponent } from './features/bets/create-bet.component';
import { DataService } from './core/services/data.service';
type View = 'home' | 'discover' | 'groups' | 'profile' | 'create-bet';
@Component({
selector: 'app-root',
standalone: true,
imports: [
CommonModule,
HomeComponent,
DiscoverComponent,
GroupsComponent,
ProfileComponent,
CreateBetComponent,
],
templateUrl: './app.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppComponent {
private dataService = inject(DataService);
currentView = signal<View>('home');
private previousView = signal<View>('home');
constructor() {
this.dataService.loadCurrentUser();
}
setView(view: View) {
if (view === 'create-bet') {
this.previousView.set(this.currentView());
this.currentView.set('create-bet');
} else {
this.currentView.set(view);
}
}
onBetActionClosed() {
this.currentView.set(this.previousView());
}
}
@@ -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,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,50 @@
import { User } from './user.model';
export enum BetRole {
Participant = 'PARTICIPANT',
Observer = 'OBSERVER',
}
export interface BetParticipant {
user: User | string; // Can be user object or ID depending on context
role: BetRole;
}
export enum BetStatus {
Active = 'ACTIVE',
AwaitingValidation = 'AWAITING_VALIDATION',
Completed = 'COMPLETED',
Expired = 'EXPIRED',
}
export interface Bet {
id: string;
title: string;
description?: string | null;
stake: string;
participants: BetParticipant[];
status: BetStatus;
startDate: Date;
endDate: Date | null;
isPublic: boolean;
groupId?: string | null;
winner?: User | string | null;
}
export interface CreateBetRequest {
title: string;
description?: string;
stake: string;
participants: { user: string; role: string }[];
isPublic?: boolean;
groupId?: string | null;
}
export interface UpdateBetRequest {
title?: string;
description?: string;
stake?: string;
status?: BetStatus;
endDate?: string;
winner?: string | null;
}
@@ -0,0 +1,22 @@
import { User } from './user.model';
export enum FriendRequestStatus {
Pending = 'PENDING',
Accepted = 'ACCEPTED',
Rejected = 'REJECTED',
}
export interface FriendRequest {
id: string;
fromUserId: string;
toUserId: string;
status: FriendRequestStatus;
fromUser?: User;
toUser?: User;
createdAt: string;
updatedAt?: string;
}
export interface SendFriendRequestRequest {
identifier: string | number;
}
@@ -0,0 +1,20 @@
import { User } from './user.model';
export interface Group {
id: string;
name: string;
members: User[];
}
export interface CreateGroupRequest {
name: string;
members: string[]; // User IDs
}
export interface UpdateGroupRequest {
name?: string;
}
export interface AddMembersRequest {
userIds: string[];
}
@@ -0,0 +1,13 @@
export interface User {
id: string;
name: string;
email: string;
avatarUrl?: string;
score?: number;
}
export interface UpdateUserRequest {
name?: string;
email?: string;
avatarUrl?: string | null;
}
@@ -0,0 +1,65 @@
import { inject, Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Bet, CreateBetRequest, UpdateBetRequest } from '../../models/bet.model';
import { SuccessResponse } from '../../models/api-responses.model';
import { map } from 'rxjs/operators';
import { toDate } from '../../utils/type-guards';
function mapBet(bet: any): Bet {
return {
...bet,
startDate: toDate(bet.startDate)!,
endDate: toDate(bet.endDate)
};
}
@Injectable({
providedIn: 'root'
})
export class BetApiService {
private http = inject(HttpClient);
private readonly apiUrl = 'http://localhost:3000/api/v1/bets';
getUserBets(): Observable<Bet[]> {
return this.http.get<SuccessResponse<Bet[]>>(`${this.apiUrl}/user`).pipe(
map(res => res.data.map(mapBet))
);
}
getPublicBets(): Observable<Bet[]> {
return this.http.get<SuccessResponse<Bet[]>>(`${this.apiUrl}/public`).pipe(
map(res => res.data.map(mapBet))
);
}
getBets(): Observable<Bet[]> {
return this.http.get<SuccessResponse<Bet[]>>(this.apiUrl).pipe(
map(res => res.data.map(mapBet))
);
}
getBet(id: string): Observable<Bet> {
return this.http.get<SuccessResponse<Bet>>(`${this.apiUrl}/${id}`).pipe(
map(res => mapBet(res.data))
);
}
createBet(bet: CreateBetRequest): Observable<Bet> {
return this.http.post<SuccessResponse<Bet>>(this.apiUrl, bet).pipe(
map(res => mapBet(res.data))
);
}
updateBet(id: string, bet: UpdateBetRequest): Observable<Bet> {
return this.http.put<SuccessResponse<Bet>>(`${this.apiUrl}/${id}`, bet).pipe(
map(res => mapBet(res.data))
);
}
deleteBet(id: string): Observable<void> {
return this.http.delete<SuccessResponse<null>>(`${this.apiUrl}/${id}`).pipe(
map(() => undefined)
);
}
}
@@ -0,0 +1,47 @@
import { inject, Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { FriendRequest, SendFriendRequestRequest } from '../../models/friend-request.model';
import { User } from '../../models/user.model';
import { SuccessResponse } from '../../models/api-responses.model';
import { map } from 'rxjs/operators';
@Injectable({
providedIn: 'root'
})
export class FriendRequestApiService {
private http = inject(HttpClient);
private readonly apiUrl = 'http://localhost:3000/api/v1/friend-requests';
getFriends(): Observable<User[]> {
return this.http.get<SuccessResponse<User[]>>(this.apiUrl).pipe(
map(res => res.data)
);
}
getPendingRequests(): Observable<FriendRequest[]> {
return this.http.get<SuccessResponse<FriendRequest[]>>(`${this.apiUrl}/pending`).pipe(
map(res => res.data)
);
}
sendRequest(request: SendFriendRequestRequest): Observable<FriendRequest> {
return this.http.post<SuccessResponse<FriendRequest>>(this.apiUrl, request).pipe(
map(res => res.data)
);
}
acceptRequest(id: string): Observable<{ message: string; newFriend: User }> {
return this.http.post<SuccessResponse<{ message: string; newFriend: User }>>(`${this.apiUrl}/${id}/accept`, {}).pipe(
map(res => res.data)
);
}
cancelRequest(id: string): Observable<SuccessResponse<null>> {
return this.http.delete<SuccessResponse<null>>(`${this.apiUrl}/${id}`);
}
declineRequest(id: string): Observable<SuccessResponse<null>> {
return this.http.delete<SuccessResponse<null>>(`${this.apiUrl}/${id}`);
}
}
@@ -0,0 +1,56 @@
import { inject, Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Group, CreateGroupRequest, UpdateGroupRequest, AddMembersRequest } from '../../models/group.model';
import { SuccessResponse } from '../../models/api-responses.model';
import { map } from 'rxjs/operators';
@Injectable({
providedIn: 'root'
})
export class GroupApiService {
private http = inject(HttpClient);
private readonly apiUrl = 'http://localhost:3000/api/v1/groups';
getGroups(): Observable<Group[]> {
return this.http.get<SuccessResponse<Group[]>>(this.apiUrl).pipe(
map(res => res.data)
);
}
getGroup(id: string): Observable<Group> {
return this.http.get<SuccessResponse<Group>>(`${this.apiUrl}/${id}`).pipe(
map(res => res.data)
);
}
createGroup(group: CreateGroupRequest): Observable<Group> {
return this.http.post<SuccessResponse<Group>>(this.apiUrl, group).pipe(
map(res => res.data)
);
}
updateGroup(id: string, group: UpdateGroupRequest): Observable<Group> {
return this.http.put<SuccessResponse<Group>>(`${this.apiUrl}/${id}`, group).pipe(
map(res => res.data)
);
}
deleteGroup(id: string): Observable<void> {
return this.http.delete<SuccessResponse<null>>(`${this.apiUrl}/${id}`).pipe(
map(() => undefined)
);
}
addMembers(groupId: string, members: AddMembersRequest): Observable<Group> {
return this.http.put<SuccessResponse<Group>>(`${this.apiUrl}/${groupId}/members`, members).pipe(
map(res => res.data)
);
}
removeMember(groupId: string, userId: string): Observable<Group> {
return this.http.delete<SuccessResponse<Group>>(`${this.apiUrl}/${groupId}/members/${userId}`).pipe(
map(res => res.data)
);
}
}
@@ -0,0 +1,26 @@
import { inject, Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { User, UpdateUserRequest } from '../../models/user.model';
import { SuccessResponse } from '../../models/api-responses.model';
import { map } from 'rxjs/operators';
@Injectable({
providedIn: 'root'
})
export class UserApiService {
private http = inject(HttpClient);
private readonly apiUrl = 'http://localhost:3000/api/v1';
getCurrentUser(): Observable<User> {
return this.http.get<SuccessResponse<User>>(`${this.apiUrl}/users/current`).pipe(
map(res => res.data)
);
}
updateCurrentUser(user: UpdateUserRequest): Observable<User> {
return this.http.put<SuccessResponse<User>>(`${this.apiUrl}/users/current`, user).pipe(
map(res => res.data)
);
}
}
@@ -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,224 @@
import { Injectable, signal, computed, inject } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { Bet, BetParticipant, BetStatus } from '../models/bet.model';
import { User } from '../models/user.model';
import { Group } from '../models/group.model';
import { FriendRequest, FriendRequestStatus } from '../models/friend-request.model';
import { BetApiService } from './api/bet-api.service';
import { GroupApiService } from './api/group-api.service';
import { UserApiService } from './api/user-api.service';
import { FriendRequestApiService } from './api/friend-request-api.service';
@Injectable({
providedIn: 'root'
})
export class DataService {
private betApiService = inject(BetApiService);
private groupApiService = inject(GroupApiService);
private userApiService = inject(UserApiService);
private friendRequestApiService = inject(FriendRequestApiService);
private readonly _bets = signal<Bet[]>([]);
private readonly _groups = signal<Group[]>([]);
private readonly _friends = signal<User[]>([]);
private readonly _friendRequests = signal<FriendRequest[]>([]);
private readonly _publicBets = signal<Bet[]>([]);
readonly currentUser = signal<User | null>(null);
readonly friends = computed(() => this._friends());
readonly receivedFriendRequests = computed(() => {
return this._friendRequests().filter(req => req.toUser?.id === this.currentUser()?.id && req.status === FriendRequestStatus.Pending);
});
readonly sentFriendRequests = computed(() => {
return this._friendRequests().filter(req => req.fromUser?.id === this.currentUser()?.id && req.status === FriendRequestStatus.Pending);
});
readonly userGroups = computed(() => this._groups());
readonly getActiveBets = computed(() => {
return this._bets().filter(bet =>
(bet.status === BetStatus.Active || bet.status === BetStatus.AwaitingValidation)
);
});
readonly getPublicBets = computed(() => this._publicBets());
// --- Load methods: chiamati da ogni componente al momento della visualizzazione ---
async loadCurrentUser(): Promise<void> {
try {
const user = await firstValueFrom(this.userApiService.getCurrentUser());
this.currentUser.set(user);
} catch (error) {
console.error('Failed to load current user', error);
}
}
async loadUserBets(): Promise<void> {
try {
const bets = await firstValueFrom(this.betApiService.getUserBets());
this._bets.set(bets);
} catch (error) {
console.error('Failed to load user bets', error);
}
}
async loadPublicBets(): Promise<void> {
try {
const bets = await firstValueFrom(this.betApiService.getPublicBets());
this._publicBets.set(bets);
} catch (error) {
console.error('Failed to load public bets', error);
}
}
async loadFriends(): Promise<void> {
try {
const friends = await firstValueFrom(this.friendRequestApiService.getFriends());
this._friends.set(friends);
} catch (error) {
console.error('Failed to load friends', error);
}
}
async loadFriendRequests(): Promise<void> {
try {
const requests = await firstValueFrom(this.friendRequestApiService.getPendingRequests());
this._friendRequests.set(requests);
} catch (error) {
console.error('Failed to load friend requests', error);
}
}
async loadGroups(): Promise<void> {
try {
const groups = await firstValueFrom(this.groupApiService.getGroups());
this._groups.set(groups);
} catch (error) {
console.error('Failed to load groups', error);
}
}
// --- Utility ---
getGroupById(id: string): Group | undefined {
return this._groups().find(g => g.id === id);
}
getBetsByGroupId(groupId: string): Bet[] {
return this._bets().filter(b => b.groupId === groupId);
}
// --- Mutations ---
async sendFriendRequest(identifier: string): Promise<{ success: boolean, message: string }> {
try {
const newRequest = await firstValueFrom(this.friendRequestApiService.sendRequest({ identifier }));
this._friendRequests.update(reqs => [...reqs, newRequest]);
return { success: true, message: 'Richiesta di amicizia inviata!' };
} catch (error: any) {
return { success: false, message: error.error?.message || 'Errore imprevisto.' };
}
}
async cancelFriendRequest(requestId: string) {
try {
await firstValueFrom(this.friendRequestApiService.cancelRequest(requestId));
this._friendRequests.update(reqs => reqs.filter(r => r.id !== requestId));
} catch (error) {
console.error('Failed to cancel friend request', error);
}
}
async acceptFriendRequest(request: FriendRequest) {
try {
const { newFriend } = await firstValueFrom(this.friendRequestApiService.acceptRequest(request.id));
this._friends.update(f => [...f, newFriend]);
this._friendRequests.update(reqs => reqs.filter(r => r.id !== request.id));
} catch (error) {
console.error('Failed to accept friend request', error);
}
}
async declineFriendRequest(requestId: string) {
try {
await firstValueFrom(this.friendRequestApiService.declineRequest(requestId));
this._friendRequests.update(reqs => reqs.filter(r => r.id !== requestId));
} catch (error) {
console.error('Failed to decline friend request', error);
}
}
async addGroup(name: string, members: User[]) {
try {
const memberIds = members.map(m => m.id);
const newGroup = await firstValueFrom(this.groupApiService.createGroup({ name, members: memberIds }));
this._groups.update(groups => [...groups, newGroup]);
} catch (error) {
console.error('Failed to add group', error);
}
}
async removeUserFromGroup(groupId: string, userId: string) {
try {
const updatedGroup = await firstValueFrom(this.groupApiService.removeMember(groupId, userId));
this._groups.update(groups => groups.map(g => g.id === groupId ? updatedGroup : g));
} catch (error) {
console.error('Failed to remove user from group', error);
}
}
async addUsersToGroup(groupId: string, users: User[]) {
try {
const userIds = users.map(u => u.id);
const updatedGroup = await firstValueFrom(this.groupApiService.addMembers(groupId, { userIds }));
this._groups.update(groups => groups.map(g => g.id === groupId ? updatedGroup : g));
} catch (error) {
console.error('Failed to add users to group', error);
}
}
async updateCurrentUser(data: { name: string, email: string }) {
try {
const updatedUser = await firstValueFrom(this.userApiService.updateCurrentUser(data));
this.currentUser.set(updatedUser);
} catch (error) {
console.error('Failed to update user', error);
}
}
async addBet(betData: {
title: string;
description: string;
stake: string;
isPublic: boolean;
endDate: Date | null;
groupId?: string;
participants: BetParticipant[];
}) {
const currentUser = this.currentUser();
if (!currentUser) return;
const participantPayload = betData.participants.map(p => ({
user: typeof p.user === 'string' ? p.user : p.user.id,
role: p.role
}));
try {
const newBet = await firstValueFrom(this.betApiService.createBet({
title: betData.title,
description: betData.description,
stake: betData.stake,
isPublic: betData.isPublic,
groupId: betData.groupId,
participants: participantPayload
}));
this._bets.update(bets => [newBet, ...bets]);
} catch (error) {
console.error('Failed to add bet', error);
}
}
}
@@ -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/user.model';
/**
* 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,144 @@
<div class="flex flex-col h-full text-white animate-fade-in">
<!-- Header -->
<header class="p-4 flex items-center flex-shrink-0 border-b border-slate-700">
<button (click)="close.emit()" class="p-2 rounded-full hover:bg-slate-700 mr-3">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<div>
<h1 class="text-2xl font-bold">Crea Scommessa</h1>
</div>
</header>
<!-- Form -->
<main class="flex-grow overflow-y-auto p-4 space-y-6">
<form (submit)="$event.preventDefault(); createBet()">
<!-- Titolo -->
<div>
<label for="title" class="block text-sm font-medium text-slate-300 mb-2">Titolo *</label>
<input type="text" id="title" name="title"
[value]="title()"
(input)="onFieldInput('title', $event)"
required
class="w-full bg-slate-800 border border-slate-600 rounded-md py-2 px-3 text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:border-cyan-500"
placeholder="Es. Chi vincerà la partita?">
</div>
<!-- Descrizione -->
<div>
<label for="description" class="block text-sm font-medium text-slate-300 mb-2">Descrizione</label>
<textarea id="description" name="description" rows="3"
[value]="description()"
(input)="onFieldInput('description', $event)"
class="w-full bg-slate-800 border border-slate-600 rounded-md py-2 px-3 text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:border-cyan-500"
placeholder="Aggiungi dettagli sulla scommessa..."></textarea>
</div>
<!-- Premio -->
<div>
<label for="stake" class="block text-sm font-medium text-slate-300 mb-2">Premio in palio *</label>
<input type="text" id="stake" name="stake"
[value]="stake()"
(input)="onFieldInput('stake', $event)"
required
class="w-full bg-slate-800 border border-slate-600 rounded-md py-2 px-3 text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:border-cyan-500"
placeholder="Es. Una pizza, 10€, gloria eterna">
</div>
<!-- Visibilità -->
<div class="flex items-center justify-between bg-slate-800 p-3 rounded-lg border border-slate-700">
<div>
<h4 class="font-semibold text-slate-200">Scommessa Pubblica</h4>
<p class="text-sm text-slate-400">Visibile a tutti nella sezione "Scopri".</p>
</div>
<button type="button" (click)="isPublic.set(!isPublic())" [class]="'relative inline-flex items-center h-6 rounded-full w-11 transition-colors ' + (isPublic() ? 'bg-cyan-500' : 'bg-slate-600')">
<span [class]="'inline-block w-4 h-4 transform bg-white rounded-full transition-transform ' + (isPublic() ? 'translate-x-6' : 'translate-x-1')"></span>
</button>
</div>
<!-- Partecipanti -->
<section>
<h3 class="text-lg font-semibold text-cyan-400 mb-3">Partecipanti</h3>
<div class="space-y-3">
@for (p of participants(); track getUserId(p.user)) {
<div class="flex items-center bg-slate-800 p-2 rounded-lg">
<img class="w-10 h-10 rounded-full mr-4" [src]="p.user.avatarUrl" [alt]="p.user.name">
<p class="flex-grow font-medium">{{ p.user.name }}</p>
<select [value]="p.role" (change)="updateParticipantRole(p.user.id, $event)" class="bg-slate-700 border-slate-600 rounded-md py-1 px-2 text-sm text-white focus:outline-none focus:ring-1 focus:ring-cyan-500">
<option value="PARTICIPANT">Partecipante</option>
<option value="OBSERVER">Osservatore</option>
</select>
@if(p.user.id !== currentUser().id) {
<button type="button" (click)="removeParticipant(p.user.id)" class="ml-2 text-slate-500 hover:text-red-500 p-2 rounded-full transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
}
</div>
}
</div>
<div class="mt-4 relative">
<button type="button" (click)="showFriendSelector.set(!showFriendSelector())" class="w-full flex items-center justify-center py-2 px-4 border-2 border-dashed border-slate-600 rounded-lg text-slate-400 hover:bg-slate-700 hover:border-slate-500 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 5a1 1 0 011 1v3h3a1 1 0 110 2h-3v3a1 1 0 11-2 0v-3H6a1 1 0 110-2h3V6a1 1 0 011-1z" clip-rule="evenodd" />
</svg>
Aggiungi Amico
</button>
@if (showFriendSelector()) {
<div class="absolute bottom-full mb-2 w-full bg-slate-700 border border-slate-600 rounded-lg shadow-lg max-h-48 overflow-y-auto z-10 animate-fade-in">
@for (friend of availableFriends(); track friend.id) {
<div (click)="addParticipant(friend)" class="flex items-center p-3 hover:bg-slate-600 cursor-pointer">
<img class="w-8 h-8 rounded-full mr-3" [src]="friend.avatarUrl" [alt]="friend.name">
<span class="font-medium">{{ friend.name }}</span>
</div>
}
@empty {
<p class="text-slate-400 text-center p-4">Nessun altro amico da aggiungere.</p>
}
</div>
}
</div>
@if (activeParticipantsCount() < 1) {
<p class="text-yellow-400 text-sm mt-2">È necessario almeno 1 partecipante attivo.</p>
}
</section>
<!-- Gruppo e Scadenza -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="group" class="block text-sm font-medium text-slate-300 mb-2">Gruppo (Opzionale)</label>
<select id="group" name="group" [value]="groupId() ?? 'none'" (change)="onGroupChange($event)" class="w-full bg-slate-800 border border-slate-600 rounded-md py-2 px-3 text-white focus:outline-none focus:ring-2 focus:ring-cyan-500">
<option value="none">Nessun gruppo</option>
@for(group of userGroups(); track group.id) {
<option [value]="group.id">{{ group.name }}</option>
}
</select>
</div>
<div>
<label for="endDate" class="block text-sm font-medium text-slate-300 mb-2">Scadenza (Opzionale)</label>
<input type="date" id="endDate" name="endDate"
[value]="endDate()"
(input)="onFieldInput('endDate', $event)"
class="w-full bg-slate-800 border border-slate-600 rounded-md py-2 px-3 text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-cyan-500">
</div>
</div>
</form>
</main>
<!-- Footer Buttons -->
<footer class="p-4 flex-shrink-0 border-t border-slate-700 flex justify-end space-x-4">
<button type="button" (click)="close.emit()" class="px-6 py-2 rounded-md bg-slate-600 hover:bg-slate-500 text-white font-semibold transition-colors">Annulla</button>
<button type="button"
(click)="createBet()"
[disabled]="!isFormValid()"
[class]="'px-6 py-2 rounded-md font-semibold transition-colors ' + (isFormValid() ? 'bg-cyan-500 hover:bg-cyan-600 text-white' : 'bg-slate-700 text-slate-500 cursor-not-allowed')">
Crea Scommessa
</button>
</footer>
</div>
@@ -0,0 +1,121 @@
import { Component, ChangeDetectionStrategy, output, inject, signal, computed, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { DataService } from '../../core/services/data.service';
import { BetParticipant, BetRole } from '../../core/models/bet.model';
import { User } from '../../core/models/user.model';
import { getUserId } from '../../core/utils/type-guards';
@Component({
selector: 'app-create-bet',
standalone: true,
imports: [CommonModule],
templateUrl: './create-bet.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CreateBetComponent implements OnInit {
protected getUserId = getUserId;
close = output<void>();
private dataService = inject(DataService);
// Form state
title = signal('');
description = signal('');
stake = signal('');
isPublic = signal(false);
endDate = signal('');
groupId = signal<string | undefined>(undefined);
participants = signal<BetParticipant[]>([]);
// Data from service
currentUser = this.dataService.currentUser;
friends = this.dataService.friends;
userGroups = this.dataService.userGroups;
// UI state
showFriendSelector = signal(false);
availableFriends = computed(() => {
const participantIds = new Set(this.participants().map(p => getUserId(p.user)));
return this.friends().filter(f => !participantIds.has(f.id));
});
activeParticipantsCount = computed(() => {
return this.participants().filter(p => p.role === BetRole.Participant).length;
});
isFormValid = computed(() => {
return this.title().trim().length > 0 &&
this.stake().trim().length > 0 &&
this.activeParticipantsCount() >= 1;
});
ngOnInit() {
this.dataService.loadFriends();
this.dataService.loadGroups();
const user = this.currentUser();
if (user) {
this.participants.set([{ user, role: BetRole.Participant }]);
}
}
addParticipant(friend: User) {
this.participants.update(p => [...p, { user: friend, role: BetRole.Participant }]);
this.groupId.set(undefined);
this.showFriendSelector.set(false);
}
removeParticipant(userId: string) {
this.participants.update(p => p.filter(participant => getUserId(participant.user) !== userId));
this.groupId.set(undefined);
}
updateParticipantRole(userId: string, newRole: BetRole) {
this.participants.update(p => p.map(participant =>
getUserId(participant.user) === userId ? { ...participant, role: newRole } : participant
));
}
onFieldInput(field: 'title' | 'description' | 'stake' | 'endDate', event: Event) {
const value = (event.target as HTMLInputElement).value;
this[field].set(value);
}
onGroupChange(event: Event) {
const value = (event.target as HTMLSelectElement).value;
const selectedGroupId = value === 'none' ? undefined : value;
this.groupId.set(selectedGroupId);
if (selectedGroupId) {
const selectedGroup = this.userGroups().find(g => g.id === selectedGroupId);
if (selectedGroup) {
const groupParticipants: BetParticipant[] = selectedGroup.members.map(member => ({
user: member,
role: BetRole.Participant
}));
this.participants.set(groupParticipants);
}
} else {
const user = this.currentUser();
if (user) {
this.participants.set([{ user, role: BetRole.Participant }]);
}
}
}
async createBet() {
if (!this.isFormValid()) return;
await this.dataService.addBet({
title: this.title().trim(),
description: this.description().trim(),
stake: this.stake().trim(),
isPublic: this.isPublic(),
endDate: this.endDate() ? new Date(this.endDate()) : null,
groupId: this.groupId(),
participants: this.participants(),
});
this.close.emit();
}
}
@@ -0,0 +1,17 @@
<div class="p-4 flex flex-col h-full">
<header class="mb-4">
<h1 class="text-3xl font-bold text-white">Scommesse Pubbliche</h1>
<p class="text-slate-400 mt-1">Unisciti a queste scommesse o prendi ispirazione!</p>
</header>
<main class="flex-grow overflow-y-auto space-y-4 pr-2 -mr-2">
@for(bet of publicBets(); track bet.id) {
<app-bet-card [bet]="bet"></app-bet-card>
}
@empty {
<div class="text-center text-slate-500 mt-8">
<p>Al momento non ci sono scommesse pubbliche.</p>
<p>Torna più tardi!</p>
</div>
}
</main>
</div>
@@ -0,0 +1,20 @@
import { Component, ChangeDetectionStrategy, inject, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { DataService } from '../../core/services/data.service';
import { BetCardComponent } from '../../shared/components/bet-card/bet-card.component';
@Component({
selector: 'app-discover',
standalone: true,
imports: [CommonModule, BetCardComponent],
templateUrl: './discover.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DiscoverComponent implements OnInit {
private dataService = inject(DataService);
readonly publicBets = this.dataService.getPublicBets;
ngOnInit() {
this.dataService.loadPublicBets();
}
}
@@ -0,0 +1,126 @@
<div class="p-4 flex flex-col h-full text-white animate-fade-in">
@if (group(); as currentGroup) {
<!-- Header -->
<header class="mb-4 flex items-center">
<button (click)="goBack.emit()" class="p-2 rounded-full hover:bg-slate-700 mr-3">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
</svg>
</button>
<div>
<h1 class="text-3xl font-bold">{{ currentGroup.name }}</h1>
<p class="text-slate-400 mt-1">{{ currentGroup.members.length }} Membri</p>
</div>
</header>
<main class="flex-grow overflow-y-auto pr-2 -mr-2 space-y-6">
<!-- Members Section -->
<section>
<h2 class="text-xl font-semibold text-cyan-400 mb-3">Membri</h2>
<div class="space-y-3">
@for (member of sortedMembers(); track member.id) {
<div class="flex items-center bg-slate-800 p-2 rounded-lg">
<img class="w-10 h-10 rounded-full mr-4" [src]="member.avatarUrl" [alt]="member.name">
<div class="flex-grow">
<p class="font-medium">{{ member.name }}</p>
<div class="flex items-center text-xs text-amber-400">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1" viewBox="0 0 20 20" fill="currentColor">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
<span>{{ member.score }} Punti Gloria</span>
</div>
</div>
<button (click)="removeMember(member.id)" class="text-slate-500 hover:text-red-500 p-2 rounded-full transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
}
</div>
@if (!isAddingMembers()) {
<button (click)="toggleAddMembers()" class="mt-4 w-full flex items-center justify-center py-2 px-4 border-2 border-dashed border-slate-600 rounded-lg text-slate-400 hover:bg-slate-700 hover:border-slate-500 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 5a1 1 0 011 1v3h3a1 1 0 110 2h-3v3a1 1 0 11-2 0v-3H6a1 1 0 110-2h3V6a1 1 0 011-1z" clip-rule="evenodd" />
</svg>
Aggiungi Partecipanti
</button>
} @else {
<div class="mt-4 bg-slate-800 p-4 rounded-lg border border-slate-700">
<h3 class="text-lg font-semibold text-cyan-400 mb-3">Seleziona Amici</h3>
<div class="max-h-40 overflow-y-auto space-y-2 bg-slate-700 p-3 rounded-md border border-slate-600">
@for (friend of availableFriends(); track friend.id) {
<label class="flex items-center space-x-3 cursor-pointer p-2 rounded-md hover:bg-slate-600 transition-colors">
<input type="checkbox"
(change)="onFriendSelect(friend.id, $event)"
[checked]="selectedFriendIds()[friend.id] || false"
class="h-5 w-5 rounded bg-slate-800 border-slate-500 text-cyan-500 focus:ring-cyan-600 cursor-pointer">
<img class="w-8 h-8 rounded-full" [src]="friend.avatarUrl" [alt]="friend.name">
<span class="font-medium text-slate-200">{{ friend.name }}</span>
</label>
}
@empty {
<p class="text-slate-400 text-center py-4">Tutti i tuoi amici sono già in questo gruppo!</p>
}
</div>
<div class="flex justify-end space-x-3 mt-4">
<button type="button" (click)="toggleAddMembers()" class="px-4 py-2 rounded-md bg-slate-600 hover:bg-slate-500 text-white font-semibold transition-colors">Annulla</button>
<button type="button"
(click)="addSelectedFriends()"
[disabled]="!isAddFriendsFormValid()"
[class]="'px-4 py-2 rounded-md font-semibold transition-colors ' + (isAddFriendsFormValid() ? 'bg-cyan-500 hover:bg-cyan-600 text-white' : 'bg-slate-700 text-slate-500 cursor-not-allowed')">
Aggiungi
</button>
</div>
</div>
}
</section>
<!-- Accordions -->
<section class="space-y-4">
<!-- Active Bets -->
<div>
<button (click)="toggleAccordion('active')" class="w-full flex justify-between items-center bg-slate-800 p-3 rounded-lg hover:bg-slate-700 transition-colors">
<h2 class="text-xl font-semibold text-cyan-400">Scommesse Attive ({{ activeBets().length }})</h2>
<svg xmlns="http://www.w3.org/2000/svg" [class]="'h-6 w-6 transition-transform ' + (activeAccordion() === 'active' ? 'rotate-180' : '')" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
@if (activeAccordion() === 'active') {
<div class="pt-4 space-y-4 animate-fade-in">
@for (bet of activeBets(); track bet.id) {
<app-bet-card [bet]="bet"></app-bet-card>
}
@empty {
<p class="text-center text-slate-500 pt-4">Nessuna scommessa attiva in questo gruppo.</p>
}
</div>
}
</div>
<!-- Past Bets -->
<div>
<button (click)="toggleAccordion('past')" class="w-full flex justify-between items-center bg-slate-800 p-3 rounded-lg hover:bg-slate-700 transition-colors">
<h2 class="text-xl font-semibold text-cyan-400">Scommesse Concluse ({{ pastBets().length }})</h2>
<svg xmlns="http://www.w3.org/2000/svg" [class]="'h-6 w-6 transition-transform ' + (activeAccordion() === 'past' ? 'rotate-180' : '')" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
@if (activeAccordion() === 'past') {
<div class="pt-4 space-y-4 animate-fade-in">
@for (bet of pastBets(); track bet.id) {
<app-bet-card [bet]="bet"></app-bet-card>
}
@empty {
<p class="text-center text-slate-500 pt-4">Nessuna scommessa conclusa in questo gruppo.</p>
}
</div>
}
</div>
</section>
</main>
} @else {
<p class="text-center text-slate-500 mt-8">Gruppo non trovato.</p>
}
</div>
@@ -0,0 +1,89 @@
import { Component, ChangeDetectionStrategy, input, output, inject, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { DataService } from '@/src/core/services/data.service';
import { Bet, BetStatus } from '@/src/core/models/bet.model';
import { BetCardComponent } from '@/src/shared/components/bet-card/bet-card.component';
@Component({
selector: 'app-group-detail',
standalone: true,
imports: [CommonModule, BetCardComponent],
templateUrl: './group-detail.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class GroupDetailComponent {
groupId = input.required<string>();
goBack = output<void>();
private dataService = inject(DataService);
private allGroups = this.dataService.userGroups;
group = computed(() => this.allGroups().find(g => g.id === this.groupId()) ?? null);
readonly groupBets = computed(() => this.dataService.getBetsByGroupId(this.groupId()));
isAddingMembers = signal(false);
selectedFriendIds = signal<{ [key: string]: boolean }>({});
sortedMembers = computed(() => {
return this.group()?.members.slice().sort((a, b) => b.score - a.score) ?? [];
});
activeBets = computed(() => {
return this.groupBets().filter((b: Bet) => b.status === BetStatus.Active || b.status === BetStatus.AwaitingValidation);
});
pastBets = computed(() => {
return this.groupBets().filter((b: Bet) => b.status === BetStatus.Completed || b.status === BetStatus.Expired);
});
availableFriends = computed(() => {
const friends = this.dataService.friends();
const memberIds = new Set(this.group()?.members.map(m => m.id) ?? []);
return friends.filter(f => !memberIds.has(f.id));
});
isAddFriendsFormValid = computed(() => {
return Object.keys(this.selectedFriendIds()).length > 0;
});
activeAccordion = signal<'active' | 'past' | 'none'>('active');
toggleAccordion(section: 'active' | 'past') {
this.activeAccordion.update(current => current === section ? 'none' : section);
}
async removeMember(userId: string) {
if (confirm('Sei sicuro di voler rimuovere questo membro?')) {
await this.dataService.removeUserFromGroup(this.groupId(), userId);
}
}
toggleAddMembers() {
this.isAddingMembers.update(val => !val);
this.selectedFriendIds.set({});
}
onFriendSelect(friendId: string, event: Event) {
const isChecked = (event.target as HTMLInputElement).checked;
this.selectedFriendIds.update(currentIds => {
const newIds = { ...currentIds };
if (isChecked) {
newIds[friendId] = true;
} else {
delete newIds[friendId];
}
return newIds;
});
}
async addSelectedFriends() {
if (!this.isAddFriendsFormValid()) return;
const selectedIds = Object.keys(this.selectedFriendIds());
const friendsToAdd = this.availableFriends().filter(f => selectedIds.includes(f.id));
await this.dataService.addUsersToGroup(this.groupId(), friendsToAdd);
this.toggleAddMembers();
}
}
@@ -0,0 +1,94 @@
@if (!selectedGroupId()) {
<div class="p-4 flex flex-col h-full text-white">
<header class="mb-4">
<div class="flex justify-between items-center">
<div>
<h1 class="text-3xl font-bold">I Miei Gruppi</h1>
<p class="text-slate-400 mt-1">Gestisci i tuoi gruppi o creane di nuovi.</p>
</div>
<button (click)="toggleCreateMode()" class="bg-cyan-500 hover:bg-cyan-600 text-white font-bold p-2 rounded-full transition-colors duration-300 shadow-lg">
@if (isCreating()) {
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
} @else {
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
</svg>
}
</button>
</div>
</header>
@if (isCreating()) {
<div class="bg-slate-800 p-4 rounded-lg border border-slate-700 mb-4 animate-fade-in">
<h2 class="text-xl font-bold text-cyan-400 mb-4">Nuovo Gruppo</h2>
<form (submit)="$event.preventDefault(); createGroup()">
<div class="mb-4">
<label for="groupName" class="block text-sm font-medium text-slate-300 mb-2">Nome Gruppo</label>
<input type="text" id="groupName" name="groupName"
[value]="newGroupName()"
(input)="onGroupNameChange($event)"
required
class="w-full bg-slate-700 border border-slate-600 rounded-md py-2 px-3 text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:border-cyan-500"
placeholder="Es. Colleghi d'ufficio">
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-slate-300 mb-2">Membri (Amici)</label>
<div class="max-h-40 overflow-y-auto space-y-2 bg-slate-700 p-3 rounded-md border border-slate-600">
@for (friend of friends(); track friend.id) {
<label class="flex items-center space-x-3 cursor-pointer p-2 rounded-md hover:bg-slate-600 transition-colors">
<input type="checkbox"
(change)="onFriendSelect(friend.id, $event)"
[checked]="selectedFriendIds()[friend.id] || false"
class="h-5 w-5 rounded bg-slate-800 border-slate-500 text-cyan-500 focus:ring-cyan-600 cursor-pointer">
<img class="w-8 h-8 rounded-full" [src]="friend.avatarUrl" [alt]="friend.name">
<span class="font-medium text-slate-200">{{ friend.name }}</span>
</label>
}
</div>
</div>
<div class="flex justify-end space-x-3 mt-4">
<button type="button" (click)="toggleCreateMode()" class="px-4 py-2 rounded-md bg-slate-600 hover:bg-slate-500 text-white font-semibold transition-colors">Annulla</button>
<button type="submit"
[disabled]="!isFormValid()"
[class]="'px-4 py-2 rounded-md font-semibold transition-colors ' + (isFormValid() ? 'bg-cyan-500 hover:bg-cyan-600 text-white' : 'bg-slate-700 text-slate-500 cursor-not-allowed')">
Crea Gruppo
</button>
</div>
</form>
</div>
}
<main class="flex-grow overflow-y-auto space-y-4 pr-2 -mr-2">
@for (group of groups(); track group.id) {
<div (click)="selectGroup(group.id)" class="bg-slate-800 p-4 rounded-lg border border-slate-700 hover:border-cyan-500 transition-colors duration-300 cursor-pointer">
<div class="flex justify-between items-center mb-3">
<h3 class="text-xl font-bold text-cyan-400">{{ group.name }}</h3>
<span class="text-sm font-semibold bg-slate-700 text-slate-300 px-2 py-1 rounded-md">{{ group.members.length }} Membri</span>
</div>
<div class="flex -space-x-2">
@for(member of group.members; track member.id) {
<img class="inline-block h-10 w-10 rounded-full ring-2 ring-slate-900" [src]="member.avatarUrl" [alt]="member.name" [title]="member.name">
}
</div>
</div>
}
@empty {
@if (!isCreating()) {
<div class="text-center text-slate-500 mt-8 flex flex-col items-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 mb-4 text-slate-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1">
<path stroke-linecap="round" stroke-linejoin="round" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.653-.124-1.282-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.653.124-1.282.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
<p>Non fai ancora parte di nessun gruppo.</p>
<p class="mt-1">Crea un gruppo per iniziare a scommettere con i tuoi amici!</p>
</div>
}
}
</main>
</div>
} @else {
<app-group-detail [groupId]="selectedGroupId()!" (goBack)="deselectGroup()"></app-group-detail>
}
@@ -0,0 +1,88 @@
import { Component, ChangeDetectionStrategy, inject, signal, computed, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { DataService } from '../../core/services/data.service';
import { GroupDetailComponent } from './group-detail/group-detail.component';
@Component({
selector: 'app-groups',
standalone: true,
imports: [CommonModule, GroupDetailComponent],
templateUrl: './groups.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class GroupsComponent implements OnInit {
private dataService = inject(DataService);
groups = this.dataService.userGroups;
friends = this.dataService.friends;
isCreating = signal(false);
newGroupName = signal('');
selectedFriendIds = signal<{ [key: string]: boolean }>({});
selectedGroupId = signal<string | null>(null);
isFormValid = computed(() => {
return this.newGroupName().trim().length > 0 && Object.keys(this.selectedFriendIds()).length > 0;
});
ngOnInit() {
this.dataService.loadGroups();
this.dataService.loadFriends();
this.dataService.loadUserBets();
}
selectGroup(groupId: string) {
this.selectedGroupId.set(groupId);
}
deselectGroup() {
this.selectedGroupId.set(null);
}
toggleCreateMode() {
this.isCreating.update(val => !val);
if (!this.isCreating()) {
this.resetForm();
}
}
onGroupNameChange(event: Event) {
this.newGroupName.set((event.target as HTMLInputElement).value);
}
onFriendSelect(friendId: string, event: Event) {
const isChecked = (event.target as HTMLInputElement).checked;
this.selectedFriendIds.update(currentIds => {
const newIds = { ...currentIds };
if (isChecked) {
newIds[friendId] = true;
} else {
delete newIds[friendId];
}
return newIds;
});
}
async createGroup() {
if (!this.isFormValid()) {
return;
}
const selectedIds = Object.keys(this.selectedFriendIds());
const selectedMembers = this.friends().filter(f => selectedIds.includes(f.id));
const currentUser = this.dataService.currentUser();
if (!currentUser) return;
const allMembers = [currentUser, ...selectedMembers];
const uniqueMembers = [...new Map(allMembers.map(item => [item['id'], item])).values()];
await this.dataService.addGroup(this.newGroupName(), uniqueMembers);
this.toggleCreateMode();
}
private resetForm() {
this.newGroupName.set('');
this.selectedFriendIds.set({});
}
}
@@ -0,0 +1,35 @@
<div class="flex flex-col h-full">
<!-- FILTERS -->
<div class="mb-4 overflow-x-auto pb-2 -mx-4 px-4 hide-scrollbar">
<div class="flex items-center space-x-2 whitespace-nowrap">
<button (click)="setGroupFilter('all')"
[class]="'px-3 py-1 text-sm font-medium rounded-full transition-colors ' + (activeGroupFilter() === 'all' ? 'bg-cyan-500 text-white' : 'bg-slate-700 text-slate-300 hover:bg-slate-600')">
Tutti
</button>
@for(group of groupFilters(); track group.id) {
<button (click)="setGroupFilter(group.id)"
[class]="'px-3 py-1 text-sm font-medium rounded-full transition-colors ' + (activeGroupFilter() === group.id ? 'bg-cyan-500 text-white' : 'bg-slate-700 text-slate-300 hover:bg-slate-600')">
{{ group.name }}
</button>
}
</div>
</div>
<main class="flex-grow overflow-y-auto pr-2 -mr-2">
<div class="space-y-6">
@for (group of betsByGroup(); track group.group.id) {
<div>
<h2 class="text-xl font-semibold text-cyan-400 mb-3 sticky top-0 bg-slate-900 py-2">{{ group.group.name }}</h2>
<div class="space-y-4">
@for(bet of group.bets; track bet.id) {
<app-bet-card [bet]="bet"></app-bet-card>
}
</div>
</div>
}
@empty {
<p class="text-center text-slate-500 mt-8">Nessun gruppo con i filtri selezionati.</p>
}
</div>
</main>
</div>
@@ -0,0 +1,73 @@
import { Component, ChangeDetectionStrategy, inject, signal, computed, input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { DataService } from '../../../core/services/data.service';
import { BetCardComponent } from '../../../shared/components/bet-card/bet-card.component';
import { Bet } from '../../../core/models/bet.model';
import { Group } from '../../../core/models/group.model';
import { getUserId, getUserName } from '../../../core/utils/type-guards';
@Component({
selector: 'app-bets-by-group',
standalone: true,
imports: [CommonModule, BetCardComponent],
templateUrl: './bets-by-group.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class BetsByGroupComponent {
private dataService = inject(DataService);
allBets = input.required<Bet[]>();
activeGroupFilter = signal<string>('all'); // 'all' or group.id
private unfilteredBetsByGroup = computed(() => {
const grouped = new Map<string, { group: Group, bets: Bet[] }>();
for (const bet of this.allBets()) {
let groupId: string;
let groupName: string;
let groupInfo: Group | undefined;
if (bet.groupId) {
groupId = bet.groupId;
groupInfo = this.dataService.getGroupById(groupId);
groupName = groupInfo?.name ?? 'Gruppo Sconosciuto';
} else {
const participantIds = bet.participants.map(p => getUserId(p.user)).sort().join('-');
groupId = `adhoc-${participantIds}`;
groupInfo = undefined;
groupName = this.createAdHocGroupName(bet);
}
if (!grouped.has(groupId)) {
grouped.set(groupId, {
group: groupInfo ?? { id: groupId, name: groupName, members: [] } as Group,
bets: []
});
}
grouped.get(groupId)!.bets.push(bet);
}
return Array.from(grouped.values());
});
groupFilters = computed(() => {
return this.unfilteredBetsByGroup().map(item => item.group);
});
betsByGroup = computed(() => {
const filter = this.activeGroupFilter();
if (filter === 'all') {
return this.unfilteredBetsByGroup();
}
return this.unfilteredBetsByGroup().filter(item => item.group.id === filter);
});
setGroupFilter(groupId: string) {
this.activeGroupFilter.set(groupId);
}
private createAdHocGroupName(bet: Bet): string {
const names = bet.participants.map(p => getUserName(p.user).split(' ')[0]);
if (names.length === 1) return `Scommessa Personale`;
return names.slice(0, 3).join(', ') + (names.length > 3 ? '...' : '');
}
}
@@ -0,0 +1,24 @@
<div class="flex flex-col h-full">
<!-- FILTERS -->
<div class="mb-4 overflow-x-auto pb-2 -mx-4 px-4 hide-scrollbar">
<div class="flex items-center space-x-2 whitespace-nowrap">
@for(filter of timeFilters; track filter.key) {
<button (click)="setTimeFilter(filter.key)"
[class]="'px-3 py-1 text-sm font-medium rounded-full transition-colors ' + (activeTimeFilter() === filter.key ? 'bg-cyan-500 text-white' : 'bg-slate-700 text-slate-300 hover:bg-slate-600')">
{{ filter.label }}
</button>
}
</div>
</div>
<main class="flex-grow overflow-y-auto pr-2 -mr-2">
<div class="space-y-4">
@for (bet of betsByTime(); track bet.id) {
<app-bet-card [bet]="bet"></app-bet-card>
}
@empty {
<p class="text-center text-slate-500 mt-8">Nessuna scommessa attiva con i filtri selezionati.</p>
}
</div>
</main>
</div>
@@ -0,0 +1,71 @@
import { Component, ChangeDetectionStrategy, input, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { BetCardComponent } from '../../../shared/components/bet-card/bet-card.component';
import { Bet } from '../../../core/models/bet.model';
type TimeFilter = 'all' | '1d' | '1w' | '1m' | '6m' | 'none';
interface TimeFilterOption {
key: TimeFilter;
label: string;
}
@Component({
selector: 'app-bets-by-time',
standalone: true,
imports: [CommonModule, BetCardComponent],
templateUrl: './bets-by-time.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class BetsByTimeComponent {
allBets = input.required<Bet[]>();
activeTimeFilter = signal<TimeFilter>('all');
readonly timeFilters: TimeFilterOption[] = [
{ key: 'all', label: 'Tutte' },
{ key: '1d', label: '1 giorno' },
{ key: '1w', label: '1 settimana' },
{ key: '1m', label: '1 mese' },
{ key: '6m', label: '6 mesi' },
{ key: 'none', label: 'Senza scadenza' },
];
betsByTime = computed(() => {
const allSortedBets = [...this.allBets()].sort((a, b) => {
const aEndDate = a.endDate?.getTime() ?? Infinity;
const bEndDate = b.endDate?.getTime() ?? Infinity;
return aEndDate - bEndDate;
});
const filter = this.activeTimeFilter();
if (filter === 'all') {
return allSortedBets;
}
const now = new Date().getTime();
return allSortedBets.filter(bet => {
if (filter === 'none') {
return !bet.endDate;
}
if (!bet.endDate) {
return false;
}
const endDate = bet.endDate.getTime();
let limit = 0;
switch (filter) {
case '1d': limit = now + 24 * 60 * 60 * 1000; break;
case '1w': limit = now + 7 * 24 * 60 * 60 * 1000; break;
case '1m': limit = now + 30 * 24 * 60 * 60 * 1000; break;
case '6m': limit = now + 180 * 24 * 60 * 60 * 1000; break;
}
return endDate > now && endDate <= limit;
});
});
setTimeFilter(filter: TimeFilter) {
this.activeTimeFilter.set(filter);
}
}
@@ -0,0 +1,25 @@
<div class="p-4 flex flex-col h-full">
<header class="mb-4">
<h1 class="text-3xl font-bold text-white">Scommesse Attive</h1>
<div class="mt-4 flex justify-center bg-slate-800 rounded-lg p-1">
<button
(click)="setViewMode('time')"
[class]="'w-1/2 py-2 text-sm font-semibold rounded-md transition-colors ' + (viewMode() === 'time' ? 'bg-cyan-500 text-white' : 'text-slate-400 hover:bg-slate-700')">
Per Scadenza
</button>
<button
(click)="setViewMode('group')"
[class]="'w-1/2 py-2 text-sm font-semibold rounded-md transition-colors ' + (viewMode() === 'group' ? 'bg-cyan-500 text-white' : 'text-slate-400 hover:bg-slate-700')">
Per Gruppi
</button>
</div>
</header>
<div class="flex-grow overflow-hidden">
@if (viewMode() === 'time') {
<app-bets-by-time [allBets]="allBets()"></app-bets-by-time>
} @else {
<app-bets-by-group [allBets]="allBets()"></app-bets-by-group>
}
</div>
</div>
@@ -0,0 +1,27 @@
import { Component, ChangeDetectionStrategy, inject, signal, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { DataService } from '../../core/services/data.service';
import { BetsByTimeComponent } from './bets-by-time/bets-by-time.component';
import { BetsByGroupComponent } from './bets-by-group/bets-by-group.component';
@Component({
selector: 'app-home',
standalone: true,
imports: [CommonModule, BetsByTimeComponent, BetsByGroupComponent],
templateUrl: './home.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class HomeComponent implements OnInit {
private dataService = inject(DataService);
viewMode = signal<'time' | 'group'>('time');
readonly allBets = this.dataService.getActiveBets;
ngOnInit() {
this.dataService.loadUserBets();
}
setViewMode(mode: 'time' | 'group') {
this.viewMode.set(mode);
}
}
@@ -0,0 +1,80 @@
<div class="flex flex-col h-full text-white animate-fade-in">
<!-- Header -->
<header class="mb-6 flex items-center flex-shrink-0">
<button (click)="close.emit()" class="p-2 rounded-full hover:bg-slate-700 mr-3">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
</svg>
</button>
<div>
<h1 class="text-3xl font-bold">Modifica Profilo</h1>
<p class="text-slate-400 mt-1">Aggiorna i tuoi dati personali.</p>
</div>
</header>
<!-- Form -->
<main class="flex-grow overflow-y-auto pr-2 -mr-2">
<form (submit)="$event.preventDefault(); saveChanges()" class="space-y-6">
<!-- Nome -->
<div>
<label for="name" class="block text-sm font-medium text-slate-300 mb-2">Nome</label>
<input type="text" id="name" name="name"
[value]="name()"
(input)="onNameInput($event)"
required
class="w-full bg-slate-800 border border-slate-600 rounded-md py-2 px-3 text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:border-cyan-500">
</div>
<!-- Email -->
<div>
<label for="email" class="block text-sm font-medium text-slate-300 mb-2">Email</label>
<input type="email" id="email" name="email"
[value]="email()"
(input)="onEmailInput($event)"
required
class="w-full bg-slate-800 border border-slate-600 rounded-md py-2 px-3 text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:border-cyan-500">
</div>
<div class="border-t border-slate-700 pt-6">
<h3 class="text-lg font-semibold text-cyan-400">Cambia Password</h3>
<p class="text-sm text-slate-400 mb-4">Lascia vuoto per non modificare la password attuale.</p>
<!-- Nuova Password -->
<div>
<label for="password" class="block text-sm font-medium text-slate-300 mb-2">Nuova Password</label>
<input type="password" id="password" name="password"
[value]="password()"
(input)="onPasswordInput($event)"
placeholder="Minimo 6 caratteri"
class="w-full bg-slate-800 border border-slate-600 rounded-md py-2 px-3 text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:border-cyan-500">
</div>
<!-- Conferma Password -->
<div class="mt-4">
<label for="confirmPassword" class="block text-sm font-medium text-slate-300 mb-2">Conferma Nuova Password</label>
<input type="password" id="confirmPassword" name="confirmPassword"
[value]="confirmPassword()"
(input)="onConfirmPasswordInput($event)"
class="w-full bg-slate-800 border rounded-md py-2 px-3 text-white placeholder-slate-400 focus:outline-none focus:ring-2"
[class.border-slate-600]="password().length === 0 || passwordsMatch()"
[class.border-red-500]="password().length > 0 && !passwordsMatch()"
[class.focus:ring-cyan-500]="password().length === 0 || passwordsMatch()"
[class.focus:ring-red-500]="password().length > 0 && !passwordsMatch()">
@if(password().length > 0 && !passwordsMatch()) {
<p class="mt-2 text-sm text-red-400">Le password non coincidono.</p>
}
</div>
</div>
<!-- Buttons -->
<div class="flex justify-end space-x-4 pt-4">
<button type="button" (click)="close.emit()" class="px-6 py-2 rounded-md bg-slate-600 hover:bg-slate-500 text-white font-semibold transition-colors">Annulla</button>
<button type="submit"
[disabled]="!isFormValid()"
[class]="'px-6 py-2 rounded-md font-semibold transition-colors ' + (isFormValid() ? 'bg-cyan-500 hover:bg-cyan-600 text-white' : 'bg-slate-700 text-slate-500 cursor-not-allowed')">
Salva Modifiche
</button>
</div>
</form>
</main>
</div>
@@ -0,0 +1,60 @@
import { Component, ChangeDetectionStrategy, output, inject, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { DataService } from '../../../core/services/data.service';
@Component({
selector: 'app-edit-profile',
standalone: true,
imports: [CommonModule],
templateUrl: './edit-profile.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class EditProfileComponent {
close = output<void>();
private dataService = inject(DataService);
currentUser = this.dataService.currentUser;
name = signal(this.currentUser()?.name ?? '');
email = signal(this.currentUser()?.email ?? '');
password = signal('');
confirmPassword = signal('');
passwordsMatch = computed(() => this.password() === this.confirmPassword());
isFormValid = computed(() => {
const nameValid = this.name().trim().length > 2;
// A simple email regex check
const emailValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(this.email());
const passwordValid = this.password().length === 0 || (this.password().length >= 6 && this.passwordsMatch());
return nameValid && emailValid && passwordValid;
});
onNameInput(event: Event) {
this.name.set((event.target as HTMLInputElement).value);
}
onEmailInput(event: Event) {
this.email.set((event.target as HTMLInputElement).value);
}
onPasswordInput(event: Event) {
this.password.set((event.target as HTMLInputElement).value);
}
onConfirmPasswordInput(event: Event) {
this.confirmPassword.set((event.target as HTMLInputElement).value);
}
async saveChanges() {
if (!this.isFormValid()) {
return;
}
await this.dataService.updateCurrentUser({
name: this.name(),
email: this.email(),
});
// Note: Password change is not persisted.
this.close.emit();
}
}
@@ -0,0 +1,134 @@
@if (!isEditing()) {
<div class="p-4 flex flex-col h-full text-white animate-fade-in">
<!-- Header with Logout -->
<header class="flex justify-between items-center mb-4">
<h1 class="text-3xl font-bold">Profilo</h1>
<button (click)="logout()" title="Logout" class="p-2 text-slate-400 hover:text-white rounded-full hover:bg-slate-700 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
</button>
</header>
<!-- User Info -->
<section class="flex flex-col items-center text-center p-6 bg-slate-800 rounded-lg border border-slate-700 mb-6">
<img class="w-24 h-24 rounded-full border-4 border-cyan-500 shadow-lg mb-4" [src]="currentUser().avatarUrl" [alt]="currentUser().name">
<h2 class="text-2xl font-bold">{{ currentUser().name }}</h2>
<div class="flex items-center text-amber-400 mt-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-1" viewBox="0 0 20 20" fill="currentColor">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
<span class="font-semibold">{{ currentUser().score }} Punti Gloria</span>
</div>
<button (click)="toggleEditMode()" class="mt-4 w-full max-w-xs bg-slate-700 hover:bg-slate-600 text-white font-bold py-2 px-4 rounded-lg transition-colors duration-300">
Modifica Profilo
</button>
</section>
<main class="flex-grow overflow-y-auto pr-2 -mr-2 space-y-6">
<!-- Friend Requests Received -->
@if (receivedFriendRequests().length > 0) {
<section>
<h3 class="text-xl font-semibold text-cyan-400 mb-3">Richieste Ricevute</h3>
<div class="space-y-3">
@for(request of receivedFriendRequests(); track request.id) {
<div class="flex items-center bg-slate-800 p-2 rounded-lg">
<img class="w-10 h-10 rounded-full mr-4" [src]="request.fromUser.avatarUrl" [alt]="request.fromUser.name">
<p class="flex-grow font-medium">{{ request.fromUser.name }}</p>
<div class="flex space-x-2">
<button (click)="acceptRequest(request)" title="Accetta" class="p-2 text-green-400 hover:bg-slate-700 rounded-full transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</button>
<button (click)="declineRequest(request.id)" title="Rifiuta" class="p-2 text-red-400 hover:bg-slate-700 rounded-full transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
}
</div>
</section>
}
<!-- Friend Requests Sent -->
@if (sentFriendRequests().length > 0) {
<section>
<h3 class="text-xl font-semibold text-cyan-400 mb-3">Richieste Inviate</h3>
<div class="space-y-3">
@for(request of sentFriendRequests(); track request.id) {
<div class="flex items-center bg-slate-800 p-2 rounded-lg">
<img class="w-10 h-10 rounded-full mr-4" [src]="request.toUser.avatarUrl" [alt]="request.toUser.name">
<p class="flex-grow font-medium">{{ request.toUser.name }}</p>
<button (click)="cancelRequest(request.id)" title="Annulla Richiesta" class="p-2 text-slate-500 hover:text-red-400 hover:bg-slate-700 rounded-full transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
</div>
}
</div>
</section>
}
<!-- Friends List -->
<section>
<div class="flex justify-between items-center mb-3">
<h3 class="text-xl font-semibold text-cyan-400">Amici</h3>
<button (click)="toggleAddFriend()" [class]="'p-2 rounded-full transition-colors ' + (isAddingFriend() ? 'bg-slate-700 text-white' : 'text-slate-400 hover:bg-slate-700 hover:text-white')">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
</svg>
</button>
</div>
@if (isAddingFriend()) {
<div class="bg-slate-800 p-4 rounded-lg border border-slate-700 mb-4 animate-fade-in">
<label for="friendName" class="block text-sm font-medium text-slate-300 mb-2">Aggiungi amico per nome o email</label>
<div class="flex space-x-2">
<input type="text" id="friendName" name="friendName"
[value]="addFriendIdentifier()" (input)="onAddFriendInput($event)"
(keyup.enter)="sendFriendRequest()"
class="flex-grow bg-slate-700 border border-slate-600 rounded-md py-2 px-3 text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:border-cyan-500"
placeholder="Es. Bob o bob@email.com">
<button (click)="sendFriendRequest()" class="bg-cyan-500 hover:bg-cyan-600 text-white font-bold py-2 px-4 rounded-md transition-colors">Invia</button>
</div>
@if(addFriendError()) { <p class="text-red-400 text-sm mt-2 animate-fade-in">{{ addFriendError() }}</p> }
@if(addFriendSuccess()) { <p class="text-green-400 text-sm mt-2 animate-fade-in">{{ addFriendSuccess() }}</p> }
</div>
}
<div class="space-y-3">
@for (friend of friends(); track friend.id) {
<div class="flex items-center bg-slate-800 p-2 rounded-lg">
<img class="w-10 h-10 rounded-full mr-4" [src]="friend.avatarUrl" [alt]="friend.name">
<div class="flex-grow">
<p class="font-medium">{{ friend.name }}</p>
<div class="flex items-center text-xs text-amber-400">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1" viewBox="0 0 20 20" fill="currentColor">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
<span>{{ friend.score }} Punti Gloria</span>
</div>
</div>
</div>
}
@empty {
@if (!isAddingFriend()) {
<div class="text-center text-slate-500 py-4">
<p>Non hai ancora nessun amico.</p>
<p class="mt-1">Aggiungine uno per iniziare a scommettere!</p>
</div>
}
}
</div>
</section>
</main>
</div>
} @else {
<app-edit-profile (close)="toggleEditMode()"></app-edit-profile>
}
@@ -0,0 +1,89 @@
import { Component, ChangeDetectionStrategy, inject, signal, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { DataService } from '../../core/services/data.service';
import { AuthService } from '../../core/services/auth.service';
import { EditProfileComponent } from './edit-profile/edit-profile.component';
import { FriendRequest } from '../../core/models/friend-request.model';
@Component({
selector: 'app-profile',
standalone: true,
imports: [CommonModule, EditProfileComponent],
templateUrl: './profile.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ProfileComponent implements OnInit {
private dataService = inject(DataService);
private authService = inject(AuthService);
currentUser = this.dataService.currentUser;
friends = this.dataService.friends;
receivedFriendRequests = this.dataService.receivedFriendRequests;
sentFriendRequests = this.dataService.sentFriendRequests;
isEditing = signal(false);
isAddingFriend = signal(false);
addFriendIdentifier = signal('');
addFriendError = signal<string | null>(null);
addFriendSuccess = signal<string | null>(null);
ngOnInit() {
this.dataService.loadCurrentUser();
this.dataService.loadFriends();
this.dataService.loadFriendRequests();
}
toggleEditMode() {
this.isEditing.update(val => !val);
}
toggleAddFriend() {
this.isAddingFriend.update(val => !val);
this.addFriendIdentifier.set('');
this.addFriendError.set(null);
this.addFriendSuccess.set(null);
}
onAddFriendInput(event: Event) {
this.addFriendIdentifier.set((event.target as HTMLInputElement).value);
this.addFriendError.set(null);
this.addFriendSuccess.set(null);
}
async sendFriendRequest() {
if (this.addFriendIdentifier().trim() === '') return;
const result = await this.dataService.sendFriendRequest(this.addFriendIdentifier().trim());
if (result.success) {
this.addFriendSuccess.set(result.message);
this.addFriendError.set(null);
this.addFriendIdentifier.set('');
setTimeout(() => {
this.addFriendSuccess.set(null);
if (this.isAddingFriend()) {
this.toggleAddFriend();
}
}, 2000);
} else {
this.addFriendError.set(result.message);
this.addFriendSuccess.set(null);
}
}
cancelRequest(requestId: string) {
this.dataService.cancelFriendRequest(requestId);
}
acceptRequest(request: FriendRequest) {
this.dataService.acceptFriendRequest(request);
}
declineRequest(requestId: string) {
this.dataService.declineFriendRequest(requestId);
}
logout() {
this.authService.logout();
}
}
@@ -0,0 +1,73 @@
@if (bet()) {
<div
class="bg-slate-800 rounded-lg shadow-lg p-4 flex flex-col space-y-4 border border-slate-700 hover:border-cyan-500 transition-colors duration-300 mb-4">
<header class="flex justify-between items-start">
<h3 class="text-lg font-bold text-cyan-400">{{ bet().title }}</h3>
<div class="flex items-center space-x-2 text-sm">
<span [class]="'px-2 py-1 text-xs font-semibold rounded-full text-slate-900 ' + statusInfo().color">
{{ statusInfo().text }}
</span>
@if(bet().isPublic) {
<span class="text-slate-400" title="Pubblica">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM4.332 8.027a6.012 6.012 0 011.912-2.706C6.512 5.73 7.97 5 10 5c2.03 0 3.488.73 4.756 1.321l.004.002c.08.036.16.07.24.106a6.008 6.008 0 011.912 2.706A7.958 7.958 0 0118 10a7.958 7.958 0 01-1.168 1.973 6.012 6.012 0 01-1.912 2.706C13.488 14.27 12.03 15 10 15c-2.03 0-3.488-.73-4.756-1.321l-.004-.002a6.005 6.005 0 01-2.152-2.814A7.958 7.958 0 012 10a7.958 7.958 0 011.168-1.973zM10 12.5a2.5 2.5 0 100-5 2.5 2.5 0 000 5z"
clip-rule="evenodd" />
</svg>
</span>
}
</div>
</header>
<p class="text-sm text-slate-400 flex-grow">{{ bet().description }}</p>
<div>
<h4 class="text-xs font-semibold text-slate-500 uppercase mb-2">Partecipanti</h4>
<div class="flex items-center space-x-2">
<div class="flex -space-x-2 overflow-hidden">
@for (p of participants(); track getUserId(p.user)) {
@if (isUser(p.user)) {
<img class="inline-block h-8 w-8 rounded-full ring-2 ring-slate-800" [src]="p.user.avatarUrl"
[alt]="p.user.name" [title]="p.user.name + ' - Premio: ' + bet().stake">
} @else {
<div
class="inline-block h-8 w-8 rounded-full ring-2 ring-slate-800 bg-slate-700 flex items-center justify-center text-[10px] text-slate-400"
[title]="'Utente ID: ' + p.user">
U
</div>
}
}
</div>
@if (observers().length > 0) {
<div class="flex items-center text-slate-400" [title]="observerNamesTitle()">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path d="M10 12a2 2 0 100-4 2 2 0 000 4z" />
<path fill-rule="evenodd"
d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.022 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z"
clip-rule="evenodd" />
</svg>
<span class="ml-1 text-sm">{{ observers().length }}</span>
</div>
}
</div>
</div>
<footer class="border-t border-slate-700 pt-3 flex justify-between items-center text-sm text-slate-400">
<div class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1.5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd"
d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z"
clip-rule="evenodd" />
</svg>
<span>{{ bet().startDate | date:'dd MMM' }}</span>
@if (bet().endDate) {
<span class="mx-1.5">&rarr;</span>
<span>{{ bet().endDate | date:'dd MMM' }}</span>
}
</div>
<div class="font-semibold text-cyan-400">
{{ getTimeRemaining() }}
</div>
</footer>
</div>
}
@@ -0,0 +1,63 @@
import { Component, ChangeDetectionStrategy, input, computed } from '@angular/core';
import { CommonModule, DatePipe } from '@angular/common';
import { Bet, BetStatus, BetRole } from '../../../core/models/bet.model';
import { getUserId, getUserName, isUser } from '../../../core/utils/type-guards';
@Component({
selector: 'app-bet-card',
standalone: true,
imports: [CommonModule, DatePipe],
templateUrl: './bet-card.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class BetCardComponent {
protected getUserId = getUserId;
protected getUserName = getUserName;
protected isUser = isUser;
bet = input.required<Bet>();
participants = computed(() => this.bet().participants.filter(p => p.role === BetRole.Participant));
observers = computed(() => this.bet().participants.filter(p => p.role === BetRole.Observer));
observerNamesTitle = computed(() => {
const observers = this.observers();
if (observers.length === 0) {
return '';
}
const observerNames = observers.map(o => getUserName(o.user)).join(', ');
return 'Osservatori: ' + observerNames;
});
statusInfo = computed(() => {
const bet = this.bet();
switch (bet.status) {
case BetStatus.Active:
return { text: 'Attiva', color: 'bg-green-500' };
case BetStatus.AwaitingValidation:
return { text: 'In Convalida', color: 'bg-yellow-500' };
case BetStatus.Completed:
return { text: 'Completata', color: 'bg-blue-500' };
case BetStatus.Expired:
return { text: 'Scaduta', color: 'bg-red-500' };
default:
return { text: 'Sconosciuto', color: 'bg-gray-500' };
}
});
getTimeRemaining() {
if (!this.bet().endDate) {
return 'Senza scadenza';
}
const diff = this.bet().endDate!.getTime() - new Date().getTime();
if (diff < 0) {
return 'Scaduta';
}
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
if (days > 0) return `${days}g rimasti`;
if (hours > 0) return `${hours}h rimaste`;
return `${minutes}m rimasti`;
}
}
+33
View File
@@ -0,0 +1,33 @@
{
"compilerOptions": {
"target": "ES2022",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
"types": [
"node"
],
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": true,
"jsx": "react-jsx",
"paths": {
"@/*": [
"./*"
]
}
},
"files": [
"./index.tsx"
],
"angularCompilerOptions": {
"disableTypeScriptVersionCheck": true
}
}
+9
View File
@@ -0,0 +1,9 @@
# GEMINI_API_KEY: Required for Gemini AI API calls.
# AI Studio automatically injects this at runtime from user secrets.
# Users configure this via the Secrets panel in the AI Studio UI.
GEMINI_API_KEY="MY_GEMINI_API_KEY"
# APP_URL: The URL where this applet is hosted.
# AI Studio automatically injects this at runtime with the Cloud Run service URL.
# Used for self-referential links, OAuth callbacks, and API endpoints.
APP_URL="MY_APP_URL"
+47
View File
@@ -0,0 +1,47 @@
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
# Env variables
.env*
!.env.example
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Node
node_modules/
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
__screenshots__/
# System files
.DS_Store
Thumbs.db
+5
View File
@@ -0,0 +1,5 @@
{
"plugins": {
"@tailwindcss/postcss": {}
}
}
+36
View File
@@ -0,0 +1,36 @@
# ========================================
# Frontend Angular Dockerfile (multi-stage)
# ========================================
FROM node:20-alpine AS base
WORKDIR /app
COPY package*.json ./
# ========================================
# Development: ng serve con hot reload
# ========================================
FROM base AS development
RUN npm install
COPY . .
EXPOSE 4200
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
# ========================================
# Build: bundle statico di produzione
# ========================================
FROM base AS build
RUN npm install
COPY . .
RUN npm run build
# Nota: la build SSR (server.ts/main.server.ts) non è al momento agganciata
# ad angular.json ("server" builder option assente), quindi ng build produce
# solo il bundle browser in dist/app/browser/. Se in futuro si abilita la SSR,
# questo stage andrà aggiornato per servire dist/app/server con Node invece di nginx.
# ========================================
# Production: bundle statico servito da nginx
# ========================================
FROM nginx:1.27-alpine AS production
COPY --from=build /app/dist/app/browser /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
+20
View File
@@ -0,0 +1,20 @@
<div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
</div>
# Run and deploy your AI Studio app
This contains everything you need to run your app locally.
View your app in AI Studio: https://ai.studio/apps/7d951078-743c-4db5-ac20-6a2c625411e7
## Run Locally
**Prerequisites:** Node.js
1. Install dependencies:
`npm install`
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
3. Run the app:
`npm run dev`
+94
View File
@@ -0,0 +1,94 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"cli": {
"packageManager": "npm",
"schematicCollections": [
"angular-eslint"
]
},
"newProjectRoot": "projects",
"projects": {
"app": {
"projectType": "application",
"schematics": {},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"define": {
"GEMINI_API_KEY": "'YOUR_GEMINI_API_KEY'"
},
"browser": "src/main.ts",
"tsConfig": "tsconfig.app.json",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"src/styles.css"
]
},
"configurations": {
"production": {
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts"
}
],
"budgets": [
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "1MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular/build:dev-server",
"configurations": {
"production": {
"buildTarget": "app:build:production"
},
"development": {
"buildTarget": "app:build:development"
}
},
"defaultConfiguration": "development"
},
"test": {
"builder": "@angular/build:unit-test"
},
"lint": {
"builder": "@angular-eslint/builder:lint",
"options": {
"lintFilePatterns": [
"src/**/*.ts",
"src/**/*.html"
]
}
}
}
}
}
}
+44
View File
@@ -0,0 +1,44 @@
// @ts-check
const eslint = require('@eslint/js');
const {defineConfig} = require('eslint/config');
const tseslint = require('typescript-eslint');
const angular = require('angular-eslint');
module.exports = defineConfig([
{
files: ['**/*.ts'],
extends: [
eslint.configs.recommended,
tseslint.configs.recommended,
tseslint.configs.stylistic,
angular.configs.tsRecommended,
],
processor: angular.processInlineTemplates,
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'app',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'app',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
extends: [
angular.configs.templateRecommended,
angular.configs.templateAccessibility,
],
rules: {},
}
]);
+5
View File
@@ -0,0 +1,5 @@
{
"name": "ScommettiApp",
"description": "A social betting and challenge platform to compete with friends and join communities.",
"requestFramePermissions": ["geolocation"]
}
+21
View File
@@ -0,0 +1,21 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
# Asset con hash nel nome: cache lunga
location ~* "\.[0-9a-f]{8,}\.(js|css|svg|png|jpg|jpeg|gif|webp|woff2?)$" {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Fallback per il routing lato client di Angular
location / {
try_files $uri $uri/ /index.html;
}
}
+12338
View File
File diff suppressed because it is too large Load Diff
+46
View File
@@ -0,0 +1,46 @@
{
"name": "amici-scommesse",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "ng serve",
"build": "ng build",
"preview": "ng serve --configuration=production"
},
"dependencies": {
"@angular/cdk": "^21.0.0",
"@angular/common": "^21.0.0",
"@angular/compiler": "^21.0.0",
"@angular/core": "^21.0.0",
"@angular/forms": "^21.0.0",
"@angular/material": "^21.0.0",
"@angular/platform-browser": "^21.0.0",
"@angular/platform-server": "^21.0.0",
"@angular/router": "^21.0.0",
"@angular/ssr": "^21.0.0",
"@google/genai": "^1.27.0",
"express": "^5.1.0",
"keycloak-js": "^26.2.3",
"motion": "^12.23.24",
"rxjs": "~7.8.0",
"tslib": "^2.3.0"
},
"devDependencies": {
"@angular/build": "^21.0.0",
"@angular/cli": "^21.0.0",
"@angular/compiler-cli": "^21.0.0",
"@tailwindcss/postcss": "^4.1.12",
"@types/express": "^5.0.1",
"@types/node": "^20.17.19",
"angular-eslint": "21.1.0",
"cross-env": "^10.1.0",
"eslint": "^9.39.1",
"jsdom": "^27.0.0",
"postcss": "^8.5.3",
"tailwindcss": "^4.1.12",
"typescript": "~5.9.2",
"typescript-eslint": "8.47.0",
"vitest": "^4.0.0"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

@@ -0,0 +1,10 @@
import {ApplicationConfig, mergeApplicationConfig} from '@angular/core';
import {provideServerRendering, withRoutes} from '@angular/ssr';
import {appConfig} from './app.config';
import {serverRoutes} from './app.routes.server';
const serverConfig: ApplicationConfig = {
providers: [provideServerRendering(withRoutes(serverRoutes))],
};
export const config = mergeApplicationConfig(appConfig, serverConfig);
+19
View File
@@ -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,
},
];
+27
View File
@@ -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>

Some files were not shown because too many files have changed in this diff Show More