Compare commits
10
Commits
18e0e04700
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c034c59186 | ||
|
|
04ba3d541f | ||
|
|
f4afaf0792 | ||
|
|
6d52968c28 | ||
|
|
0ed70eaa18 | ||
|
|
3d4eac4ea5 | ||
|
|
aa5ca9bbe3 | ||
|
|
162e0de62a | ||
|
|
e2fe8b264f | ||
|
|
8752acd2c4 |
@@ -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
|
||||||
@@ -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
@@ -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/
|
||||||
@@ -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
|
||||||
|
|
||||||
# ========================================
|
# ========================================
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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' });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
Generated
+205
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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;
|
|
||||||
};
|
|
||||||
@@ -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,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;
|
|
||||||
};
|
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -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`
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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": []
|
||||||
|
}
|
||||||
Generated
+7566
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||||
@@ -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">→</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`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# GEMINI_API_KEY: Required for Gemini AI API calls.
|
||||||
|
# AI Studio automatically injects this at runtime from user secrets.
|
||||||
|
# Users configure this via the Secrets panel in the AI Studio UI.
|
||||||
|
GEMINI_API_KEY="MY_GEMINI_API_KEY"
|
||||||
|
|
||||||
|
# APP_URL: The URL where this applet is hosted.
|
||||||
|
# AI Studio automatically injects this at runtime with the Cloud Run service URL.
|
||||||
|
# Used for self-referential links, OAuth callbacks, and API endpoints.
|
||||||
|
APP_URL="MY_APP_URL"
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
|
||||||
|
|
||||||
|
# Env variables
|
||||||
|
.env*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Compiled output
|
||||||
|
/dist
|
||||||
|
/tmp
|
||||||
|
/out-tsc
|
||||||
|
/bazel-out
|
||||||
|
|
||||||
|
# Node
|
||||||
|
node_modules/
|
||||||
|
npm-debug.log
|
||||||
|
yarn-error.log
|
||||||
|
|
||||||
|
# IDEs and editors
|
||||||
|
.idea/
|
||||||
|
.project
|
||||||
|
.classpath
|
||||||
|
.c9/
|
||||||
|
*.launch
|
||||||
|
.settings/
|
||||||
|
*.sublime-workspace
|
||||||
|
|
||||||
|
# Visual Studio Code
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/tasks.json
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.history/*
|
||||||
|
|
||||||
|
# Miscellaneous
|
||||||
|
/.angular/cache
|
||||||
|
.sass-cache/
|
||||||
|
/connect.lock
|
||||||
|
/coverage
|
||||||
|
/libpeerconnection.log
|
||||||
|
testem.log
|
||||||
|
/typings
|
||||||
|
__screenshots__/
|
||||||
|
|
||||||
|
# System files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"plugins": {
|
||||||
|
"@tailwindcss/postcss": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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`
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: {},
|
||||||
|
}
|
||||||
|
]);
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"name": "ScommettiApp",
|
||||||
|
"description": "A social betting and challenge platform to compete with friends and join communities.",
|
||||||
|
"requestFramePermissions": ["geolocation"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
||||||
|
|
||||||
|
# Asset con hash nel nome: cache lunga
|
||||||
|
location ~* "\.[0-9a-f]{8,}\.(js|css|svg|png|jpg|jpeg|gif|webp|woff2?)$" {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fallback per il routing lato client di Angular
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+12338
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"name": "amici-scommesse",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "ng serve",
|
||||||
|
"build": "ng build",
|
||||||
|
"preview": "ng serve --configuration=production"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@angular/cdk": "^21.0.0",
|
||||||
|
"@angular/common": "^21.0.0",
|
||||||
|
"@angular/compiler": "^21.0.0",
|
||||||
|
"@angular/core": "^21.0.0",
|
||||||
|
"@angular/forms": "^21.0.0",
|
||||||
|
"@angular/material": "^21.0.0",
|
||||||
|
"@angular/platform-browser": "^21.0.0",
|
||||||
|
"@angular/platform-server": "^21.0.0",
|
||||||
|
"@angular/router": "^21.0.0",
|
||||||
|
"@angular/ssr": "^21.0.0",
|
||||||
|
"@google/genai": "^1.27.0",
|
||||||
|
"express": "^5.1.0",
|
||||||
|
"keycloak-js": "^26.2.3",
|
||||||
|
"motion": "^12.23.24",
|
||||||
|
"rxjs": "~7.8.0",
|
||||||
|
"tslib": "^2.3.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@angular/build": "^21.0.0",
|
||||||
|
"@angular/cli": "^21.0.0",
|
||||||
|
"@angular/compiler-cli": "^21.0.0",
|
||||||
|
"@tailwindcss/postcss": "^4.1.12",
|
||||||
|
"@types/express": "^5.0.1",
|
||||||
|
"@types/node": "^20.17.19",
|
||||||
|
"angular-eslint": "21.1.0",
|
||||||
|
"cross-env": "^10.1.0",
|
||||||
|
"eslint": "^9.39.1",
|
||||||
|
"jsdom": "^27.0.0",
|
||||||
|
"postcss": "^8.5.3",
|
||||||
|
"tailwindcss": "^4.1.12",
|
||||||
|
"typescript": "~5.9.2",
|
||||||
|
"typescript-eslint": "8.47.0",
|
||||||
|
"vitest": "^4.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
@@ -0,0 +1,10 @@
|
|||||||
|
import {ApplicationConfig, mergeApplicationConfig} from '@angular/core';
|
||||||
|
import {provideServerRendering, withRoutes} from '@angular/ssr';
|
||||||
|
import {appConfig} from './app.config';
|
||||||
|
import {serverRoutes} from './app.routes.server';
|
||||||
|
|
||||||
|
const serverConfig: ApplicationConfig = {
|
||||||
|
providers: [provideServerRendering(withRoutes(serverRoutes))],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const config = mergeApplicationConfig(appConfig, serverConfig);
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import {
|
||||||
|
ApplicationConfig,
|
||||||
|
provideBrowserGlobalErrorListeners,
|
||||||
|
provideZonelessChangeDetection,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { provideRouter } from '@angular/router';
|
||||||
|
import { provideHttpClient, withInterceptors, withFetch } from '@angular/common/http';
|
||||||
|
import { authInterceptor } from './core/interceptors/auth.interceptor';
|
||||||
|
|
||||||
|
import { routes } from './app.routes';
|
||||||
|
|
||||||
|
export const appConfig: ApplicationConfig = {
|
||||||
|
providers: [
|
||||||
|
provideZonelessChangeDetection(),
|
||||||
|
provideBrowserGlobalErrorListeners(),
|
||||||
|
provideRouter(routes),
|
||||||
|
provideHttpClient(withFetch(), withInterceptors([authInterceptor])),
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import {RenderMode, ServerRoute} from '@angular/ssr';
|
||||||
|
|
||||||
|
export const serverRoutes: ServerRoute[] = [
|
||||||
|
{
|
||||||
|
path: '**',
|
||||||
|
renderMode: RenderMode.Client,
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { Routes } from '@angular/router';
|
||||||
|
import { Home } from './components/home/home';
|
||||||
|
import { Groups } from './components/groups/groups';
|
||||||
|
import { Explore } from './components/explore/explore';
|
||||||
|
import { Profile } from './components/profile/profile';
|
||||||
|
import { Activity } from './components/activity/activity';
|
||||||
|
import { ChallengeDetail } from './components/challenge-detail/challenge-detail';
|
||||||
|
import { GroupDetail } from './components/group-detail/group-detail';
|
||||||
|
import { CreateGroup } from './components/create-group/create-group';
|
||||||
|
import { CreateBet } from './components/create-bet/create-bet';
|
||||||
|
import { Settings } from './components/settings/settings';
|
||||||
|
import { BetHistory } from './components/bet-history/bet-history';
|
||||||
|
|
||||||
|
export const routes: Routes = [
|
||||||
|
{ path: '', component: Home },
|
||||||
|
{ path: 'groups', component: Groups },
|
||||||
|
{ path: 'explore', component: Explore },
|
||||||
|
{ path: 'profile', component: Profile },
|
||||||
|
{ path: 'activity', component: Activity },
|
||||||
|
{ path: 'challenge-detail/:id', component: ChallengeDetail },
|
||||||
|
{ path: 'group-detail/:id', component: GroupDetail },
|
||||||
|
{ path: 'create-group', component: CreateGroup },
|
||||||
|
{ path: 'create-bet', component: CreateBet },
|
||||||
|
{ path: 'settings', component: Settings },
|
||||||
|
{ path: 'bet-history', component: BetHistory },
|
||||||
|
{ path: '**', redirectTo: '' },
|
||||||
|
];
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
/* Activity component styles */
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<div class="min-h-screen pb-32">
|
||||||
|
<header class="fixed top-0 left-0 w-full z-50 bg-background/80 backdrop-blur-md flex justify-between items-center px-6 py-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button class="w-10 h-10 flex items-center justify-center rounded-full hover:bg-surface-container transition-colors" [routerLink]="['/']">
|
||||||
|
<mat-icon class="text-primary">arrow_back</mat-icon>
|
||||||
|
</button>
|
||||||
|
<h1 class="font-headline font-black tracking-tight text-xl text-primary">Activity</h1>
|
||||||
|
</div>
|
||||||
|
<button class="w-10 h-10 flex items-center justify-center rounded-full hover:bg-surface-container transition-colors">
|
||||||
|
<mat-icon class="text-primary">search</mat-icon>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="pt-24 px-6 max-w-2xl mx-auto">
|
||||||
|
<!-- Stats -->
|
||||||
|
<div class="grid grid-cols-2 gap-4 mb-8">
|
||||||
|
<div class="bg-surface-container-low p-6 rounded-2xl flex flex-col justify-between aspect-square border border-black/5">
|
||||||
|
<mat-icon class="text-primary text-3xl">celebration</mat-icon>
|
||||||
|
<div>
|
||||||
|
<p class="text-3xl font-headline font-bold">12</p>
|
||||||
|
<p class="text-sm text-on-surface-variant font-medium">New Alerts</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-secondary-container p-6 rounded-2xl flex flex-col justify-between aspect-square border border-black/5">
|
||||||
|
<mat-icon class="text-secondary text-3xl">trending_up</mat-icon>
|
||||||
|
<div>
|
||||||
|
<p class="text-3xl font-headline font-bold text-secondary">84%</p>
|
||||||
|
<p class="text-sm text-secondary/80 font-medium">Win Rate</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Timeline -->
|
||||||
|
<div class="space-y-12">
|
||||||
|
<section>
|
||||||
|
<h2 class="text-xs font-headline font-extrabold uppercase tracking-widest text-on-surface-variant mb-8 px-2">Recent Activity</h2>
|
||||||
|
<div class="space-y-10">
|
||||||
|
@for (activity of activities(); track $index) {
|
||||||
|
<div class="relative pl-16">
|
||||||
|
<div class="absolute left-0 top-0 w-12 h-12 rounded-full flex items-center justify-center shadow-lg z-10"
|
||||||
|
[ngClass]="{
|
||||||
|
'bg-secondary-container text-secondary': activity.type === 'win',
|
||||||
|
'bg-error-container text-error': activity.type === 'loss',
|
||||||
|
'bg-tertiary-container text-tertiary': activity.type === 'group_joined'
|
||||||
|
}">
|
||||||
|
<mat-icon style="font-variation-settings: 'FILL' 1;">
|
||||||
|
{{ activity.type === 'win' ? 'trophy' : activity.type === 'loss' ? 'trending_down' : 'group_add' }}
|
||||||
|
</mat-icon>
|
||||||
|
</div>
|
||||||
|
<div class="absolute left-6 top-12 bottom-[-40px] w-0.5 bg-surface-container"></div>
|
||||||
|
<div class="bg-surface p-5 rounded-2xl soft-shadow border border-black/5">
|
||||||
|
<p class="leading-relaxed">
|
||||||
|
@if (activity.type === 'win') {
|
||||||
|
You won the <span class="font-bold">{{ activity.title }}!</span> {{ activity.amount }} Arena Points have been credited.
|
||||||
|
} @else if (activity.type === 'loss') {
|
||||||
|
You lost the <span class="font-bold">{{ activity.title }}.</span> {{ activity.amount }} Arena Points have been deducted.
|
||||||
|
} @else {
|
||||||
|
You joined the <span class="font-bold">{{ activity.title }}</span> challenge.
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
<p class="mt-3 text-[11px] font-medium text-on-surface-variant/50">{{ activity.timestamp }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import {ChangeDetectionStrategy, Component, inject} from '@angular/core';
|
||||||
|
import {CommonModule} from '@angular/common';
|
||||||
|
import {MatIconModule} from '@angular/material/icon';
|
||||||
|
import {RouterModule} from '@angular/router';
|
||||||
|
import {toSignal} from '@angular/core/rxjs-interop';
|
||||||
|
import {UserService} from '../../services/user';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-activity',
|
||||||
|
imports: [CommonModule, MatIconModule, RouterModule],
|
||||||
|
templateUrl: './activity.html',
|
||||||
|
styleUrl: './activity.css',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class Activity {
|
||||||
|
private userService = inject(UserService);
|
||||||
|
activities = toSignal(this.userService.getActivities(), {initialValue: []});
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
/* App component styles */
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<div class="min-h-screen bg-background text-on-background font-sans transition-colors duration-300">
|
||||||
|
<router-outlet></router-outlet>
|
||||||
|
|
||||||
|
<!-- Bottom Navigation Bar -->
|
||||||
|
@if (showBottomNav$ | async) {
|
||||||
|
<nav class="fixed bottom-0 left-0 w-full z-50 flex justify-around items-center px-4 pb-6 pt-2 bg-surface/70 backdrop-blur-xl rounded-t-[24px] shadow-[0_-12px_32px_-8px_rgba(50,41,79,0.08)] border-t border-black/5">
|
||||||
|
<a class="flex flex-col items-center justify-center px-4 py-1 transition-colors rounded-full"
|
||||||
|
routerLink="/home" routerLinkActive="bg-primary/10 text-primary" [routerLinkActiveOptions]="{exact: true}">
|
||||||
|
<mat-icon [style.font-variation-settings]="fillIcon">home</mat-icon>
|
||||||
|
<span class="font-headline font-medium text-[11px]">{{ t('nav.home') }}</span>
|
||||||
|
</a>
|
||||||
|
<a class="flex flex-col items-center justify-center px-4 py-1 transition-colors rounded-full text-on-surface-variant"
|
||||||
|
routerLink="/groups" routerLinkActive="bg-primary/10 text-primary">
|
||||||
|
<mat-icon [style.font-variation-settings]="fillIcon">group</mat-icon>
|
||||||
|
<span class="font-headline font-medium text-[11px]">{{ t('nav.groups') }}</span>
|
||||||
|
</a>
|
||||||
|
<a class="flex flex-col items-center justify-center px-4 py-1 transition-colors rounded-full text-on-surface-variant"
|
||||||
|
routerLink="/create-bet" routerLinkActive="bg-primary/10 text-primary">
|
||||||
|
<mat-icon [style.font-variation-settings]="fillIcon">add_circle</mat-icon>
|
||||||
|
<span class="font-headline font-medium text-[11px]">{{ t('nav.create') }}</span>
|
||||||
|
</a>
|
||||||
|
<a class="flex flex-col items-center justify-center px-4 py-1 transition-colors rounded-full text-on-surface-variant"
|
||||||
|
routerLink="/explore" routerLinkActive="bg-primary/10 text-primary">
|
||||||
|
<mat-icon [style.font-variation-settings]="fillIcon">public</mat-icon>
|
||||||
|
<span class="font-headline font-medium text-[11px]">{{ t('nav.explore') }}</span>
|
||||||
|
</a>
|
||||||
|
<a class="flex flex-col items-center justify-center px-4 py-1 transition-colors rounded-full text-on-surface-variant"
|
||||||
|
routerLink="/profile" routerLinkActive="bg-primary/10 text-primary">
|
||||||
|
<mat-icon [style.font-variation-settings]="fillIcon">person</mat-icon>
|
||||||
|
<span class="font-headline font-medium text-[11px]">{{ t('nav.profile') }}</span>
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user