Compare commits
19 Commits
61f0945db2
...
docker
| Author | SHA1 | Date | |
|---|---|---|---|
| e18a09e1da | |||
| f3262a0497 | |||
| 690f7e636a | |||
| 5b95f18889 | |||
| 45dd205270 | |||
| 0764b0b625 | |||
| 8befcb8abf | |||
| 89aed8a458 | |||
| ae861e78d2 | |||
| 77eb9943d0 | |||
| a0ec72f6c0 | |||
| 21a1d57cab | |||
| 2833cd0487 | |||
| 865bfa2752 | |||
| 70ed8f0a61 | |||
| ed42a41bcd | |||
| 6a3f931431 | |||
| ef1bc92e67 | |||
| 343f171d6a |
+20
-30
@@ -3,11 +3,21 @@
|
|||||||
|
|
||||||
# === ASP.NET Core Configuration ===
|
# === ASP.NET Core Configuration ===
|
||||||
ASPNETCORE_ENVIRONMENT=Production
|
ASPNETCORE_ENVIRONMENT=Production
|
||||||
ASPNETCORE_URLS=http://+:5000;https://+:5001
|
ASPNETCORE_URLS=http://+:8080
|
||||||
|
|
||||||
# === HTTPS Certificate ===
|
# === AUTENTICAZIONE APPLICAZIONE (SICUREZZA) ===
|
||||||
# Password per il certificato PFX
|
# Username amministratore
|
||||||
CERT_PASSWORD=AutoBidder2024
|
ADMIN_USERNAME=admin
|
||||||
|
|
||||||
|
# Password amministratore (OBBLIGATORIO in produzione!)
|
||||||
|
# REQUISITI: min 12 caratteri, maiuscole, minuscole, numeri, simboli
|
||||||
|
# Esempio: Admin@SecurePass2024!
|
||||||
|
ADMIN_PASSWORD=
|
||||||
|
|
||||||
|
# === NOTA: SESSIONE BIDOO ===
|
||||||
|
# Non servono credenziali Bidoo!
|
||||||
|
# Il cookie di sessione Bidoo viene configurato manualmente
|
||||||
|
# dall'interfaccia web in Settings ? Sessione Bidoo
|
||||||
|
|
||||||
# === PostgreSQL Database (Statistiche) ===
|
# === PostgreSQL Database (Statistiche) ===
|
||||||
# Username PostgreSQL
|
# Username PostgreSQL
|
||||||
@@ -20,34 +30,14 @@ POSTGRES_PASSWORD=autobidder_password
|
|||||||
POSTGRES_DB=autobidder_stats
|
POSTGRES_DB=autobidder_stats
|
||||||
|
|
||||||
# Usa PostgreSQL per statistiche (true/false)
|
# Usa PostgreSQL per statistiche (true/false)
|
||||||
DATABASE_USE_POSTGRES=true
|
USE_POSTGRES=true
|
||||||
|
|
||||||
# Auto-crea schema PostgreSQL se mancante (true/false)
|
# === Application Settings ===
|
||||||
DATABASE_AUTO_CREATE_SCHEMA=true
|
# Logging level (Debug, Information, Warning, Error)
|
||||||
|
LOG_LEVEL=Information
|
||||||
|
|
||||||
# Fallback a SQLite se PostgreSQL non disponibile (true/false)
|
# Porta applicazione (default: 8080 container, mappata su host)
|
||||||
DATABASE_FALLBACK_TO_SQLITE=true
|
APP_PORT=5000
|
||||||
|
|
||||||
# === Gitea Container Registry ===
|
|
||||||
# URL del registry (senza https://)
|
|
||||||
GITEA_REGISTRY=192.168.30.23/Alby96
|
|
||||||
|
|
||||||
# Username Gitea
|
|
||||||
GITEA_USERNAME=Alby96
|
|
||||||
|
|
||||||
# Access Token Gitea (genera su: https://192.168.30.23/user/settings/applications)
|
|
||||||
# Scope richiesti: write:package, read:package
|
|
||||||
GITEA_PASSWORD=ghp_your_token_here
|
|
||||||
|
|
||||||
# === Deployment Configuration ===
|
|
||||||
# IP o hostname del server di deploy
|
|
||||||
DEPLOY_HOST=192.168.30.23
|
|
||||||
|
|
||||||
# User SSH per deploy
|
|
||||||
DEPLOY_USER=deploy
|
|
||||||
|
|
||||||
# Path alla chiave privata SSH (per CI/CD)
|
|
||||||
# DEPLOY_SSH_KEY_PATH=/path/to/ssh/key
|
|
||||||
|
|
||||||
# === Database Configuration ===
|
# === Database Configuration ===
|
||||||
# Path database SQLite locale (default: /app/data/autobidder.db in container)
|
# Path database SQLite locale (default: /app/data/autobidder.db in container)
|
||||||
|
|||||||
+18
-5
@@ -1,6 +1,18 @@
|
|||||||
<Router AppAssembly="@typeof(App).Assembly">
|
<CascadingAuthenticationState>
|
||||||
|
<Router AppAssembly="@typeof(App).Assembly">
|
||||||
<Found Context="routeData">
|
<Found Context="routeData">
|
||||||
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
|
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)">
|
||||||
|
<NotAuthorized>
|
||||||
|
@if (context.User.Identity?.IsAuthenticated != true)
|
||||||
|
{
|
||||||
|
<RedirectToLogin />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<p>Non sei autorizzato ad accedere a questa risorsa.</p>
|
||||||
|
}
|
||||||
|
</NotAuthorized>
|
||||||
|
</AuthorizeRouteView>
|
||||||
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
|
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
|
||||||
</Found>
|
</Found>
|
||||||
<NotFound>
|
<NotFound>
|
||||||
@@ -13,11 +25,12 @@
|
|||||||
<line x1="12" y1="16" x2="12.01" y2="16"></line>
|
<line x1="12" y1="16" x2="12.01" y2="16"></line>
|
||||||
</svg>
|
</svg>
|
||||||
<h1 style="font-size: 1.5rem; margin-bottom: 0.5rem;">Pagina non trovata</h1>
|
<h1 style="font-size: 1.5rem; margin-bottom: 0.5rem;">Pagina non trovata</h1>
|
||||||
<p style="color: var(--text-muted);">Spiacenti, non c'e' nulla a questo indirizzo.</p>
|
<p style="color: var(--text-muted);">Spiacenti, non c'è nulla a questo indirizzo.</p>
|
||||||
<a href="/" style="color: var(--primary-color); text-decoration: none; margin-top: 1rem; display: inline-block;">
|
<a href="/" style="color: var(--primary-color); text-decoration: none; margin-top: 1rem; display: inline-block;">
|
||||||
? Torna alla Home
|
?? Torna alla Home
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</LayoutView>
|
</LayoutView>
|
||||||
</NotFound>
|
</NotFound>
|
||||||
</Router>
|
</Router>
|
||||||
|
</CascadingAuthenticationState>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
@@ -7,17 +7,21 @@
|
|||||||
<AssemblyName>AutoBidder</AssemblyName>
|
<AssemblyName>AutoBidder</AssemblyName>
|
||||||
<RootNamespace>AutoBidder</RootNamespace>
|
<RootNamespace>AutoBidder</RootNamespace>
|
||||||
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||||
|
<DockerfileContext>.</DockerfileContext>
|
||||||
|
<DockerfileFile>Dockerfile</DockerfileFile>
|
||||||
|
|
||||||
<!-- Versioning per Docker & Gitea Registry -->
|
<!-- Versioning per Docker & Gitea Registry -->
|
||||||
<Version>1.0.0</Version>
|
<!-- v1.3.0: Database management + bug fixes (duplicates, race conditions, warnings) -->
|
||||||
<AssemblyVersion>1.0.0.0</AssemblyVersion>
|
<Version>1.3.0</Version>
|
||||||
<FileVersion>1.0.0.0</FileVersion>
|
<AssemblyVersion>1.3.0.0</AssemblyVersion>
|
||||||
<InformationalVersion>1.0.0</InformationalVersion>
|
<FileVersion>1.3.0.0</FileVersion>
|
||||||
|
<InformationalVersion>1.3.0</InformationalVersion>
|
||||||
|
|
||||||
<!-- Metadata immagine Docker -->
|
<!-- Metadata immagine Docker -->
|
||||||
<ContainerImageName>autobidder</ContainerImageName>
|
<ContainerImageName>autobidder</ContainerImageName>
|
||||||
<ContainerImageTag>$(Version)</ContainerImageTag>
|
<ContainerImageTag>$(Version)</ContainerImageTag>
|
||||||
<ContainerRegistry>gitea.encke-hake.ts.net/alby96/mimante</ContainerRegistry>
|
<!-- CORRETTO: Convenzione Gitea {registro}/{proprietario}/{immagine} -->
|
||||||
|
<ContainerRegistry>gitea.encke-hake.ts.net/alby96</ContainerRegistry>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -63,6 +67,7 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.0" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.0" />
|
||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.0" />
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.0" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -76,6 +81,77 @@
|
|||||||
<None Include=".gitea\workflows\deploy.yml" />
|
<None Include=".gitea\workflows\deploy.yml" />
|
||||||
<None Include=".gitea\workflows\health-check.yml" />
|
<None Include=".gitea\workflows\health-check.yml" />
|
||||||
<None Include=".github\workflows\ci-cd.yml" />
|
<None Include=".github\workflows\ci-cd.yml" />
|
||||||
|
<None Include="Dockerfile" />
|
||||||
|
<None Include=".dockerignore" />
|
||||||
|
<None Include="Properties\PublishProfiles\GiteaRegistry-Versioned.pubxml.user" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!-- ============================================ -->
|
||||||
|
<!-- POST-BUILD TARGET: Push automatico su Gitea -->
|
||||||
|
<!-- con versionamento da <Version> della solution -->
|
||||||
|
<!-- ============================================ -->
|
||||||
|
<Target Name="PushDockerImageToGitea" AfterTargets="Publish" Condition="'$(PushToGiteaRegistry)' == 'true'">
|
||||||
|
<PropertyGroup>
|
||||||
|
<GiteaRegistry>gitea.encke-hake.ts.net/alby96</GiteaRegistry>
|
||||||
|
<LocalImageName>autobidder</LocalImageName>
|
||||||
|
<GiteaImageLatest>$(GiteaRegistry)/$(LocalImageName):latest</GiteaImageLatest>
|
||||||
|
<GiteaImageVersion>$(GiteaRegistry)/$(LocalImageName):$(Version)</GiteaImageVersion>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<Message Importance="high" Text="" />
|
||||||
|
<Message Importance="high" Text="+-------------------------------------------------------------------+" />
|
||||||
|
<Message Importance="high" Text="¦ POST-BUILD: Pubblicazione su Gitea Container Registry ¦" />
|
||||||
|
<Message Importance="high" Text="+-------------------------------------------------------------------+" />
|
||||||
|
<Message Importance="high" Text="" />
|
||||||
|
<Message Importance="high" Text="?? Solution Version: $(Version)" />
|
||||||
|
<Message Importance="high" Text="?? Local Image: $(LocalImageName):latest" />
|
||||||
|
<Message Importance="high" Text="??? Target Tags:" />
|
||||||
|
<Message Importance="high" Text=" • $(GiteaImageLatest)" />
|
||||||
|
<Message Importance="high" Text=" • $(GiteaImageVersion)" />
|
||||||
|
<Message Importance="high" Text="" />
|
||||||
|
<Message Importance="high" Text="-------------------------------------------------------------------" />
|
||||||
|
<Message Importance="high" Text="??? Tagging images..." />
|
||||||
|
<Message Importance="high" Text="-------------------------------------------------------------------" />
|
||||||
|
|
||||||
|
<!-- Tag immagine locale per Gitea (latest) -->
|
||||||
|
<Exec Command="docker tag $(LocalImageName):latest $(GiteaImageLatest)" />
|
||||||
|
<Message Importance="high" Text="? Tagged: $(GiteaImageLatest)" />
|
||||||
|
|
||||||
|
<!-- Tag immagine locale per Gitea (versione solution) -->
|
||||||
|
<Exec Command="docker tag $(LocalImageName):latest $(GiteaImageVersion)" />
|
||||||
|
<Message Importance="high" Text="? Tagged: $(GiteaImageVersion)" />
|
||||||
|
|
||||||
|
<Message Importance="high" Text="" />
|
||||||
|
<Message Importance="high" Text="-------------------------------------------------------------------" />
|
||||||
|
<Message Importance="high" Text="?? Pushing to Gitea Registry..." />
|
||||||
|
<Message Importance="high" Text="-------------------------------------------------------------------" />
|
||||||
|
|
||||||
|
<!-- Push latest -->
|
||||||
|
<Exec Command="docker push $(GiteaImageLatest)" />
|
||||||
|
<Message Importance="high" Text="? Pushed: $(GiteaImageLatest)" />
|
||||||
|
|
||||||
|
<!-- Push version -->
|
||||||
|
<Exec Command="docker push $(GiteaImageVersion)" />
|
||||||
|
<Message Importance="high" Text="? Pushed: $(GiteaImageVersion)" />
|
||||||
|
|
||||||
|
<Message Importance="high" Text="" />
|
||||||
|
<Message Importance="high" Text="+-------------------------------------------------------------------+" />
|
||||||
|
<Message Importance="high" Text="¦ ? PUBBLICAZIONE COMPLETATA CON SUCCESSO! ¦" />
|
||||||
|
<Message Importance="high" Text="+-------------------------------------------------------------------+" />
|
||||||
|
<Message Importance="high" Text="" />
|
||||||
|
<Message Importance="high" Text="?? Visualizza su Gitea:" />
|
||||||
|
<Message Importance="high" Text=" https://gitea.encke-hake.ts.net/Alby96/-/packages/container/autobidder" />
|
||||||
|
<Message Importance="high" Text="" />
|
||||||
|
<Message Importance="high" Text="?? Tag pubblicati:" />
|
||||||
|
<Message Importance="high" Text=" • latest (sempre aggiornato all'ultima versione)" />
|
||||||
|
<Message Importance="high" Text=" • $(Version) (versione solution corrente)" />
|
||||||
|
<Message Importance="high" Text="" />
|
||||||
|
<Message Importance="high" Text="?? Pull command:" />
|
||||||
|
<Message Importance="high" Text=" docker pull $(GiteaImageLatest)" />
|
||||||
|
<Message Importance="high" Text=" docker pull $(GiteaImageVersion)" />
|
||||||
|
<Message Importance="high" Text="" />
|
||||||
|
<Message Importance="high" Text="-------------------------------------------------------------------" />
|
||||||
|
<Message Importance="high" Text="" />
|
||||||
|
</Target>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
# ?? AutoBidder - Docker Deploy su Gitea
|
|
||||||
|
|
||||||
Setup minimalista per build e deploy Docker.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ?? Requisiti
|
|
||||||
|
|
||||||
- Docker Desktop running
|
|
||||||
- Login Gitea Registry:
|
|
||||||
```powershell
|
|
||||||
docker login gitea.encke-hake.ts.net
|
|
||||||
# Username: alby96
|
|
||||||
# Password: <personal-access-token>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Genera token**: https://gitea.encke-hake.ts.net/user/settings/applications ? Permissions: `write:packages`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ?? Publish da Visual Studio
|
|
||||||
|
|
||||||
```
|
|
||||||
Build ? Publish ? Docker ? Publish
|
|
||||||
```
|
|
||||||
|
|
||||||
**Automatico**:
|
|
||||||
- Build immagine Docker
|
|
||||||
- Tag: `latest`, `1.0.0`, `1.0.0-20260118`
|
|
||||||
- Push su Gitea Registry
|
|
||||||
|
|
||||||
**Registry**: https://gitea.encke-hake.ts.net/alby96/mimante/-/packages/container/autobidder
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ?? Aggiornare Versione
|
|
||||||
|
|
||||||
Modifica `AutoBidder.csproj`:
|
|
||||||
```xml
|
|
||||||
<PropertyGroup>
|
|
||||||
<Version>1.0.1</Version>
|
|
||||||
</PropertyGroup>
|
|
||||||
```
|
|
||||||
|
|
||||||
Poi publish come sopra.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ?? Deploy Unraid
|
|
||||||
|
|
||||||
### Via Template
|
|
||||||
|
|
||||||
1. Unraid ? Docker ? Add Template
|
|
||||||
2. URL: `https://192.168.30.23/Alby96/Mimante/raw/branch/docker/deployment/unraid-template.xml`
|
|
||||||
3. Install "AutoBidder"
|
|
||||||
4. Configura:
|
|
||||||
- Port: `8888:8080`
|
|
||||||
- AppData: `/mnt/user/appdata/autobidder`
|
|
||||||
- PostgreSQL: `Host=192.168.30.23;Port=5432;...`
|
|
||||||
5. Apply
|
|
||||||
|
|
||||||
### Via Docker Compose
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker-compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
Accesso: http://localhost:8080
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ?? Troubleshooting
|
|
||||||
|
|
||||||
### Publish fallisce: "unauthorized"
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
docker login gitea.encke-hake.ts.net
|
|
||||||
# Retry publish
|
|
||||||
```
|
|
||||||
|
|
||||||
### Container non parte
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
# Verifica porta libera
|
|
||||||
netstat -ano | findstr :8080
|
|
||||||
|
|
||||||
# Rebuild
|
|
||||||
docker build -t test .
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ?? File Configurazione
|
|
||||||
|
|
||||||
| File | Scopo |
|
|
||||||
|------|-------|
|
|
||||||
| `Dockerfile` | Build immagine multi-stage |
|
|
||||||
| `docker-compose.yml` | Deploy con PostgreSQL |
|
|
||||||
| `Properties/PublishProfiles/Docker.pubxml` | Profilo publish Visual Studio |
|
|
||||||
| `deployment/unraid-template.xml` | Template Unraid |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Setup completo! Build ? Publish ? Docker per deployare! ??**
|
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using AutoBidder.Models;
|
||||||
|
|
||||||
|
namespace AutoBidder.Data;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// DbContext per autenticazione Identity
|
||||||
|
/// </summary>
|
||||||
|
public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
|
||||||
|
{
|
||||||
|
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
|
||||||
|
: base(options)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnModelCreating(ModelBuilder builder)
|
||||||
|
{
|
||||||
|
base.OnModelCreating(builder);
|
||||||
|
|
||||||
|
// Personalizza nomi tabelle Identity (opzionale)
|
||||||
|
builder.Entity<ApplicationUser>(entity =>
|
||||||
|
{
|
||||||
|
entity.ToTable("Users");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
-3
@@ -54,17 +54,27 @@ EXPOSE 8080
|
|||||||
# Environment variables (overridable via docker-compose/unraid)
|
# Environment variables (overridable via docker-compose/unraid)
|
||||||
ENV ASPNETCORE_URLS=http://+:8080
|
ENV ASPNETCORE_URLS=http://+:8080
|
||||||
ENV ASPNETCORE_ENVIRONMENT=Production
|
ENV ASPNETCORE_ENVIRONMENT=Production
|
||||||
|
ENV Kestrel__EnableHttps=false
|
||||||
|
|
||||||
|
# Database path - tutti i database SQLite e dati persistenti
|
||||||
|
# Può essere sovrascritto nel docker-compose per mappare un volume persistente
|
||||||
|
ENV DATA_PATH=/app/Data
|
||||||
|
|
||||||
|
# Autenticazione applicazione (OBBLIGATORIO)
|
||||||
|
ENV ADMIN_USERNAME=admin
|
||||||
|
ENV ADMIN_PASSWORD=
|
||||||
|
|
||||||
# Health check
|
# Health check
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
# Aumentato timeout e start-period per Blazor Server
|
||||||
|
HEALTHCHECK --interval=30s --timeout=30s --start-period=90s --retries=5 \
|
||||||
CMD curl -f http://localhost:8080/ || exit 1
|
CMD curl -f http://localhost:8080/ || exit 1
|
||||||
|
|
||||||
# Labels for metadata
|
# Labels for metadata
|
||||||
LABEL org.opencontainers.image.title="AutoBidder" \
|
LABEL org.opencontainers.image.title="AutoBidder" \
|
||||||
org.opencontainers.image.description="Sistema automatizzato gestione aste Bidoo - Blazor .NET 8" \
|
org.opencontainers.image.description="Sistema automatizzato gestione aste Bidoo - Blazor .NET 8" \
|
||||||
org.opencontainers.image.version="1.0.0" \
|
org.opencontainers.image.version="1.2.0" \
|
||||||
org.opencontainers.image.vendor="Alby96" \
|
org.opencontainers.image.vendor="Alby96" \
|
||||||
org.opencontainers.image.source="https://192.168.30.23/Alby96/Mimante"
|
org.opencontainers.image.source="https://gitea.encke-hake.ts.net/Alby96/Mimante"
|
||||||
|
|
||||||
# Entry point
|
# Entry point
|
||||||
ENTRYPOINT ["dotnet", "AutoBidder.dll"]
|
ENTRYPOINT ["dotnet", "AutoBidder.dll"]
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
# Sezione Configurazione Database - Impostazioni
|
|
||||||
|
|
||||||
## ?? Nota Implementazione
|
|
||||||
|
|
||||||
La configurazione del database PostgreSQL è già completamente funzionante tramite:
|
|
||||||
|
|
||||||
1. **appsettings.json** - Connection strings e configurazione
|
|
||||||
2. **AppSettings** (Utilities/SettingsManager.cs) - Proprietà salvate:
|
|
||||||
- `UsePostgreSQL`
|
|
||||||
- `PostgresConnectionString`
|
|
||||||
- `AutoCreateDatabaseSchema`
|
|
||||||
- `FallbackToSQLite`
|
|
||||||
|
|
||||||
3. **Program.cs** - Inizializzazione automatica database
|
|
||||||
|
|
||||||
## ?? UI Settings (Opzionale)
|
|
||||||
|
|
||||||
Se si desidera aggiungere una sezione nella pagina `Settings.razor` per configurare PostgreSQL tramite UI,
|
|
||||||
le proprietà sono già disponibili nel modello `AppSettings`.
|
|
||||||
|
|
||||||
### Esempio Codice UI
|
|
||||||
|
|
||||||
```razor
|
|
||||||
<!-- CONFIGURAZIONE DATABASE -->
|
|
||||||
<div class="card mb-4">
|
|
||||||
<div class="card-header bg-secondary text-white">
|
|
||||||
<h5><i class="bi bi-database-fill"></i> Configurazione Database</h5>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="form-check form-switch mb-3">
|
|
||||||
<input type="checkbox" class="form-check-input" id="usePostgres" @bind="settings.UsePostgreSQL" />
|
|
||||||
<label class="form-check-label" for="usePostgres">
|
|
||||||
Usa PostgreSQL per Statistiche Avanzate
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (settings.UsePostgreSQL)
|
|
||||||
{
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">PostgreSQL Connection String:</label>
|
|
||||||
<input type="text" class="form-control" @bind="settings.PostgresConnectionString" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-check mb-2">
|
|
||||||
<input type="checkbox" class="form-check-input" id="autoCreate" @bind="settings.AutoCreateDatabaseSchema" />
|
|
||||||
<label class="form-check-label" for="autoCreate">
|
|
||||||
Auto-crea schema se mancante
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-check mb-3">
|
|
||||||
<input type="checkbox" class="form-check-input" id="fallback" @bind="settings.FallbackToSQLite" />
|
|
||||||
<label class="form-check-label" for="fallback">
|
|
||||||
Fallback a SQLite se PostgreSQL non disponibile
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<button class="btn btn-secondary" @onclick="SaveSettings">
|
|
||||||
Salva Configurazione Database
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
## ? Stato Attuale
|
|
||||||
|
|
||||||
**Il database PostgreSQL funziona perfettamente configurandolo tramite:**
|
|
||||||
- `appsettings.json` (Development)
|
|
||||||
- Variabili ambiente `.env` (Production/Docker)
|
|
||||||
|
|
||||||
**Non è necessaria una UI se la configurazione rimane statica.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Per maggiori dettagli vedi: `Documentation/POSTGRESQL_SETUP.md`
|
|
||||||
@@ -1,339 +0,0 @@
|
|||||||
# ?? IMPLEMENTAZIONE COMPLETA - PostgreSQL + UI Impostazioni
|
|
||||||
|
|
||||||
## ? **STATO FINALE: 100% COMPLETATO**
|
|
||||||
|
|
||||||
Tutte le funzionalità PostgreSQL sono state implementate e integrate con UI completa nella pagina Impostazioni.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ?? **COMPONENTI IMPLEMENTATI**
|
|
||||||
|
|
||||||
### 1. **Backend PostgreSQL** ?
|
|
||||||
|
|
||||||
| Componente | File | Status |
|
|
||||||
|------------|------|--------|
|
|
||||||
| DbContext | `Data/PostgresStatsContext.cs` | ? Completo |
|
|
||||||
| Modelli | `Models/PostgresModels.cs` | ? 5 entità |
|
|
||||||
| Service | `Services/StatsService.cs` | ? Dual-DB |
|
|
||||||
| Configuration | `Program.cs` | ? Auto-init |
|
|
||||||
| Settings Model | `Utilities/SettingsManager.cs` | ? Proprietà DB |
|
|
||||||
|
|
||||||
### 2. **Frontend UI** ?
|
|
||||||
|
|
||||||
| Componente | File | Descrizione |
|
|
||||||
|------------|------|-------------|
|
|
||||||
| Settings Page | `Pages/Settings.razor` | ? Sezione DB completa |
|
|
||||||
| Connection Test | Settings code-behind | ? Test PostgreSQL |
|
|
||||||
| Documentation | `Documentation/` | ? 2 guide |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ?? **UI SEZIONE DATABASE**
|
|
||||||
|
|
||||||
### **Layout Completo**
|
|
||||||
|
|
||||||
```
|
|
||||||
??????????????????????????????????????????????
|
|
||||||
? ?? Configurazione Database ?
|
|
||||||
??????????????????????????????????????????????
|
|
||||||
? ?? Database Dual-Mode: ?
|
|
||||||
? PostgreSQL per statistiche avanzate ?
|
|
||||||
? + SQLite come fallback locale ?
|
|
||||||
??????????????????????????????????????????????
|
|
||||||
? ?? Usa PostgreSQL per Statistiche Avanzate?
|
|
||||||
? ?
|
|
||||||
? ?? PostgreSQL Connection String: ?
|
|
||||||
? [Host=localhost;Port=5432;...] ?
|
|
||||||
? ?
|
|
||||||
? ?? Auto-crea schema database se mancante ?
|
|
||||||
? ?? Fallback automatico a SQLite ?
|
|
||||||
? ?
|
|
||||||
? ?? Configurazione Docker: [info box] ?
|
|
||||||
? ?
|
|
||||||
? [?? Test Connessione PostgreSQL] ?
|
|
||||||
? ? Connessione riuscita! PostgreSQL 16 ?
|
|
||||||
? ?
|
|
||||||
? [?? Salva Configurazione Database] ?
|
|
||||||
??????????????????????????????????????????????
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ?? **FUNZIONALITÀ UI**
|
|
||||||
|
|
||||||
### **1. Toggle PostgreSQL**
|
|
||||||
```razor
|
|
||||||
<input type="checkbox" @bind="settings.UsePostgreSQL" />
|
|
||||||
```
|
|
||||||
- Abilita/disabilita PostgreSQL
|
|
||||||
- Mostra/nasconde opzioni avanzate
|
|
||||||
|
|
||||||
### **2. Connection String Editor**
|
|
||||||
```razor
|
|
||||||
<input type="text" @bind="settings.PostgresConnectionString"
|
|
||||||
class="font-monospace" />
|
|
||||||
```
|
|
||||||
- Input monospaziato per leggibilità
|
|
||||||
- Placeholder con esempio formato
|
|
||||||
|
|
||||||
### **3. Auto-Create Schema**
|
|
||||||
```razor
|
|
||||||
<input type="checkbox" @bind="settings.AutoCreateDatabaseSchema" />
|
|
||||||
```
|
|
||||||
- Crea automaticamente tabelle al primo avvio
|
|
||||||
- Default: `true` (consigliato)
|
|
||||||
|
|
||||||
### **4. Fallback SQLite**
|
|
||||||
```razor
|
|
||||||
<input type="checkbox" @bind="settings.FallbackToSQLite" />
|
|
||||||
```
|
|
||||||
- Usa SQLite se PostgreSQL non disponibile
|
|
||||||
- Default: `true` (garantisce continuità)
|
|
||||||
|
|
||||||
### **5. Test Connessione**
|
|
||||||
```csharp
|
|
||||||
private async Task TestDatabaseConnection()
|
|
||||||
{
|
|
||||||
await using var conn = new Npgsql.NpgsqlConnection(connString);
|
|
||||||
await conn.OpenAsync();
|
|
||||||
|
|
||||||
var cmd = new Npgsql.NpgsqlCommand("SELECT version()", conn);
|
|
||||||
var version = await cmd.ExecuteScalarAsync();
|
|
||||||
|
|
||||||
dbTestResult = $"Connessione riuscita! PostgreSQL {version}";
|
|
||||||
dbTestSuccess = true;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Output:**
|
|
||||||
- ? Verde: Connessione riuscita + versione
|
|
||||||
- ? Rosso: Errore con messaggio dettagliato
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ?? **PERSISTENZA CONFIGURAZIONE**
|
|
||||||
|
|
||||||
### **File JSON Locale**
|
|
||||||
```json
|
|
||||||
// %LOCALAPPDATA%/AutoBidder/settings.json
|
|
||||||
{
|
|
||||||
"UsePostgreSQL": true,
|
|
||||||
"PostgresConnectionString": "Host=localhost;Port=5432;...",
|
|
||||||
"AutoCreateDatabaseSchema": true,
|
|
||||||
"FallbackToSQLite": true
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### **Caricamento Automatico**
|
|
||||||
```csharp
|
|
||||||
protected override void OnInitialized()
|
|
||||||
{
|
|
||||||
settings = AutoBidder.Utilities.SettingsManager.Load();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### **Salvataggio Click**
|
|
||||||
```csharp
|
|
||||||
private void SaveSettings()
|
|
||||||
{
|
|
||||||
AutoBidder.Utilities.SettingsManager.Save(settings);
|
|
||||||
await JSRuntime.InvokeVoidAsync("alert", "? Salvato!");
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ?? **INTEGRAZIONE PROGRAM.CS**
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// Legge impostazioni da AppSettings
|
|
||||||
var usePostgres = builder.Configuration.GetValue<bool>("Database:UsePostgres");
|
|
||||||
|
|
||||||
// Applica configurazione da settings.json
|
|
||||||
var settings = AutoBidder.Utilities.SettingsManager.Load();
|
|
||||||
if (settings.UsePostgreSQL)
|
|
||||||
{
|
|
||||||
builder.Services.AddDbContext<PostgresStatsContext>(options =>
|
|
||||||
{
|
|
||||||
options.UseNpgsql(settings.PostgresConnectionString);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ?? **DOCUMENTAZIONE CREATA**
|
|
||||||
|
|
||||||
### **1. Setup Guide**
|
|
||||||
**File:** `Documentation/POSTGRESQL_SETUP.md`
|
|
||||||
|
|
||||||
**Contenuto:**
|
|
||||||
- Quick Start (Development + Production)
|
|
||||||
- Schema tabelle completo
|
|
||||||
- Configurazione Docker Compose
|
|
||||||
- Query SQL utili
|
|
||||||
- Troubleshooting
|
|
||||||
- Backup/Restore
|
|
||||||
- Performance tuning
|
|
||||||
|
|
||||||
### **2. UI Template**
|
|
||||||
**File:** `Documentation/DATABASE_SETTINGS_UI.md`
|
|
||||||
|
|
||||||
**Contenuto:**
|
|
||||||
- Template Razor per UI
|
|
||||||
- Esempio code-behind
|
|
||||||
- Best practices
|
|
||||||
- Stato implementazione
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ?? **DEPLOYMENT**
|
|
||||||
|
|
||||||
### **Development**
|
|
||||||
```sh
|
|
||||||
# 1. Avvia PostgreSQL locale
|
|
||||||
docker run -d --name autobidder-postgres \
|
|
||||||
-e POSTGRES_DB=autobidder_stats \
|
|
||||||
-e POSTGRES_USER=autobidder \
|
|
||||||
-e POSTGRES_PASSWORD=autobidder_password \
|
|
||||||
-p 5432:5432 postgres:16-alpine
|
|
||||||
|
|
||||||
# 2. Configura in UI
|
|
||||||
http://localhost:5000/settings
|
|
||||||
? Sezione "Configurazione Database"
|
|
||||||
? Usa PostgreSQL: ?
|
|
||||||
? Connection String: Host=localhost;Port=5432;...
|
|
||||||
? Test Connessione ? ? Successo
|
|
||||||
? Salva Configurazione Database
|
|
||||||
|
|
||||||
# 3. Riavvia applicazione
|
|
||||||
dotnet run
|
|
||||||
```
|
|
||||||
|
|
||||||
### **Production (Docker Compose)**
|
|
||||||
```sh
|
|
||||||
# 1. Configura .env
|
|
||||||
POSTGRES_PASSWORD=your_secure_password_here
|
|
||||||
|
|
||||||
# 2. Deploy
|
|
||||||
docker-compose up -d
|
|
||||||
|
|
||||||
# 3. Verifica logs
|
|
||||||
docker-compose logs -f autobidder
|
|
||||||
# [PostgreSQL] Connection successful
|
|
||||||
# [PostgreSQL] Schema created successfully
|
|
||||||
# [PostgreSQL] Statistics features ENABLED
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ? **FEATURES COMPLETATE**
|
|
||||||
|
|
||||||
### **Backend**
|
|
||||||
- ? 5 tabelle PostgreSQL auto-create
|
|
||||||
- ? Migrazione schema automatica
|
|
||||||
- ? Fallback graceful a SQLite
|
|
||||||
- ? Dual-database architecture
|
|
||||||
- ? StatsService con PostgreSQL + SQLite
|
|
||||||
- ? Connection pooling
|
|
||||||
- ? Retry logic (3 tentativi)
|
|
||||||
- ? Transaction support
|
|
||||||
|
|
||||||
### **Frontend**
|
|
||||||
- ? UI Sezione Database in Settings
|
|
||||||
- ? Toggle enable/disable PostgreSQL
|
|
||||||
- ? Connection string editor
|
|
||||||
- ? Auto-create schema checkbox
|
|
||||||
- ? Fallback SQLite checkbox
|
|
||||||
- ? Test connessione con feedback visivo
|
|
||||||
- ? Info box configurazione Docker
|
|
||||||
- ? Salvataggio persistente settings
|
|
||||||
|
|
||||||
### **Documentazione**
|
|
||||||
- ? Setup guide completa
|
|
||||||
- ? Template UI opzionale
|
|
||||||
- ? Schema tabelle documentato
|
|
||||||
- ? Query esempi SQL
|
|
||||||
- ? Troubleshooting guide
|
|
||||||
- ? Docker Compose configurato
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ?? **STATISTICHE PROGETTO**
|
|
||||||
|
|
||||||
```
|
|
||||||
? Build Successful
|
|
||||||
? 0 Errors
|
|
||||||
? 0 Warnings
|
|
||||||
|
|
||||||
?? Files Created: 4
|
|
||||||
- Data/PostgresStatsContext.cs
|
|
||||||
- Models/PostgresModels.cs
|
|
||||||
- Documentation/POSTGRESQL_SETUP.md
|
|
||||||
- Documentation/DATABASE_SETTINGS_UI.md
|
|
||||||
|
|
||||||
?? Files Modified: 6
|
|
||||||
- AutoBidder.csproj (+ Npgsql package)
|
|
||||||
- Services/StatsService.cs
|
|
||||||
- Utilities/SettingsManager.cs (+ DB properties)
|
|
||||||
- Program.cs (+ PostgreSQL init)
|
|
||||||
- appsettings.json (+ connection strings)
|
|
||||||
- Pages/Settings.razor (+ UI section)
|
|
||||||
|
|
||||||
?? Total Lines Added: ~2,000
|
|
||||||
?? Total Lines Modified: ~300
|
|
||||||
|
|
||||||
?? Features: 100% Complete
|
|
||||||
?? Tests: Build ?
|
|
||||||
?? Documentation: 100% Complete
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ?? **TESTING CHECKLIST**
|
|
||||||
|
|
||||||
### **UI Testing**
|
|
||||||
- [ ] Aprire pagina Settings
|
|
||||||
- [ ] Verificare presenza sezione "Configurazione Database"
|
|
||||||
- [ ] Toggle PostgreSQL on/off
|
|
||||||
- [ ] Modificare connection string
|
|
||||||
- [ ] Click "Test Connessione" senza PostgreSQL ? ? Errore
|
|
||||||
- [ ] Avviare PostgreSQL Docker
|
|
||||||
- [ ] Click "Test Connessione" ? ? Successo
|
|
||||||
- [ ] Click "Salva Configurazione"
|
|
||||||
- [ ] Riavviare app e verificare settings persistiti
|
|
||||||
|
|
||||||
### **Backend Testing**
|
|
||||||
- [ ] PostgreSQL disponibile ? Tabelle auto-create
|
|
||||||
- [ ] PostgreSQL non disponibile ? Fallback SQLite
|
|
||||||
- [ ] Registrazione asta conclusa ? Dati in DB
|
|
||||||
- [ ] Query statistiche ? Risultati corretti
|
|
||||||
- [ ] Connection retry ? 3 tentativi
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ?? **CONCLUSIONE**
|
|
||||||
|
|
||||||
**Sistema PostgreSQL completamente integrato con:**
|
|
||||||
|
|
||||||
? **Backend completo** - 5 tabelle, dual-DB, auto-init
|
|
||||||
? **Frontend UI** - Sezione Settings con tutte le opzioni
|
|
||||||
? **Test connessione** - Feedback real-time
|
|
||||||
? **Documentazione** - 2 guide complete
|
|
||||||
? **Docker ready** - docker-compose configurato
|
|
||||||
? **Production ready** - Fallback graceful implementato
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Il progetto AutoBidder ora dispone di un sistema completo per statistiche avanzate con PostgreSQL, configurabile tramite UI intuitiva e con documentazione completa!** ????
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ?? **RIFERIMENTI**
|
|
||||||
|
|
||||||
- Setup Guide: `Documentation/POSTGRESQL_SETUP.md`
|
|
||||||
- UI Template: `Documentation/DATABASE_SETTINGS_UI.md`
|
|
||||||
- Settings Model: `Utilities/SettingsManager.cs`
|
|
||||||
- DB Context: `Data/PostgresStatsContext.cs`
|
|
||||||
- Stats Service: `Services/StatsService.cs`
|
|
||||||
- Settings UI: `Pages/Settings.razor`
|
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
______________________________________________________________________________________________________________
|
||||||
|
FUNZIONALITA
|
||||||
|
|
||||||
|
Cambiare la pagina delle statistiche in modo da aggiungere una sezione in più, oltre alle statistiche memorizzate in un automatico, in cui posso associare un range di prezzo e di puntate per ogni articolo, identificato tramite il suo nome
|
||||||
|
|
||||||
|
Aggiungere una scansione periodica e automatica delle aste terminate in modo da aggiornare automaticamente il mio elenco degli articoli delle aste terminate per aggiornare prezzo e numero di puntate usate in automatico. Molto importante: salvare anche l'ora di chiusura dell'asta
|
||||||
|
|
||||||
|
Aggiungere una funzionalità di aggiunta automatica delle aste al monitor appena compaiono nell'elenco delle aste disponibile cercando tramite sezione e nome articolo
|
||||||
|
|
||||||
|
Aggiungi una indicazione visiva nella colonna dello stato che indica quando un'asta pur essendo nello stato attiva il bot non punta perché fuori range oppure per altri motivi
|
||||||
|
|
||||||
|
Fare una tasto nelle statistiche che applichi massivamente i limiti a tutti gli articoli attualmente monitorati che hanno delle informazioni salvate nel database delle aste terminate
|
||||||
|
|
||||||
|
_______________________________________________________________________________________________________________
|
||||||
|
REWORK
|
||||||
|
|
||||||
|
Esegui un rework generico del sistema di log della singola asta e del log globale. Ci sono troppe righe inutili come tante righe simili duplicate nel log della singola asta e informazioni inutili nel log globale come per esempio l'indicazione del focus che si sposta su una certa riga. Valuta i cambiamenti e le ottimizzazioni da fare e applica le modifiche.
|
||||||
|
|
||||||
|
Esegui un rework della grafica in modo da eliminare le animazioni popup che danno fastidio all'usabilità del programma. In particolare intendo che quando il mouse passa su un pulsante o una griglia questa aumenta leggermente di dimensione per evidenziarsi ma questo non mi piace. Elimina questa cosa e sostituiscila piuttosto con una illuminazione o colorazione più chiara o scura per evidenziare il fatto che sto per selezionare quel particolare pulsante
|
||||||
|
|
||||||
|
_______________________________________________________________________________________________________________
|
||||||
|
CORREZIONI
|
||||||
|
|
||||||
|
Aggiungi più stati per indicare la strategia o il fatto che non sta puntando e per quale motivo.
|
||||||
|
In particolare oltre agli stati già presenti indicare anche il motivo per cui non sta puntando come per esempio "fuori range di prezzo", "fuori range di puntate", "asta terminata", "strategia non permette puntata", ecc
|
||||||
|
|
||||||
@@ -1,363 +0,0 @@
|
|||||||
# PostgreSQL Setup - AutoBidder Statistics
|
|
||||||
|
|
||||||
## ?? Overview
|
|
||||||
|
|
||||||
AutoBidder utilizza PostgreSQL per statistiche avanzate e analisi strategiche delle aste concluse. Il sistema supporta **dual-database**:
|
|
||||||
- **PostgreSQL**: Statistiche persistenti e analisi avanzate
|
|
||||||
- **SQLite**: Fallback locale se PostgreSQL non disponibile
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ?? Quick Start
|
|
||||||
|
|
||||||
### Development (Locale)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Avvia PostgreSQL con Docker
|
|
||||||
docker run -d \
|
|
||||||
--name autobidder-postgres \
|
|
||||||
-e POSTGRES_DB=autobidder_stats \
|
|
||||||
-e POSTGRES_USER=autobidder \
|
|
||||||
-e POSTGRES_PASSWORD=autobidder_password \
|
|
||||||
-p 5432:5432 \
|
|
||||||
postgres:16-alpine
|
|
||||||
|
|
||||||
# 2. Avvia AutoBidder
|
|
||||||
dotnet run
|
|
||||||
|
|
||||||
# 3. Verifica logs
|
|
||||||
# Dovresti vedere:
|
|
||||||
# [PostgreSQL] Connection successful
|
|
||||||
# [PostgreSQL] Schema created successfully
|
|
||||||
# [PostgreSQL] Statistics features ENABLED
|
|
||||||
```
|
|
||||||
|
|
||||||
### Production (Docker Compose)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Configura variabili ambiente
|
|
||||||
cp .env.example .env
|
|
||||||
nano .env # Modifica POSTGRES_PASSWORD
|
|
||||||
|
|
||||||
# 2. Avvia stack completo
|
|
||||||
docker-compose up -d
|
|
||||||
|
|
||||||
# 3. Verifica stato
|
|
||||||
docker-compose ps
|
|
||||||
docker-compose logs -f autobidder
|
|
||||||
docker-compose logs -f postgres
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ?? Schema Database
|
|
||||||
|
|
||||||
### Tabelle Create Automaticamente
|
|
||||||
|
|
||||||
#### `completed_auctions`
|
|
||||||
Aste concluse con dettagli completi per analisi strategiche.
|
|
||||||
|
|
||||||
| Colonna | Tipo | Descrizione |
|
|
||||||
|---------|------|-------------|
|
|
||||||
| id | SERIAL | Primary key |
|
|
||||||
| auction_id | VARCHAR(100) | ID univoco asta (indexed) |
|
|
||||||
| product_name | VARCHAR(500) | Nome prodotto (indexed) |
|
|
||||||
| final_price | DECIMAL(10,2) | Prezzo finale |
|
|
||||||
| buy_now_price | DECIMAL(10,2) | Prezzo "Compra Subito" |
|
|
||||||
| total_bids | INTEGER | Puntate totali asta |
|
|
||||||
| my_bids_count | INTEGER | Mie puntate |
|
|
||||||
| won | BOOLEAN | Asta vinta? (indexed) |
|
|
||||||
| winner_username | VARCHAR(100) | Username vincitore |
|
|
||||||
| average_latency | DECIMAL(10,2) | Latency media (ms) |
|
|
||||||
| savings | DECIMAL(10,2) | Risparmio effettivo |
|
|
||||||
| completed_at | TIMESTAMP | Data/ora completamento (indexed) |
|
|
||||||
|
|
||||||
#### `product_statistics`
|
|
||||||
Statistiche aggregate per prodotto.
|
|
||||||
|
|
||||||
| Colonna | Tipo | Descrizione |
|
|
||||||
|---------|------|-------------|
|
|
||||||
| id | SERIAL | Primary key |
|
|
||||||
| product_key | VARCHAR(200) | Chiave univoca prodotto (unique) |
|
|
||||||
| product_name | VARCHAR(500) | Nome prodotto |
|
|
||||||
| average_winning_bids | DECIMAL(10,2) | Media puntate vincenti |
|
|
||||||
| recommended_max_bids | INTEGER | **Suggerimento strategico** |
|
|
||||||
| recommended_max_price | DECIMAL(10,2) | **Suggerimento strategico** |
|
|
||||||
| competition_level | VARCHAR(20) | Low/Medium/High |
|
|
||||||
| last_updated | TIMESTAMP | Ultimo aggiornamento |
|
|
||||||
|
|
||||||
#### `bidder_performances`
|
|
||||||
Performance puntatori concorrenti.
|
|
||||||
|
|
||||||
| Colonna | Tipo | Descrizione |
|
|
||||||
|---------|------|-------------|
|
|
||||||
| id | SERIAL | Primary key |
|
|
||||||
| username | VARCHAR(100) | Username puntatore (unique) |
|
|
||||||
| total_auctions | INTEGER | Aste totali |
|
|
||||||
| auctions_won | INTEGER | Aste vinte |
|
|
||||||
| win_rate | DECIMAL(5,2) | Percentuale vittorie (indexed) |
|
|
||||||
| average_bids_per_auction | DECIMAL(10,2) | Media puntate/asta |
|
|
||||||
| is_aggressive | BOOLEAN | Puntatore aggressivo? |
|
|
||||||
|
|
||||||
#### `daily_metrics`
|
|
||||||
Metriche giornaliere aggregate.
|
|
||||||
|
|
||||||
| Colonna | Tipo | Descrizione |
|
|
||||||
|---------|------|-------------|
|
|
||||||
| id | SERIAL | Primary key |
|
|
||||||
| date | DATE | Data (unique) |
|
|
||||||
| total_bids_used | INTEGER | Puntate usate |
|
|
||||||
| money_spent | DECIMAL(10,2) | Spesa totale |
|
|
||||||
| win_rate | DECIMAL(5,2) | Win rate giornaliero |
|
|
||||||
| roi | DECIMAL(10,2) | **ROI %** |
|
|
||||||
|
|
||||||
#### `strategic_insights`
|
|
||||||
Raccomandazioni strategiche generate automaticamente.
|
|
||||||
|
|
||||||
| Colonna | Tipo | Descrizione |
|
|
||||||
|---------|------|-------------|
|
|
||||||
| id | SERIAL | Primary key |
|
|
||||||
| insight_type | VARCHAR(50) | Tipo insight (indexed) |
|
|
||||||
| product_key | VARCHAR(200) | Prodotto riferimento |
|
|
||||||
| recommended_action | TEXT | **Azione consigliata** |
|
|
||||||
| confidence_level | DECIMAL(5,2) | Livello confidenza (0-100) |
|
|
||||||
| is_active | BOOLEAN | Insight attivo? |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ?? Configurazione
|
|
||||||
|
|
||||||
### `appsettings.json`
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"ConnectionStrings": {
|
|
||||||
"PostgresStats": "Host=localhost;Port=5432;Database=autobidder_stats;Username=autobidder;Password=autobidder_password",
|
|
||||||
"PostgresStatsProduction": "Host=postgres;Port=5432;Database=autobidder_stats;Username=${POSTGRES_USER};Password=${POSTGRES_PASSWORD}"
|
|
||||||
},
|
|
||||||
"Database": {
|
|
||||||
"UsePostgres": true,
|
|
||||||
"AutoCreateSchema": true,
|
|
||||||
"FallbackToSQLite": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `.env` (Production)
|
|
||||||
|
|
||||||
```env
|
|
||||||
# PostgreSQL
|
|
||||||
POSTGRES_USER=autobidder
|
|
||||||
POSTGRES_PASSWORD=your_secure_password_here
|
|
||||||
POSTGRES_DB=autobidder_stats
|
|
||||||
|
|
||||||
# Database config
|
|
||||||
DATABASE_USE_POSTGRES=true
|
|
||||||
DATABASE_AUTO_CREATE_SCHEMA=true
|
|
||||||
DATABASE_FALLBACK_TO_SQLITE=true
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ?? Utilizzo API
|
|
||||||
|
|
||||||
### Registra Asta Conclusa
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// Chiamato automaticamente da AuctionMonitor
|
|
||||||
await statsService.RecordAuctionCompletedAsync(auction, won: true);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Ottieni Raccomandazioni Strategiche
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// Raccomandazioni per prodotto specifico
|
|
||||||
var productKey = GenerateProductKey("iPhone 15 Pro");
|
|
||||||
var insights = await statsService.GetStrategicInsightsAsync(productKey);
|
|
||||||
|
|
||||||
foreach (var insight in insights)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"{insight.InsightType}: {insight.RecommendedAction}");
|
|
||||||
Console.WriteLine($"Confidence: {insight.ConfidenceLevel}%");
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Analisi Competitori
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// Top 10 puntatori più vincenti
|
|
||||||
var competitors = await statsService.GetTopCompetitorsAsync(10);
|
|
||||||
|
|
||||||
foreach (var competitor in competitors)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"{competitor.Username}: {competitor.WinRate}% win rate");
|
|
||||||
if (competitor.IsAggressive)
|
|
||||||
{
|
|
||||||
Console.WriteLine(" ?? AGGRESSIVE BIDDER - Avoid competition");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Statistiche Prodotto
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// Ottieni statistiche per strategia bidding
|
|
||||||
var productKey = GenerateProductKey("PlayStation 5");
|
|
||||||
var stat = await postgresDb.ProductStatistics
|
|
||||||
.FirstOrDefaultAsync(p => p.ProductKey == productKey);
|
|
||||||
|
|
||||||
if (stat != null)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"Recommended max bids: {stat.RecommendedMaxBids}");
|
|
||||||
Console.WriteLine($"Recommended max price: €{stat.RecommendedMaxPrice}");
|
|
||||||
Console.WriteLine($"Competition level: {stat.CompetitionLevel}");
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ?? Troubleshooting
|
|
||||||
|
|
||||||
### PostgreSQL non si connette
|
|
||||||
|
|
||||||
```
|
|
||||||
[PostgreSQL] Cannot connect to database
|
|
||||||
[PostgreSQL] Statistics features will use SQLite fallback
|
|
||||||
```
|
|
||||||
|
|
||||||
**Soluzione:**
|
|
||||||
1. Verifica che PostgreSQL sia in esecuzione: `docker ps | grep postgres`
|
|
||||||
2. Controlla connection string in `appsettings.json`
|
|
||||||
3. Verifica credenziali in `.env`
|
|
||||||
4. Check logs PostgreSQL: `docker logs autobidder-postgres`
|
|
||||||
|
|
||||||
### Schema non creato
|
|
||||||
|
|
||||||
```
|
|
||||||
[PostgreSQL] Schema validation failed
|
|
||||||
[PostgreSQL] Statistics features DISABLED (missing tables)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Soluzione:**
|
|
||||||
1. Abilita auto-creazione in `appsettings.json`: `"AutoCreateSchema": true`
|
|
||||||
2. Riavvia applicazione: `docker-compose restart autobidder`
|
|
||||||
3. Verifica permessi utente PostgreSQL
|
|
||||||
4. Check logs dettagliati: `docker-compose logs -f autobidder`
|
|
||||||
|
|
||||||
### Fallback a SQLite
|
|
||||||
|
|
||||||
Se PostgreSQL non è disponibile, AutoBidder usa automaticamente SQLite locale:
|
|
||||||
- ? Nessun downtime
|
|
||||||
- ? Statistiche base funzionanti
|
|
||||||
- ?? Insight strategici disabilitati
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ?? Backup PostgreSQL
|
|
||||||
|
|
||||||
### Manuale
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Backup database
|
|
||||||
docker exec autobidder-postgres pg_dump -U autobidder autobidder_stats > backup.sql
|
|
||||||
|
|
||||||
# Restore
|
|
||||||
docker exec -i autobidder-postgres psql -U autobidder autobidder_stats < backup.sql
|
|
||||||
```
|
|
||||||
|
|
||||||
### Automatico (con Docker Compose)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Backup in ./postgres-backups/
|
|
||||||
docker-compose exec postgres pg_dump -U autobidder autobidder_stats \
|
|
||||||
> ./postgres-backups/backup_$(date +%Y%m%d_%H%M%S).sql
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ?? Monitoraggio
|
|
||||||
|
|
||||||
### Connessione Database
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Entra in PostgreSQL shell
|
|
||||||
docker exec -it autobidder-postgres psql -U autobidder -d autobidder_stats
|
|
||||||
|
|
||||||
# Query utili
|
|
||||||
SELECT COUNT(*) FROM completed_auctions;
|
|
||||||
SELECT COUNT(*) FROM product_statistics;
|
|
||||||
SELECT * FROM daily_metrics ORDER BY date DESC LIMIT 7;
|
|
||||||
```
|
|
||||||
|
|
||||||
### Statistiche Utilizzo
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- Aste concluse per giorno (ultimi 30 giorni)
|
|
||||||
SELECT
|
|
||||||
DATE(completed_at) as date,
|
|
||||||
COUNT(*) as total_auctions,
|
|
||||||
SUM(CASE WHEN won THEN 1 ELSE 0 END) as won,
|
|
||||||
ROUND(AVG(my_bids_count), 2) as avg_bids
|
|
||||||
FROM completed_auctions
|
|
||||||
WHERE completed_at >= NOW() - INTERVAL '30 days'
|
|
||||||
GROUP BY DATE(completed_at)
|
|
||||||
ORDER BY date DESC;
|
|
||||||
|
|
||||||
-- Top 10 prodotti più competitivi
|
|
||||||
SELECT
|
|
||||||
product_name,
|
|
||||||
total_auctions,
|
|
||||||
average_winning_bids,
|
|
||||||
competition_level
|
|
||||||
FROM product_statistics
|
|
||||||
ORDER BY average_winning_bids DESC
|
|
||||||
LIMIT 10;
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ?? Performance
|
|
||||||
|
|
||||||
### Indici Creati Automaticamente
|
|
||||||
|
|
||||||
- `idx_auction_id` su `completed_auctions(auction_id)`
|
|
||||||
- `idx_product_name` su `completed_auctions(product_name)`
|
|
||||||
- `idx_completed_at` su `completed_auctions(completed_at)`
|
|
||||||
- `idx_won` su `completed_auctions(won)`
|
|
||||||
- `idx_username` su `bidder_performances(username)` [UNIQUE]
|
|
||||||
- `idx_win_rate` su `bidder_performances(win_rate)`
|
|
||||||
- `idx_product_key` su `product_statistics(product_key)` [UNIQUE]
|
|
||||||
- `idx_date` su `daily_metrics(date)` [UNIQUE]
|
|
||||||
|
|
||||||
### Ottimizzazioni
|
|
||||||
|
|
||||||
- Retry automatico su fallimenti (3 tentativi)
|
|
||||||
- Timeout comandi: 30 secondi
|
|
||||||
- Connection pooling gestito da Npgsql
|
|
||||||
- Transazioni ACID per consistenza dati
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ?? Roadmap
|
|
||||||
|
|
||||||
### Prossime Features
|
|
||||||
|
|
||||||
- [ ] **Auto-generazione Insights**: Analisi pattern vincenti automatica
|
|
||||||
- [ ] **Heatmap Competizione**: Orari migliori per puntare
|
|
||||||
- [ ] **ML Predictions**: Predizione probabilità vittoria
|
|
||||||
- [ ] **Alert System**: Notifiche su insight critici
|
|
||||||
- [ ] **Export Analytics**: CSV/Excel per analisi esterna
|
|
||||||
- [ ] **Backup Scheduler**: Backup automatici giornalieri
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ?? Riferimenti
|
|
||||||
|
|
||||||
- [Npgsql Documentation](https://www.npgsql.org/doc/)
|
|
||||||
- [EF Core PostgreSQL](https://www.npgsql.org/efcore/)
|
|
||||||
- [PostgreSQL 16 Docs](https://www.postgresql.org/docs/16/)
|
|
||||||
- [Docker PostgreSQL](https://hub.docker.com/_/postgres)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Sistema PostgreSQL completamente integrato e pronto per analisi strategiche avanzate! ????**
|
|
||||||
@@ -1,333 +0,0 @@
|
|||||||
# ?? UI Sezione Database - Visual Guide
|
|
||||||
|
|
||||||
## ?? **Preview Sezione Configurazione Database**
|
|
||||||
|
|
||||||
### **Stato: PostgreSQL Abilitato**
|
|
||||||
|
|
||||||
```
|
|
||||||
???????????????????????????????????????????????????????????????????
|
|
||||||
? ?? Configurazione Database ?
|
|
||||||
???????????????????????????????????????????????????????????????????
|
|
||||||
? ?
|
|
||||||
? ?? Database Dual-Mode: ?
|
|
||||||
? ?
|
|
||||||
? AutoBidder utilizza PostgreSQL per statistiche avanzate ?
|
|
||||||
? e SQLite come fallback locale. Se PostgreSQL non è ?
|
|
||||||
? disponibile, le statistiche base continueranno a funzionare ?
|
|
||||||
? con SQLite. ?
|
|
||||||
? ?
|
|
||||||
???????????????????????????????????????????????????????????????????
|
|
||||||
? ?
|
|
||||||
? ?? [?] Usa PostgreSQL per Statistiche Avanzate ?
|
|
||||||
? Abilita analisi strategiche, raccomandazioni e metriche ?
|
|
||||||
? ?
|
|
||||||
? ?? PostgreSQL Connection String: ?
|
|
||||||
? ????????????????????????????????????????????????????????? ?
|
|
||||||
? ? Host=localhost;Port=5432;Database=autobidder_stats; ? ?
|
|
||||||
? ? Username=autobidder;Password=autobidder_password ? ?
|
|
||||||
? ????????????????????????????????????????????????????????? ?
|
|
||||||
? ?? Formato: Host=server;Port=5432;Database=dbname;... ?
|
|
||||||
? ?
|
|
||||||
? ?? [?] Auto-crea schema database se mancante ?
|
|
||||||
? Crea automaticamente le tabelle PostgreSQL al primo ?
|
|
||||||
? avvio ?
|
|
||||||
? ?
|
|
||||||
? ?? [?] Fallback automatico a SQLite se PostgreSQL non ?
|
|
||||||
? disponibile ?
|
|
||||||
? Consigliato: garantisce continuità anche senza ?
|
|
||||||
? PostgreSQL ?
|
|
||||||
? ?
|
|
||||||
? ?? Configurazione Docker: ?
|
|
||||||
? ?
|
|
||||||
? Se usi Docker Compose, il servizio PostgreSQL è già ?
|
|
||||||
? configurato. Usa: ?
|
|
||||||
? ?
|
|
||||||
? Host=postgres;Port=5432;Database=autobidder_stats; ?
|
|
||||||
? Username=autobidder;Password=${POSTGRES_PASSWORD} ?
|
|
||||||
? ?
|
|
||||||
? ?? Configura POSTGRES_PASSWORD nel file .env ?
|
|
||||||
? ?
|
|
||||||
? ???????????????????????????????????? ?
|
|
||||||
? ? ?? Test Connessione PostgreSQL ? ?
|
|
||||||
? ???????????????????????????????????? ?
|
|
||||||
? ?
|
|
||||||
? ? Connessione riuscita! PostgreSQL 16.1 ?
|
|
||||||
? ?
|
|
||||||
? ?????????????????????????????????????? ?
|
|
||||||
? ? ?? Salva Configurazione Database ? ?
|
|
||||||
? ?????????????????????????????????????? ?
|
|
||||||
? ?
|
|
||||||
???????????????????????????????????????????????????????????????????
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### **Stato: PostgreSQL Disabilitato**
|
|
||||||
|
|
||||||
```
|
|
||||||
???????????????????????????????????????????????????????????????????
|
|
||||||
? ?? Configurazione Database ?
|
|
||||||
???????????????????????????????????????????????????????????????????
|
|
||||||
? ?
|
|
||||||
? ?? Database Dual-Mode: ?
|
|
||||||
? ?
|
|
||||||
? AutoBidder utilizza PostgreSQL per statistiche avanzate ?
|
|
||||||
? e SQLite come fallback locale. Se PostgreSQL non è ?
|
|
||||||
? disponibile, le statistiche base continueranno a funzionare ?
|
|
||||||
? con SQLite. ?
|
|
||||||
? ?
|
|
||||||
???????????????????????????????????????????????????????????????????
|
|
||||||
? ?
|
|
||||||
? ?? [ ] Usa PostgreSQL per Statistiche Avanzate ?
|
|
||||||
? Abilita analisi strategiche, raccomandazioni e metriche ?
|
|
||||||
? ?
|
|
||||||
? ?????????????????????????????????????? ?
|
|
||||||
? ? ?? Salva Configurazione Database ? ?
|
|
||||||
? ?????????????????????????????????????? ?
|
|
||||||
? ?
|
|
||||||
???????????????????????????????????????????????????????????????????
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### **Test Connessione - Stati**
|
|
||||||
|
|
||||||
#### **In Corso**
|
|
||||||
```
|
|
||||||
????????????????????????????????????????
|
|
||||||
? ? Test in corso... ?
|
|
||||||
????????????????????????????????????????
|
|
||||||
```
|
|
||||||
|
|
||||||
#### **Successo**
|
|
||||||
```
|
|
||||||
????????????????????????????????????????
|
|
||||||
? ?? Test Connessione PostgreSQL ?
|
|
||||||
????????????????????????????????????????
|
|
||||||
|
|
||||||
? Connessione riuscita! PostgreSQL 16.1
|
|
||||||
```
|
|
||||||
|
|
||||||
#### **Errore - Host non raggiungibile**
|
|
||||||
```
|
|
||||||
????????????????????????????????????????
|
|
||||||
? ?? Test Connessione PostgreSQL ?
|
|
||||||
????????????????????????????????????????
|
|
||||||
|
|
||||||
? Errore PostgreSQL: No connection could be made because the target machine actively refused it
|
|
||||||
```
|
|
||||||
|
|
||||||
#### **Errore - Credenziali errate**
|
|
||||||
```
|
|
||||||
????????????????????????????????????????
|
|
||||||
? ?? Test Connessione PostgreSQL ?
|
|
||||||
????????????????????????????????????????
|
|
||||||
|
|
||||||
? Errore PostgreSQL: password authentication failed for user "autobidder"
|
|
||||||
```
|
|
||||||
|
|
||||||
#### **Errore - Database non esistente**
|
|
||||||
```
|
|
||||||
????????????????????????????????????????
|
|
||||||
? ?? Test Connessione PostgreSQL ?
|
|
||||||
????????????????????????????????????????
|
|
||||||
|
|
||||||
? Errore PostgreSQL: database "autobidder_stats" does not exist
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ?? **Stili CSS Applicati**
|
|
||||||
|
|
||||||
### **Card Container**
|
|
||||||
```css
|
|
||||||
.card {
|
|
||||||
border-radius: 12px;
|
|
||||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card:hover {
|
|
||||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### **Header**
|
|
||||||
```css
|
|
||||||
.card-header.bg-secondary {
|
|
||||||
background: linear-gradient(135deg, #6c757d 0%, #5a6268 100%);
|
|
||||||
color: white;
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### **Alert Box**
|
|
||||||
```css
|
|
||||||
.alert-info {
|
|
||||||
background: linear-gradient(135deg, #d1ecf1 0%, #bee5eb 100%);
|
|
||||||
border: none;
|
|
||||||
border-left: 4px solid #17a2b8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-warning {
|
|
||||||
background: linear-gradient(135deg, #fff3cd 0%, #ffe69c 100%);
|
|
||||||
border: none;
|
|
||||||
border-left: 4px solid #ffc107;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### **Form Switch**
|
|
||||||
```css
|
|
||||||
.form-check-input:checked {
|
|
||||||
background-color: #0dcaf0;
|
|
||||||
border-color: #0dcaf0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-switch .form-check-input {
|
|
||||||
width: 3em;
|
|
||||||
height: 1.5em;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### **Input Monospace**
|
|
||||||
```css
|
|
||||||
.font-monospace {
|
|
||||||
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
background: #f8f9fa;
|
|
||||||
border: 2px solid #dee2e6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.font-monospace:focus {
|
|
||||||
border-color: #0dcaf0;
|
|
||||||
box-shadow: 0 0 0 0.25rem rgba(13, 202, 240, 0.25);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### **Button Hover**
|
|
||||||
```css
|
|
||||||
.btn.hover-lift {
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn.hover-lift:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary.hover-lift:hover {
|
|
||||||
background: linear-gradient(135deg, #0d6efd 0%, #0b5ed7 100%);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### **Success/Error Feedback**
|
|
||||||
```css
|
|
||||||
.text-success {
|
|
||||||
color: #00d800 !important;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-danger {
|
|
||||||
color: #f85149 !important;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bi-check-circle-fill,
|
|
||||||
.bi-x-circle-fill {
|
|
||||||
font-size: 1.2rem;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ?? **Interazioni Utente**
|
|
||||||
|
|
||||||
### **Scenario 1: Prima Configurazione**
|
|
||||||
|
|
||||||
1. **Utente apre Settings** ? Vede sezione Database
|
|
||||||
2. **PostgreSQL disabilitato** ? Solo toggle visibile
|
|
||||||
3. **Utente abilita PostgreSQL** ? Si espandono opzioni
|
|
||||||
4. **Utente inserisce connection string** ? Formato validato
|
|
||||||
5. **Click "Test Connessione"** ? Spinner appare
|
|
||||||
6. **Test fallisce** ? ? Rosso con messaggio errore
|
|
||||||
7. **Utente corregge password** ? Riprova test
|
|
||||||
8. **Test successo** ? ? Verde con versione
|
|
||||||
9. **Click "Salva"** ? Alert "? Salvato!"
|
|
||||||
10. **Riavvio app** ? Settings caricati automaticamente
|
|
||||||
|
|
||||||
### **Scenario 2: Migrazione SQLite ? PostgreSQL**
|
|
||||||
|
|
||||||
1. **App funziona con SQLite** ? Dati locali
|
|
||||||
2. **Utente avvia PostgreSQL Docker** ? Container ready
|
|
||||||
3. **Utente va in Settings** ? Abilita PostgreSQL
|
|
||||||
4. **Connection string già compilata** ? Default localhost
|
|
||||||
5. **Test connessione** ? ? Successo
|
|
||||||
6. **Salva e riavvia** ? Program.cs crea tabelle
|
|
||||||
7. **Nuove aste registrate** ? Dati su PostgreSQL
|
|
||||||
8. **Vecchi dati SQLite** ? Rimangono intatti (fallback)
|
|
||||||
|
|
||||||
### **Scenario 3: Errore PostgreSQL**
|
|
||||||
|
|
||||||
1. **PostgreSQL configurato** ? App avviata
|
|
||||||
2. **Container PostgreSQL crash** ? Connection lost
|
|
||||||
3. **App rileva fallimento** ? Log: "PostgreSQL unavailable"
|
|
||||||
4. **Fallback automatico** ? "Using SQLite fallback"
|
|
||||||
5. **Statistiche continuano** ? Nessun downtime
|
|
||||||
6. **Utente ripristina PostgreSQL** ? Test connessione OK
|
|
||||||
7. **Riavvio app** ? Torna a usare PostgreSQL
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ?? **Responsive Design**
|
|
||||||
|
|
||||||
### **Desktop (>1200px)**
|
|
||||||
- Form a 2 colonne dove possibile
|
|
||||||
- Alert box con icone grandi
|
|
||||||
- Bottoni spaziati orizzontalmente
|
|
||||||
|
|
||||||
### **Tablet (768px-1200px)**
|
|
||||||
- Form a colonna singola
|
|
||||||
- Connection string full-width
|
|
||||||
- Bottoni stack verticale
|
|
||||||
|
|
||||||
### **Mobile (<768px)**
|
|
||||||
```
|
|
||||||
???????????????????????????
|
|
||||||
? ?? Configurazione DB ?
|
|
||||||
???????????????????????????
|
|
||||||
? ?? Info box ?
|
|
||||||
???????????????????????????
|
|
||||||
? ?? Usa PostgreSQL ?
|
|
||||||
? ?
|
|
||||||
? ?? Connection String: ?
|
|
||||||
? ??????????????????????? ?
|
|
||||||
? ? Host=... ? ?
|
|
||||||
? ??????????????????????? ?
|
|
||||||
? ?
|
|
||||||
? ?? Auto-create ?
|
|
||||||
? ?? Fallback SQLite ?
|
|
||||||
? ?
|
|
||||||
? [?? Test Connessione] ?
|
|
||||||
? ?
|
|
||||||
? ? Successo! ?
|
|
||||||
? ?
|
|
||||||
? [?? Salva] ?
|
|
||||||
???????????????????????????
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ?? **Accessibilità**
|
|
||||||
|
|
||||||
- ? **Keyboard Navigation**: Tab tra campi
|
|
||||||
- ? **Screen Readers**: Label descrittivi
|
|
||||||
- ? **Contrast Ratio**: WCAG AA compliant
|
|
||||||
- ? **Focus Indicators**: Visibili su tutti i controlli
|
|
||||||
- ? **Error Messages**: Chiari e specifici
|
|
||||||
- ? **Success Feedback**: Visivo + Alert
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**UI completa, accessibile e user-friendly per configurazione PostgreSQL! ???**
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,29 @@
|
|||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
|
||||||
|
namespace AutoBidder.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Utente dell'applicazione con supporto Identity
|
||||||
|
/// </summary>
|
||||||
|
public class ApplicationUser : IdentityUser
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Data creazione utente
|
||||||
|
/// </summary>
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Data ultimo accesso
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? LastLoginAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Indica se l'utente è attivo
|
||||||
|
/// </summary>
|
||||||
|
public bool IsActive { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Note amministrative sull'utente
|
||||||
|
/// </summary>
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
}
|
||||||
+453
-17
@@ -13,8 +13,9 @@ namespace AutoBidder.Models
|
|||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Numero massimo di righe di log da mantenere per ogni asta
|
/// Numero massimo di righe di log da mantenere per ogni asta
|
||||||
|
/// Ridotto per ottimizzare consumo RAM
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private const int MAX_LOG_LINES = 500;
|
private const int MAX_LOG_LINES = 200;
|
||||||
|
|
||||||
public string AuctionId { get; set; } = "";
|
public string AuctionId { get; set; } = "";
|
||||||
public string Name { get; set; } = ""; // Opzionale, può essere lasciato vuoto
|
public string Name { get; set; } = ""; // Opzionale, può essere lasciato vuoto
|
||||||
@@ -37,8 +38,14 @@ namespace AutoBidder.Models
|
|||||||
public double MaxPrice { get; set; } = 0;
|
public double MaxPrice { get; set; } = 0;
|
||||||
public int MinResets { get; set; } = 0; // Numero minimo reset prima di puntare
|
public int MinResets { get; set; } = 0; // Numero minimo reset prima di puntare
|
||||||
public int MaxResets { get; set; } = 0; // Numero massimo reset (0 = illimitati)
|
public int MaxResets { get; set; } = 0; // Numero massimo reset (0 = illimitati)
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Numero massimo di puntate consentite per questa asta (0 = illimitato).
|
||||||
|
/// Impostato dall'utente nella griglia statistiche o dai limiti prodotto.
|
||||||
|
/// Controllato in ShouldBid contro BidsUsedOnThisAuction.
|
||||||
|
/// </summary>
|
||||||
[JsonPropertyName("MaxClicks")]
|
[JsonPropertyName("MaxClicks")]
|
||||||
public int MaxClicks { get; set; } = 0; // Numero massimo di puntate consentite (0 = illimitato)
|
public int MaxClicks { get; set; } = 0;
|
||||||
|
|
||||||
// Stato asta
|
// Stato asta
|
||||||
public bool IsActive { get; set; } = true;
|
public bool IsActive { get; set; } = true;
|
||||||
@@ -59,10 +66,54 @@ namespace AutoBidder.Models
|
|||||||
[JsonPropertyName("BidsUsedOnThisAuction")]
|
[JsonPropertyName("BidsUsedOnThisAuction")]
|
||||||
public int? BidsUsedOnThisAuction { get; set; }
|
public int? BidsUsedOnThisAuction { get; set; }
|
||||||
|
|
||||||
|
|
||||||
// Timestamp
|
// Timestamp
|
||||||
public DateTime AddedAt { get; set; } = DateTime.UtcNow;
|
public DateTime AddedAt { get; set; } = DateTime.UtcNow;
|
||||||
public DateTime? LastClickAt { get; set; }
|
public DateTime? LastClickAt { get; set; }
|
||||||
|
|
||||||
|
// ?? NUOVO: Sistema timing basato su deadline
|
||||||
|
/// <summary>
|
||||||
|
/// Timestamp UTC preciso della scadenza dell'asta.
|
||||||
|
/// Calcolato come: DateTime.UtcNow + Timer (quando riceviamo lo stato)
|
||||||
|
/// </summary>
|
||||||
|
[JsonIgnore]
|
||||||
|
public DateTime? DeadlineUtc { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Timestamp UTC dell'ultimo aggiornamento della deadline.
|
||||||
|
/// Usato per rilevare reset del timer.
|
||||||
|
/// </summary>
|
||||||
|
[JsonIgnore]
|
||||||
|
public DateTime? LastDeadlineUpdateUtc { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Timer raw dell'ultimo stato ricevuto (in secondi).
|
||||||
|
/// Usato per rilevare cambiamenti nel timer.
|
||||||
|
/// </summary>
|
||||||
|
[JsonIgnore]
|
||||||
|
public double LastRawTimer { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True se la puntata è già stata schedulata per questo ciclo.
|
||||||
|
/// Resettato quando il timer si resetta.
|
||||||
|
/// </summary>
|
||||||
|
[JsonIgnore]
|
||||||
|
public bool BidScheduled { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Timer per cui è stata schedulata l'ultima puntata.
|
||||||
|
/// Usato per evitare doppie puntate sullo stesso ciclo.
|
||||||
|
/// </summary>
|
||||||
|
[JsonIgnore]
|
||||||
|
public double LastScheduledTimerMs { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stato di fine asta ricevuto dal poll ma non ancora processato.
|
||||||
|
/// Il ticker ha un'ultima occasione di puntare prima che venga gestito.
|
||||||
|
/// </summary>
|
||||||
|
[JsonIgnore]
|
||||||
|
public AuctionState? PendingEndState { get; set; }
|
||||||
|
|
||||||
// Storico
|
// Storico
|
||||||
public List<BidHistory> BidHistory { get; set; } = new List<BidHistory>();
|
public List<BidHistory> BidHistory { get; set; } = new List<BidHistory>();
|
||||||
public Dictionary<string, BidderInfo> BidderStats { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
public Dictionary<string, BidderInfo> BidderStats { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||||
@@ -74,9 +125,9 @@ namespace AutoBidder.Models
|
|||||||
[JsonPropertyName("RecentBids")]
|
[JsonPropertyName("RecentBids")]
|
||||||
public List<BidHistoryEntry> RecentBids { get; set; } = new List<BidHistoryEntry>();
|
public List<BidHistoryEntry> RecentBids { get; set; } = new List<BidHistoryEntry>();
|
||||||
|
|
||||||
// Log per-asta (non serializzato)
|
// Log per-asta strutturato (non serializzato)
|
||||||
[System.Text.Json.Serialization.JsonIgnore]
|
[System.Text.Json.Serialization.JsonIgnore]
|
||||||
public List<string> AuctionLog { get; set; } = new();
|
public List<AuctionLogEntry> AuctionLog { get; set; } = new();
|
||||||
|
|
||||||
// Flag runtime: indica che è in corso un'operazione di final attack per questa asta
|
// Flag runtime: indica che è in corso un'operazione di final attack per questa asta
|
||||||
[System.Text.Json.Serialization.JsonIgnore]
|
[System.Text.Json.Serialization.JsonIgnore]
|
||||||
@@ -122,26 +173,411 @@ namespace AutoBidder.Models
|
|||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
public AuctionState? LastState { get; set; }
|
public AuctionState? LastState { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Aggiunge una voce al log dell'asta con limite automatico di righe
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="message">Messaggio da aggiungere al log</param>
|
|
||||||
/// <param name="maxLines">Numero massimo di righe da mantenere (default: 500)</param>
|
|
||||||
public void AddLog(string message, int maxLines = 500)
|
|
||||||
{
|
|
||||||
var entry = $"{DateTime.Now:HH:mm:ss.fff} - {message}";
|
|
||||||
AuctionLog.Add(entry);
|
|
||||||
|
|
||||||
// Mantieni solo gli ultimi maxLines log
|
/// <summary>
|
||||||
|
/// Aggiunge una voce strutturata al log dell'asta con deduplicazione e limite righe.
|
||||||
|
/// Parsifica automaticamente il tag [TAG] per determinare livello e categoria.
|
||||||
|
/// </summary>
|
||||||
|
public void AddLog(string message, int maxLines = 200)
|
||||||
|
{
|
||||||
|
// Protezione null-safety (dopo ClearData)
|
||||||
|
if (AuctionLog == null) AuctionLog = new();
|
||||||
|
|
||||||
|
var now = DateTime.Now;
|
||||||
|
|
||||||
|
// Parsifica tag dal messaggio per determinare livello e categoria
|
||||||
|
var (level, category, cleanMessage) = ParseLogTag(message);
|
||||||
|
|
||||||
|
// DEDUPLICAZIONE: Se l'ultimo messaggio è uguale, incrementa contatore
|
||||||
|
if (AuctionLog.Count > 0)
|
||||||
|
{
|
||||||
|
var last = AuctionLog[^1];
|
||||||
|
if (last.Message == cleanMessage && last.Category == category)
|
||||||
|
{
|
||||||
|
last.RepeatCount++;
|
||||||
|
last.Timestamp = now;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AuctionLog.Add(new AuctionLogEntry
|
||||||
|
{
|
||||||
|
Timestamp = now,
|
||||||
|
Level = level,
|
||||||
|
Category = category,
|
||||||
|
Message = cleanMessage
|
||||||
|
});
|
||||||
|
|
||||||
if (AuctionLog.Count > maxLines)
|
if (AuctionLog.Count > maxLines)
|
||||||
{
|
{
|
||||||
// Rimuovi i log più vecchi per mantenere la dimensione sotto controllo
|
AuctionLog.RemoveRange(0, AuctionLog.Count - maxLines);
|
||||||
int excessCount = AuctionLog.Count - maxLines;
|
|
||||||
AuctionLog.RemoveRange(0, excessCount);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Aggiunge una voce tipizzata al log dell'asta (senza parsing del tag).
|
||||||
|
/// </summary>
|
||||||
|
public void AddLog(string message, AuctionLogLevel level, AuctionLogCategory category)
|
||||||
|
{
|
||||||
|
// Protezione null-safety (dopo ClearData)
|
||||||
|
if (AuctionLog == null) AuctionLog = new();
|
||||||
|
|
||||||
|
var now = DateTime.Now;
|
||||||
|
|
||||||
|
if (AuctionLog.Count > 0)
|
||||||
|
{
|
||||||
|
var last = AuctionLog[^1];
|
||||||
|
if (last.Message == message && last.Category == category)
|
||||||
|
{
|
||||||
|
last.RepeatCount++;
|
||||||
|
last.Timestamp = now;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AuctionLog.Add(new AuctionLogEntry
|
||||||
|
{
|
||||||
|
Timestamp = now,
|
||||||
|
Level = level,
|
||||||
|
Category = category,
|
||||||
|
Message = message
|
||||||
|
});
|
||||||
|
|
||||||
|
if (AuctionLog.Count > MAX_LOG_LINES)
|
||||||
|
{
|
||||||
|
AuctionLog.RemoveRange(0, AuctionLog.Count - MAX_LOG_LINES);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parsifica i tag [TAG] per determinare livello e categoria automaticamente.
|
||||||
|
/// </summary>
|
||||||
|
private static (AuctionLogLevel level, AuctionLogCategory category, string cleanMessage) ParseLogTag(string message)
|
||||||
|
{
|
||||||
|
// Cerca pattern [TAG] all'inizio del messaggio
|
||||||
|
var tagMatch = System.Text.RegularExpressions.Regex.Match(message, @"^\[([A-Z_ ]+)\]\s*(.*)$");
|
||||||
|
if (!tagMatch.Success)
|
||||||
|
return (AuctionLogLevel.Info, AuctionLogCategory.General, message);
|
||||||
|
|
||||||
|
var tag = tagMatch.Groups[1].Value.Trim();
|
||||||
|
var cleanMsg = tagMatch.Groups[2].Value;
|
||||||
|
|
||||||
|
return tag switch
|
||||||
|
{
|
||||||
|
// Bid/puntata
|
||||||
|
"BID" => (AuctionLogLevel.Bid, AuctionLogCategory.BidAttempt, cleanMsg),
|
||||||
|
"BID OK" => (AuctionLogLevel.Success, AuctionLogCategory.BidResult, cleanMsg),
|
||||||
|
"BID FAIL" => (AuctionLogLevel.Error, AuctionLogCategory.BidResult, cleanMsg),
|
||||||
|
"BID EXCEPTION" => (AuctionLogLevel.Error, AuctionLogCategory.BidResult, cleanMsg),
|
||||||
|
"MANUAL BID" => (AuctionLogLevel.Bid, AuctionLogCategory.BidAttempt, cleanMsg),
|
||||||
|
"MANUAL BID OK" => (AuctionLogLevel.Success, AuctionLogCategory.BidResult, cleanMsg),
|
||||||
|
"MANUAL BID FAIL" => (AuctionLogLevel.Error, AuctionLogCategory.BidResult, cleanMsg),
|
||||||
|
|
||||||
|
// Timing
|
||||||
|
"TICKER" => (AuctionLogLevel.Timing, AuctionLogCategory.Ticker, cleanMsg),
|
||||||
|
"TIMING" or "\u26a0\ufe0f TIMING" => (AuctionLogLevel.Warning, AuctionLogCategory.Ticker, cleanMsg),
|
||||||
|
|
||||||
|
// Prezzi/limiti
|
||||||
|
"PRICE" => (AuctionLogLevel.Warning, AuctionLogCategory.Price, cleanMsg),
|
||||||
|
"VALUE" => (AuctionLogLevel.Warning, AuctionLogCategory.Value, cleanMsg),
|
||||||
|
"LIMIT" => (AuctionLogLevel.Warning, AuctionLogCategory.Limit, cleanMsg),
|
||||||
|
|
||||||
|
// Reset
|
||||||
|
var r when r.StartsWith("RESET") => (AuctionLogLevel.Info, AuctionLogCategory.Reset, cleanMsg),
|
||||||
|
|
||||||
|
// Strategie
|
||||||
|
"STRATEGY" => (AuctionLogLevel.Strategy, AuctionLogCategory.Strategy, cleanMsg),
|
||||||
|
"COMPETITION" => (AuctionLogLevel.Strategy, AuctionLogCategory.Competition, cleanMsg),
|
||||||
|
|
||||||
|
// Diagnostica
|
||||||
|
"DIAG" => (AuctionLogLevel.Debug, AuctionLogCategory.Diagnostic, cleanMsg),
|
||||||
|
"DEBUG" => (AuctionLogLevel.Debug, AuctionLogCategory.General, cleanMsg),
|
||||||
|
|
||||||
|
// Stato
|
||||||
|
"START" => (AuctionLogLevel.Info, AuctionLogCategory.Status, cleanMsg),
|
||||||
|
"ASTA TERMINATA" => (AuctionLogLevel.Warning, AuctionLogCategory.Status, cleanMsg),
|
||||||
|
"\u26a0\ufe0f SUGGERIMENTO" => (AuctionLogLevel.Warning, AuctionLogCategory.Ticker, cleanMsg),
|
||||||
|
|
||||||
|
// Polling
|
||||||
|
"POLL ERROR" => (AuctionLogLevel.Error, AuctionLogCategory.Polling, cleanMsg),
|
||||||
|
|
||||||
|
// Errori generici
|
||||||
|
"ERROR" or "ERRORE" => (AuctionLogLevel.Error, AuctionLogCategory.General, cleanMsg),
|
||||||
|
"WARN" => (AuctionLogLevel.Warning, AuctionLogCategory.General, cleanMsg),
|
||||||
|
"OK" => (AuctionLogLevel.Success, AuctionLogCategory.General, cleanMsg),
|
||||||
|
|
||||||
|
_ => (AuctionLogLevel.Info, AuctionLogCategory.General, message)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
public int PollingLatencyMs { get; set; } = 0; // Ultima latenza polling ms
|
public int PollingLatencyMs { get; set; } = 0; // Ultima latenza polling ms
|
||||||
|
|
||||||
|
// ???????????????????????????????????????????????????????????????
|
||||||
|
// TRACKING AVANZATO PER STRATEGIE
|
||||||
|
// ???????????????????????????????????????????????????????????????
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Storico latenze ultime N misurazioni (per media mobile)
|
||||||
|
/// </summary>
|
||||||
|
[JsonIgnore]
|
||||||
|
public List<int> LatencyHistory { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Numero massimo di latenze da memorizzare (ridotto per RAM)
|
||||||
|
/// </summary>
|
||||||
|
private const int MAX_LATENCY_HISTORY = 10;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Aggiunge una misurazione di latenza allo storico
|
||||||
|
/// </summary>
|
||||||
|
public void AddLatencyMeasurement(int latencyMs)
|
||||||
|
{
|
||||||
|
LatencyHistory.Add(latencyMs);
|
||||||
|
if (LatencyHistory.Count > MAX_LATENCY_HISTORY)
|
||||||
|
LatencyHistory.RemoveAt(0);
|
||||||
|
PollingLatencyMs = latencyMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Latenza media calcolata sullo storico
|
||||||
|
/// </summary>
|
||||||
|
[JsonIgnore]
|
||||||
|
public double AverageLatencyMs => LatencyHistory.Count > 0
|
||||||
|
? LatencyHistory.Average()
|
||||||
|
: PollingLatencyMs > 0 ? PollingLatencyMs : 60;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Heat metric (0-100) che indica quanto è "calda" l'asta
|
||||||
|
/// Calcolato in base a: bidder attivi, frequenza puntate, collisioni
|
||||||
|
/// </summary>
|
||||||
|
[JsonIgnore]
|
||||||
|
public int HeatMetric { get; set; } = 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Numero di bidder unici attivi negli ultimi N secondi
|
||||||
|
/// </summary>
|
||||||
|
[JsonIgnore]
|
||||||
|
public int ActiveBiddersCount { get; set; } = 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Numero di collisioni rilevate (puntate nello stesso secondo)
|
||||||
|
/// </summary>
|
||||||
|
[JsonIgnore]
|
||||||
|
public int CollisionCount { get; set; } = 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Collisioni consecutive senza puntata vincente
|
||||||
|
/// </summary>
|
||||||
|
[JsonIgnore]
|
||||||
|
public int ConsecutiveCollisions { get; set; } = 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Timestamp dell'ultimo soft retreat
|
||||||
|
/// </summary>
|
||||||
|
[JsonIgnore]
|
||||||
|
public DateTime? LastSoftRetreatAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Se true, l'asta è in soft retreat temporaneo
|
||||||
|
/// </summary>
|
||||||
|
[JsonIgnore]
|
||||||
|
public bool IsInSoftRetreat { get; set; } = false;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Contatore puntate effettuate in questa sessione su questa asta
|
||||||
|
/// </summary>
|
||||||
|
[JsonIgnore]
|
||||||
|
public int SessionBidCount { get; set; } = 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Numero di volte che il timer è scaduto prima della puntata
|
||||||
|
/// </summary>
|
||||||
|
[JsonIgnore]
|
||||||
|
public int TimerExpiredCount { get; set; } = 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Numero di puntate riuscite
|
||||||
|
/// </summary>
|
||||||
|
[JsonIgnore]
|
||||||
|
public int SuccessfulBidCount { get; set; } = 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Numero di puntate fallite
|
||||||
|
/// </summary>
|
||||||
|
[JsonIgnore]
|
||||||
|
public int FailedBidCount { get; set; } = 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lista utenti identificati come aggressivi in questa asta
|
||||||
|
/// </summary>
|
||||||
|
[JsonIgnore]
|
||||||
|
public HashSet<string> AggressiveBidders { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Offset dinamico calcolato per questa asta (ms)
|
||||||
|
/// </summary>
|
||||||
|
[JsonIgnore]
|
||||||
|
public int DynamicOffsetMs { get; set; } = 150;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Offset effettivo usato nell'ultima puntata (include jitter)
|
||||||
|
/// </summary>
|
||||||
|
[JsonIgnore]
|
||||||
|
public int LastUsedOffsetMs { get; set; } = 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Indica se questa asta è stata seguita dall'inizio (per salvare storia completa)
|
||||||
|
/// </summary>
|
||||||
|
public bool IsTrackedFromStart { get; set; } = false;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Timestamp di inizio tracking
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? TrackingStartedAt { get; set; }
|
||||||
|
|
||||||
|
// ???????????????????????????????????????????????????????????????
|
||||||
|
// IMPOSTAZIONI PER-ASTA (override globali)
|
||||||
|
// ???????????????????????????????????????????????????????????????
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Override: abilita/disabilita strategie avanzate per questa asta
|
||||||
|
/// null = usa impostazione globale
|
||||||
|
/// </summary>
|
||||||
|
public bool? AdvancedStrategiesEnabled { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Override: abilita/disabilita jitter per questa asta
|
||||||
|
/// </summary>
|
||||||
|
public bool? JitterEnabledOverride { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Override: abilita/disabilita soft retreat per questa asta
|
||||||
|
/// </summary>
|
||||||
|
public bool? SoftRetreatEnabledOverride { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Override: limite puntate per questa asta
|
||||||
|
/// </summary>
|
||||||
|
public int? MaxBidsOverride { get; set; }
|
||||||
|
|
||||||
|
// ?? NUOVO: Rilevamento situazione di duello
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True se rilevata situazione di duello (solo 2 bidder dominanti)
|
||||||
|
/// </summary>
|
||||||
|
[JsonIgnore]
|
||||||
|
public bool IsDuelSituation { get; set; } = false;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Username dell'avversario in caso di duello
|
||||||
|
/// </summary>
|
||||||
|
[JsonIgnore]
|
||||||
|
public string? DuelOpponent { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Vantaggio/svantaggio nel duello (% puntate mie - % puntate avversario)
|
||||||
|
/// Positivo = sto dominando, Negativo = sto perdendo
|
||||||
|
/// </summary>
|
||||||
|
[JsonIgnore]
|
||||||
|
public double DuelAdvantage { get; set; } = 0;
|
||||||
|
|
||||||
|
// ???????????????????????????????????????????????????????????????????
|
||||||
|
// GESTIONE MEMORIA
|
||||||
|
// ???????????????????????????????????????????????????????????????????
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pulisce tutti i dati in memoria dell'asta per liberare RAM.
|
||||||
|
/// Chiamare prima di rimuovere l'asta dalla lista.
|
||||||
|
/// </summary>
|
||||||
|
public void ClearData()
|
||||||
|
{
|
||||||
|
// Pulisci liste storiche
|
||||||
|
BidHistory?.Clear();
|
||||||
|
BidHistory = null!;
|
||||||
|
|
||||||
|
RecentBids?.Clear();
|
||||||
|
RecentBids = null!;
|
||||||
|
|
||||||
|
AuctionLog?.Clear();
|
||||||
|
AuctionLog = null!;
|
||||||
|
|
||||||
|
BidderStats?.Clear();
|
||||||
|
BidderStats = null!;
|
||||||
|
|
||||||
|
LatencyHistory?.Clear();
|
||||||
|
LatencyHistory = null!;
|
||||||
|
|
||||||
|
AggressiveBidders?.Clear();
|
||||||
|
AggressiveBidders = null!;
|
||||||
|
|
||||||
|
// Pulisci oggetti complessi
|
||||||
|
LastState = null;
|
||||||
|
PendingEndState = null;
|
||||||
|
CalculatedValue = null;
|
||||||
|
DuelOpponent = null;
|
||||||
|
WinLimitDescription = null;
|
||||||
|
|
||||||
|
// Reset flag
|
||||||
|
IsTrackedFromStart = false;
|
||||||
|
TrackingStartedAt = null;
|
||||||
|
DeadlineUtc = null;
|
||||||
|
LastDeadlineUpdateUtc = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Compatta i dati mantenendo solo le informazioni recenti.
|
||||||
|
/// Utile per ridurre la memoria senza eliminare completamente i dati.
|
||||||
|
/// </summary>
|
||||||
|
public void CompactData(int maxBidHistory = 50, int maxRecentBids = 30, int maxLogLines = 100)
|
||||||
|
{
|
||||||
|
// Compatta BidHistory
|
||||||
|
if (BidHistory != null && BidHistory.Count > maxBidHistory)
|
||||||
|
{
|
||||||
|
var recent = BidHistory.TakeLast(maxBidHistory).ToList();
|
||||||
|
BidHistory.Clear();
|
||||||
|
BidHistory.AddRange(recent);
|
||||||
|
BidHistory.TrimExcess();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compatta RecentBids
|
||||||
|
if (RecentBids != null && RecentBids.Count > maxRecentBids)
|
||||||
|
{
|
||||||
|
var recent = RecentBids.TakeLast(maxRecentBids).ToList();
|
||||||
|
RecentBids.Clear();
|
||||||
|
RecentBids.AddRange(recent);
|
||||||
|
RecentBids.TrimExcess();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compatta AuctionLog
|
||||||
|
if (AuctionLog != null && AuctionLog.Count > maxLogLines)
|
||||||
|
{
|
||||||
|
var recent = AuctionLog.TakeLast(maxLogLines).ToList();
|
||||||
|
AuctionLog.Clear();
|
||||||
|
AuctionLog.AddRange(recent);
|
||||||
|
AuctionLog.TrimExcess();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compatta LatencyHistory
|
||||||
|
if (LatencyHistory != null && LatencyHistory.Count > 10)
|
||||||
|
{
|
||||||
|
var recent = LatencyHistory.TakeLast(10).ToList();
|
||||||
|
LatencyHistory.Clear();
|
||||||
|
LatencyHistory.AddRange(recent);
|
||||||
|
LatencyHistory.TrimExcess();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compatta BidderStats - mantieni solo i top bidders
|
||||||
|
if (BidderStats != null && BidderStats.Count > 20)
|
||||||
|
{
|
||||||
|
var topBidders = BidderStats
|
||||||
|
.OrderByDescending(kv => kv.Value.BidCount)
|
||||||
|
.Take(20)
|
||||||
|
.ToDictionary(kv => kv.Key, kv => kv.Value, StringComparer.OrdinalIgnoreCase);
|
||||||
|
BidderStats.Clear();
|
||||||
|
foreach (var kv in topBidders)
|
||||||
|
BidderStats[kv.Key] = kv.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace AutoBidder.Models
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Entry strutturata per il log di una singola asta.
|
||||||
|
/// Contiene timestamp preciso, livello di gravità, categoria e messaggio.
|
||||||
|
/// </summary>
|
||||||
|
public class AuctionLogEntry
|
||||||
|
{
|
||||||
|
public DateTime Timestamp { get; set; }
|
||||||
|
public AuctionLogLevel Level { get; set; }
|
||||||
|
public AuctionLogCategory Category { get; set; }
|
||||||
|
public string Message { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Contatore deduplicazione (se > 1, il messaggio è stato ripetuto)
|
||||||
|
/// </summary>
|
||||||
|
public int RepeatCount { get; set; } = 1;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Formato compatto per display: solo ora con millisecondi
|
||||||
|
/// </summary>
|
||||||
|
public string TimeDisplay => Timestamp.ToString("HH:mm:ss.fff");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Icona Bootstrap per il livello
|
||||||
|
/// </summary>
|
||||||
|
public string LevelIcon => Level switch
|
||||||
|
{
|
||||||
|
AuctionLogLevel.Error => "bi-x-circle-fill",
|
||||||
|
AuctionLogLevel.Warning => "bi-exclamation-triangle-fill",
|
||||||
|
AuctionLogLevel.Success => "bi-check-circle-fill",
|
||||||
|
AuctionLogLevel.Bid => "bi-hand-index-thumb-fill",
|
||||||
|
AuctionLogLevel.Strategy => "bi-shield-fill",
|
||||||
|
AuctionLogLevel.Timing => "bi-stopwatch-fill",
|
||||||
|
AuctionLogLevel.Debug => "bi-bug-fill",
|
||||||
|
_ => "bi-info-circle-fill"
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Classe CSS per il livello
|
||||||
|
/// </summary>
|
||||||
|
public string LevelClass => Level switch
|
||||||
|
{
|
||||||
|
AuctionLogLevel.Error => "alog-error",
|
||||||
|
AuctionLogLevel.Warning => "alog-warning",
|
||||||
|
AuctionLogLevel.Success => "alog-success",
|
||||||
|
AuctionLogLevel.Bid => "alog-bid",
|
||||||
|
AuctionLogLevel.Strategy => "alog-strategy",
|
||||||
|
AuctionLogLevel.Timing => "alog-timing",
|
||||||
|
AuctionLogLevel.Debug => "alog-debug",
|
||||||
|
_ => "alog-info"
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Label breve del livello
|
||||||
|
/// </summary>
|
||||||
|
public string LevelLabel => Level switch
|
||||||
|
{
|
||||||
|
AuctionLogLevel.Error => "ERR",
|
||||||
|
AuctionLogLevel.Warning => "WARN",
|
||||||
|
AuctionLogLevel.Success => "OK",
|
||||||
|
AuctionLogLevel.Bid => "BID",
|
||||||
|
AuctionLogLevel.Strategy => "STRAT",
|
||||||
|
AuctionLogLevel.Timing => "TIME",
|
||||||
|
AuctionLogLevel.Debug => "DBG",
|
||||||
|
_ => "INFO"
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Label della categoria
|
||||||
|
/// </summary>
|
||||||
|
public string CategoryLabel => Category switch
|
||||||
|
{
|
||||||
|
AuctionLogCategory.Ticker => "Ticker",
|
||||||
|
AuctionLogCategory.Price => "Prezzo",
|
||||||
|
AuctionLogCategory.Reset => "Reset",
|
||||||
|
AuctionLogCategory.BidAttempt => "Puntata",
|
||||||
|
AuctionLogCategory.BidResult => "Risultato",
|
||||||
|
AuctionLogCategory.Strategy => "Strategia",
|
||||||
|
AuctionLogCategory.Value => "Valore",
|
||||||
|
AuctionLogCategory.Competition => "Compet.",
|
||||||
|
AuctionLogCategory.Limit => "Limite",
|
||||||
|
AuctionLogCategory.Diagnostic => "Diagn.",
|
||||||
|
AuctionLogCategory.Status => "Stato",
|
||||||
|
AuctionLogCategory.Polling => "Poll",
|
||||||
|
_ => "Generale"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Livello di gravità del log per-asta
|
||||||
|
/// </summary>
|
||||||
|
public enum AuctionLogLevel
|
||||||
|
{
|
||||||
|
Debug = 0,
|
||||||
|
Info = 1,
|
||||||
|
Timing = 2,
|
||||||
|
Strategy = 3,
|
||||||
|
Bid = 4,
|
||||||
|
Success = 5,
|
||||||
|
Warning = 6,
|
||||||
|
Error = 7
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Categoria del log per filtraggio e raggruppamento
|
||||||
|
/// </summary>
|
||||||
|
public enum AuctionLogCategory
|
||||||
|
{
|
||||||
|
General,
|
||||||
|
Ticker,
|
||||||
|
Price,
|
||||||
|
Reset,
|
||||||
|
BidAttempt,
|
||||||
|
BidResult,
|
||||||
|
Strategy,
|
||||||
|
Value,
|
||||||
|
Competition,
|
||||||
|
Limit,
|
||||||
|
Diagnostic,
|
||||||
|
Status,
|
||||||
|
Polling
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,12 +3,25 @@ using System;
|
|||||||
namespace AutoBidder.Models
|
namespace AutoBidder.Models
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Informazioni su un utente che ha piazzato puntate
|
/// Informazioni su un utente che ha piazzato puntate.
|
||||||
|
/// Il conteggio è CUMULATIVO dall'inizio del monitoraggio (non limitato come RecentBids).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class BidderInfo
|
public class BidderInfo
|
||||||
{
|
{
|
||||||
public string Username { get; set; } = "";
|
public string Username { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Conteggio CUMULATIVO delle puntate dall'inizio del monitoraggio.
|
||||||
|
/// Questo valore non viene mai decrementato anche se RecentBids viene troncato.
|
||||||
|
/// </summary>
|
||||||
public int BidCount { get; set; } = 0;
|
public int BidCount { get; set; } = 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Conteggio puntate visibili nell'attuale finestra RecentBids (per UI).
|
||||||
|
/// Può essere inferiore a BidCount se RecentBids è stato troncato.
|
||||||
|
/// </summary>
|
||||||
|
public int RecentBidCount { get; set; } = 0;
|
||||||
|
|
||||||
public DateTime LastBidTime { get; set; } = DateTime.MinValue;
|
public DateTime LastBidTime { get; set; } = DateTime.MinValue;
|
||||||
|
|
||||||
public string LastBidTimeDisplay => LastBidTime == DateTime.MinValue
|
public string LastBidTimeDisplay => LastBidTime == DateTime.MinValue
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace AutoBidder.Models
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Rappresenta un'asta visualizzata nel browser delle aste
|
||||||
|
/// Contiene informazioni base per la visualizzazione nella griglia
|
||||||
|
/// </summary>
|
||||||
|
public class BidooBrowserAuction
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// ID univoco dell'asta
|
||||||
|
/// </summary>
|
||||||
|
public string AuctionId { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// URL completo dell'asta
|
||||||
|
/// </summary>
|
||||||
|
public string Url { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Nome/titolo del prodotto
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// URL dell'immagine del prodotto
|
||||||
|
/// </summary>
|
||||||
|
public string ImageUrl { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Prezzo attuale dell'asta in euro
|
||||||
|
/// </summary>
|
||||||
|
public decimal CurrentPrice { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Username dell'ultimo bidder
|
||||||
|
/// </summary>
|
||||||
|
public string LastBidder { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tempo rimanente in secondi
|
||||||
|
/// </summary>
|
||||||
|
public int RemainingSeconds { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Timer formattato (es: "00:08")
|
||||||
|
/// </summary>
|
||||||
|
public string TimerDisplay => $"{RemainingSeconds / 60:00}:{RemainingSeconds % 60:00}";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Frequenza timer dell'asta (in secondi)
|
||||||
|
/// </summary>
|
||||||
|
public int TimerFrequency { get; set; } = 8;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Prezzo "Compralo Subito"
|
||||||
|
/// </summary>
|
||||||
|
public decimal BuyNowPrice { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Indica se l'asta è già stata aggiunta al monitor
|
||||||
|
/// </summary>
|
||||||
|
public bool IsMonitored { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Indica se l'asta è attiva (non chiusa)
|
||||||
|
/// </summary>
|
||||||
|
public bool IsActive { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Indica se l'asta è venduta
|
||||||
|
/// </summary>
|
||||||
|
public bool IsSold { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Indica se l'asta richiede solo puntate manuali (no autobid)
|
||||||
|
/// </summary>
|
||||||
|
public bool IsManualOnly { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Indica se è un'asta turbo (timer < 10 sec)
|
||||||
|
/// </summary>
|
||||||
|
public bool IsTurbo => TimerFrequency <= 8;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ID del prodotto
|
||||||
|
/// </summary>
|
||||||
|
public int ProductId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Indica se l'asta è un'asta di puntate/crediti
|
||||||
|
/// </summary>
|
||||||
|
public bool IsCreditAuction { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Valore crediti se è un'asta di puntate
|
||||||
|
/// </summary>
|
||||||
|
public int CreditValue { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Timestamp ultimo aggiornamento stato
|
||||||
|
/// </summary>
|
||||||
|
public DateTime LastUpdated { get; set; } = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
namespace AutoBidder.Models
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Rappresenta una categoria/scheda di aste su Bidoo
|
||||||
|
/// </summary>
|
||||||
|
public class BidooCategoryInfo
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// ID del tab (es: 1, 2, 3, 4, 5)
|
||||||
|
/// </summary>
|
||||||
|
public int TabId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ID del tag per le categorie specifiche (es: 6=Buoni, 5=Smartphone)
|
||||||
|
/// </summary>
|
||||||
|
public int TagId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Slug della categoria (es: "buoni", "smartphone")
|
||||||
|
/// </summary>
|
||||||
|
public string Slug { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Nome visualizzato della categoria
|
||||||
|
/// </summary>
|
||||||
|
public string DisplayName { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Indica se questa categoria è una categoria speciale (preferite, tutte, puntate, manuali)
|
||||||
|
/// </summary>
|
||||||
|
public bool IsSpecialCategory { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Icona da mostrare (opzionale)
|
||||||
|
/// </summary>
|
||||||
|
public string? Icon { get; set; }
|
||||||
|
|
||||||
|
public override string ToString() => DisplayName;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
namespace AutoBidder.Models
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Record per le statistiche aggregate di un prodotto nel database
|
||||||
|
/// </summary>
|
||||||
|
public class ProductStatisticsRecord
|
||||||
|
{
|
||||||
|
public string ProductKey { get; set; } = string.Empty;
|
||||||
|
public string ProductName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
// Contatori
|
||||||
|
public int TotalAuctions { get; set; }
|
||||||
|
public int WonAuctions { get; set; }
|
||||||
|
public int LostAuctions { get; set; }
|
||||||
|
|
||||||
|
// Statistiche prezzo
|
||||||
|
public double AvgFinalPrice { get; set; }
|
||||||
|
public double? MinFinalPrice { get; set; }
|
||||||
|
public double? MaxFinalPrice { get; set; }
|
||||||
|
public double? MedianFinalPrice { get; set; }
|
||||||
|
|
||||||
|
// Statistiche puntate
|
||||||
|
public double AvgBidsToWin { get; set; }
|
||||||
|
public int? MinBidsToWin { get; set; }
|
||||||
|
public int? MaxBidsToWin { get; set; }
|
||||||
|
|
||||||
|
// Statistiche reset
|
||||||
|
public double AvgResets { get; set; }
|
||||||
|
public int? MinResets { get; set; }
|
||||||
|
public int? MaxResets { get; set; }
|
||||||
|
|
||||||
|
// Limiti consigliati (calcolati dall'algoritmo)
|
||||||
|
public double? RecommendedMinPrice { get; set; }
|
||||||
|
public double? RecommendedMaxPrice { get; set; }
|
||||||
|
public int? RecommendedMinResets { get; set; }
|
||||||
|
public int? RecommendedMaxResets { get; set; }
|
||||||
|
public int? RecommendedMaxBids { get; set; }
|
||||||
|
|
||||||
|
// Valori di default definiti dall'utente (editabili)
|
||||||
|
public double? UserDefaultMinPrice { get; set; }
|
||||||
|
public double? UserDefaultMaxPrice { get; set; }
|
||||||
|
public int? UserDefaultMinResets { get; set; }
|
||||||
|
public int? UserDefaultMaxResets { get; set; }
|
||||||
|
public int? UserDefaultMaxBids { get; set; }
|
||||||
|
public int? UserDefaultBidBeforeDeadlineMs { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Se true, usa i limiti personalizzati del prodotto. Se false, usa i globali.
|
||||||
|
/// </summary>
|
||||||
|
public bool UseCustomLimits { get; set; }
|
||||||
|
|
||||||
|
// JSON con statistiche per fascia oraria
|
||||||
|
public string? HourlyStatsJson { get; set; }
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
public string? LastUpdated { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Calcola il win rate come percentuale
|
||||||
|
/// </summary>
|
||||||
|
public double WinRate => TotalAuctions > 0 ? (double)WonAuctions / TotalAuctions * 100 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Risultato asta esteso con tutti i campi per analytics
|
||||||
|
/// </summary>
|
||||||
|
public class AuctionResultExtended
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string AuctionId { get; set; } = "";
|
||||||
|
public string AuctionName { get; set; } = "";
|
||||||
|
public double FinalPrice { get; set; }
|
||||||
|
public int BidsUsed { get; set; }
|
||||||
|
public bool Won { get; set; }
|
||||||
|
public string Timestamp { get; set; } = "";
|
||||||
|
public double? BuyNowPrice { get; set; }
|
||||||
|
public double? ShippingCost { get; set; }
|
||||||
|
public double? TotalCost { get; set; }
|
||||||
|
public double? Savings { get; set; }
|
||||||
|
|
||||||
|
// Campi estesi per analytics
|
||||||
|
public string? WinnerUsername { get; set; }
|
||||||
|
public int? ClosedAtHour { get; set; }
|
||||||
|
public string? ProductKey { get; set; }
|
||||||
|
public int? TotalResets { get; set; }
|
||||||
|
public int? WinnerBidsUsed { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Limiti consigliati per un'asta basati sulle statistiche storiche
|
||||||
|
/// </summary>
|
||||||
|
public class RecommendedLimits
|
||||||
|
{
|
||||||
|
public double MinPrice { get; set; }
|
||||||
|
public double MaxPrice { get; set; }
|
||||||
|
public int MinResets { get; set; }
|
||||||
|
public int MaxResets { get; set; }
|
||||||
|
public int MaxBids { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Confidence score (0-100) - quanto sono affidabili questi limiti
|
||||||
|
/// </summary>
|
||||||
|
public int ConfidenceScore { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Numero di aste usate per calcolare i limiti
|
||||||
|
/// </summary>
|
||||||
|
public int SampleSize { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fascia oraria migliore per vincere (0-23)
|
||||||
|
/// </summary>
|
||||||
|
public int? BestHourToPlay { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Win rate medio per questo prodotto
|
||||||
|
/// </summary>
|
||||||
|
public double? AverageWinRate { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Statistiche per fascia oraria
|
||||||
|
/// </summary>
|
||||||
|
public class HourlyStats
|
||||||
|
{
|
||||||
|
public int Hour { get; set; }
|
||||||
|
public int TotalAuctions { get; set; }
|
||||||
|
public int WonAuctions { get; set; }
|
||||||
|
public double AvgFinalPrice { get; set; }
|
||||||
|
public double AvgBidsUsed { get; set; }
|
||||||
|
|
||||||
|
public double WinRate => TotalAuctions > 0 ? (double)WonAuctions / TotalAuctions * 100 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Record completo storia asta con tutte le metriche avanzate
|
||||||
|
/// </summary>
|
||||||
|
public class CompleteAuctionHistoryRecord
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string AuctionId { get; set; } = "";
|
||||||
|
public string AuctionName { get; set; } = "";
|
||||||
|
public string? ProductKey { get; set; }
|
||||||
|
public string? OriginalUrl { get; set; }
|
||||||
|
|
||||||
|
// Dati finali
|
||||||
|
public double FinalPrice { get; set; }
|
||||||
|
public double? BuyNowPrice { get; set; }
|
||||||
|
public double? ShippingCost { get; set; }
|
||||||
|
public double? TotalCost { get; set; }
|
||||||
|
public double? Savings { get; set; }
|
||||||
|
public double? SavingsPercentage { get; set; }
|
||||||
|
|
||||||
|
// Risultato
|
||||||
|
public bool Won { get; set; }
|
||||||
|
public string? WinnerUsername { get; set; }
|
||||||
|
public int? WinnerBidsUsed { get; set; }
|
||||||
|
|
||||||
|
// Metriche competizione
|
||||||
|
public int TotalResets { get; set; }
|
||||||
|
public int TotalUniqueBidders { get; set; }
|
||||||
|
public int MaxHeatMetric { get; set; }
|
||||||
|
public double AvgHeatMetric { get; set; }
|
||||||
|
public int TotalCollisions { get; set; }
|
||||||
|
|
||||||
|
// Mie statistiche
|
||||||
|
public int MyBidsUsed { get; set; }
|
||||||
|
public int MySuccessfulBids { get; set; }
|
||||||
|
public int MyFailedBids { get; set; }
|
||||||
|
public int MyTimerExpired { get; set; }
|
||||||
|
public double? MyAvgLatencyMs { get; set; }
|
||||||
|
|
||||||
|
// Timestamps
|
||||||
|
public DateTime ClosedAt { get; set; }
|
||||||
|
public int ClosedAtHour { get; set; }
|
||||||
|
public int? DurationSeconds { get; set; }
|
||||||
|
public bool IsCompleteTracking { get; set; }
|
||||||
|
|
||||||
|
// JSON
|
||||||
|
public string? AggressiveBiddersJson { get; set; }
|
||||||
|
public string? BiddersSummaryJson { get; set; }
|
||||||
|
|
||||||
|
// Proprietà calcolate
|
||||||
|
public string DurationFormatted => DurationSeconds.HasValue
|
||||||
|
? TimeSpan.FromSeconds(DurationSeconds.Value).ToString(@"hh\:mm\:ss")
|
||||||
|
: "-";
|
||||||
|
|
||||||
|
public double SuccessRate => (MySuccessfulBids + MyFailedBids) > 0
|
||||||
|
? (double)MySuccessfulBids / (MySuccessfulBids + MyFailedBids) * 100
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
@page
|
||||||
|
@model AutoBidder.Pages.Account.LoginModel
|
||||||
|
@{
|
||||||
|
Layout = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="it">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Login - AutoBidder</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet" />
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet" />
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
||||||
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 40px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 380px;
|
||||||
|
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 35px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header h1 {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header p {
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-floating {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-floating .form-control {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 12px;
|
||||||
|
color: #fff;
|
||||||
|
height: 55px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-floating .form-control:focus {
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
border-color: #4f46e5;
|
||||||
|
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.25);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-floating .form-control::placeholder {
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-floating label {
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-floating .form-control:focus ~ label,
|
||||||
|
.form-floating .form-control:not(:placeholder-shown) ~ label {
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-check {
|
||||||
|
margin-bottom: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-check-input {
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
border-color: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-check-input:checked {
|
||||||
|
background-color: #4f46e5;
|
||||||
|
border-color: #4f46e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-check-label {
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-login {
|
||||||
|
width: 100%;
|
||||||
|
background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 14px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-login:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 10px 30px rgba(79, 70, 229, 0.4);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-login:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-error {
|
||||||
|
background: rgba(239, 68, 68, 0.15);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||||
|
border-radius: 12px;
|
||||||
|
color: #fca5a5;
|
||||||
|
padding: 12px 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-footer {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 25px;
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-footer small {
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-footer i {
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="login-card">
|
||||||
|
<div class="login-header">
|
||||||
|
<h1>AutoBidder</h1>
|
||||||
|
<p>Sistema Gestione Aste Bidoo</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!string.IsNullOrEmpty(Model.ErrorMessage))
|
||||||
|
{
|
||||||
|
<div class="alert-error">
|
||||||
|
<i class="bi bi-exclamation-circle"></i>
|
||||||
|
@Model.ErrorMessage
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<form method="post">
|
||||||
|
@Html.AntiForgeryToken()
|
||||||
|
|
||||||
|
<div class="form-floating">
|
||||||
|
<input type="text" class="form-control" id="username" name="Username"
|
||||||
|
placeholder="Username" value="@Model.Username" required autocomplete="username" />
|
||||||
|
<label for="username"><i class="bi bi-person"></i> Username</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-floating">
|
||||||
|
<input type="password" class="form-control" id="password" name="Password"
|
||||||
|
placeholder="Password" required autocomplete="current-password" />
|
||||||
|
<label for="password"><i class="bi bi-lock"></i> Password</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="rememberMe" name="RememberMe" value="true" />
|
||||||
|
<label class="form-check-label" for="rememberMe">Ricordami</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-login">
|
||||||
|
<i class="bi bi-box-arrow-in-right"></i> Accedi
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="login-footer">
|
||||||
|
<small><i class="bi bi-shield-lock"></i> Connessione sicura</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
using Microsoft.AspNetCore.Authentication;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
using AutoBidder.Models;
|
||||||
|
|
||||||
|
namespace AutoBidder.Pages.Account;
|
||||||
|
|
||||||
|
public class LoginModel : PageModel
|
||||||
|
{
|
||||||
|
private readonly SignInManager<ApplicationUser> _signInManager;
|
||||||
|
private readonly UserManager<ApplicationUser> _userManager;
|
||||||
|
|
||||||
|
public LoginModel(SignInManager<ApplicationUser> signInManager, UserManager<ApplicationUser> userManager)
|
||||||
|
{
|
||||||
|
_signInManager = signInManager;
|
||||||
|
_userManager = userManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
[BindProperty]
|
||||||
|
public string Username { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[BindProperty]
|
||||||
|
public string Password { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[BindProperty]
|
||||||
|
public bool RememberMe { get; set; }
|
||||||
|
|
||||||
|
public string? ErrorMessage { get; set; }
|
||||||
|
|
||||||
|
[FromQuery(Name = "returnUrl")]
|
||||||
|
public string? ReturnUrl { get; set; }
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnGetAsync()
|
||||||
|
{
|
||||||
|
// Se già autenticato, vai alla home
|
||||||
|
if (User.Identity?.IsAuthenticated == true)
|
||||||
|
{
|
||||||
|
return LocalRedirect(GetSafeReturnUrl());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logout eventuali sessioni precedenti
|
||||||
|
await HttpContext.SignOutAsync(IdentityConstants.ApplicationScheme);
|
||||||
|
|
||||||
|
return Page();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnPostAsync()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(Username) || string.IsNullOrEmpty(Password))
|
||||||
|
{
|
||||||
|
ErrorMessage = "Inserisci username e password.";
|
||||||
|
return Page();
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await _signInManager.PasswordSignInAsync(
|
||||||
|
Username,
|
||||||
|
Password,
|
||||||
|
RememberMe,
|
||||||
|
lockoutOnFailure: true
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.Succeeded)
|
||||||
|
{
|
||||||
|
return LocalRedirect(GetSafeReturnUrl());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.IsLockedOut)
|
||||||
|
{
|
||||||
|
ErrorMessage = "Account bloccato. Riprova tra qualche minuto.";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ErrorMessage = "Username o password non validi.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return Page();
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetSafeReturnUrl()
|
||||||
|
{
|
||||||
|
// Ritorna solo URL locali sicuri
|
||||||
|
if (!string.IsNullOrEmpty(ReturnUrl) && Url.IsLocalUrl(ReturnUrl))
|
||||||
|
{
|
||||||
|
return ReturnUrl;
|
||||||
|
}
|
||||||
|
return "/";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
@page
|
||||||
|
@model AutoBidder.Pages.Account.LogoutModel
|
||||||
|
@{
|
||||||
|
Layout = null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
using Microsoft.AspNetCore.Authentication;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
|
||||||
|
namespace AutoBidder.Pages.Account;
|
||||||
|
|
||||||
|
public class LogoutModel : PageModel
|
||||||
|
{
|
||||||
|
public async Task<IActionResult> OnGetAsync()
|
||||||
|
{
|
||||||
|
await HttpContext.SignOutAsync(IdentityConstants.ApplicationScheme);
|
||||||
|
return Redirect("/Account/Login");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnPostAsync()
|
||||||
|
{
|
||||||
|
await HttpContext.SignOutAsync(IdentityConstants.ApplicationScheme);
|
||||||
|
return Redirect("/Account/Login");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,671 @@
|
|||||||
|
@page "/browser"
|
||||||
|
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||||
|
@using AutoBidder.Models
|
||||||
|
@using AutoBidder.Services
|
||||||
|
@inject BidooBrowserService BrowserService
|
||||||
|
@inject ApplicationStateService AppState
|
||||||
|
@inject AuctionMonitor AuctionMonitor
|
||||||
|
@inject IJSRuntime JSRuntime
|
||||||
|
@implements IDisposable
|
||||||
|
|
||||||
|
<PageTitle>Esplora Aste - AutoBidder</PageTitle>
|
||||||
|
|
||||||
|
<div class="browser-container animate-fade-in p-4">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="d-flex align-items-center justify-content-between mb-4 flex-wrap gap-3">
|
||||||
|
<div class="d-flex align-items-center animate-fade-in-down">
|
||||||
|
<i class="bi bi-search text-primary me-3" style="font-size: 2rem;"></i>
|
||||||
|
<div>
|
||||||
|
<h2 class="mb-0 fw-bold">Esplora Aste</h2>
|
||||||
|
<small class="text-muted">Naviga le aste pubbliche di Bidoo senza login</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex gap-2 flex-wrap">
|
||||||
|
<button class="btn btn-outline-secondary" @onclick="RefreshAll" disabled="@isLoading">
|
||||||
|
<i class="bi @(isLoading ? "bi-arrow-clockwise spin" : "bi-arrow-clockwise")"></i>
|
||||||
|
Aggiorna
|
||||||
|
</button>
|
||||||
|
@if (auctions.Count > 0)
|
||||||
|
{
|
||||||
|
<button class="btn btn-outline-danger" @onclick="ClearAllAuctions" title="Rimuove tutte le aste caricate e ferma il polling">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
Pulisci Tutto
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Category Selector -->
|
||||||
|
<div class="card mb-4 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-3 align-items-end">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label fw-semibold">
|
||||||
|
<i class="bi bi-tag me-2"></i>Categoria
|
||||||
|
</label>
|
||||||
|
<select class="form-select form-select-lg" @bind="selectedCategoryIndex" @bind:after="OnCategoryChanged">
|
||||||
|
@if (categories.Count == 0)
|
||||||
|
{
|
||||||
|
<option value="-1">Caricamento categorie...</option>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@for (int i = 0; i < categories.Count; i++)
|
||||||
|
{
|
||||||
|
<option value="@i">
|
||||||
|
@if (!string.IsNullOrEmpty(categories[i].Icon))
|
||||||
|
{
|
||||||
|
@categories[i].DisplayName
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@categories[i].DisplayName
|
||||||
|
}
|
||||||
|
</option>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="stats-mini">
|
||||||
|
<span class="text-muted">Aste caricate:</span>
|
||||||
|
<span class="fw-bold text-primary ms-2">@auctions.Count</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="stats-mini">
|
||||||
|
<span class="text-muted">Monitorate:</span>
|
||||||
|
<span class="fw-bold text-success ms-2">@auctions.Count(a => a.IsMonitored)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ? NUOVO: Search Bar -->
|
||||||
|
<div class="card mb-4 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-3 align-items-center">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="input-group input-group-lg">
|
||||||
|
<span class="input-group-text bg-primary text-white border-0">
|
||||||
|
<i class="bi bi-search"></i>
|
||||||
|
</span>
|
||||||
|
<input type="text"
|
||||||
|
class="form-control form-control-lg border-0"
|
||||||
|
placeholder="Cerca per nome asta, prezzo, vincitore..."
|
||||||
|
@bind="searchQuery"
|
||||||
|
@bind:event="oninput"
|
||||||
|
@bind:after="OnSearchChanged" />
|
||||||
|
@if (!string.IsNullOrEmpty(searchQuery))
|
||||||
|
{
|
||||||
|
<button class="btn btn-outline-secondary border-0"
|
||||||
|
@onclick="ClearSearch"
|
||||||
|
title="Cancella ricerca">
|
||||||
|
<i class="bi bi-x-lg"></i>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="stats-mini">
|
||||||
|
<span class="text-muted">Risultati filtrati:</span>
|
||||||
|
<span class="fw-bold text-info ms-2">@filteredAuctions.Count</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading State -->
|
||||||
|
@if (isLoading)
|
||||||
|
{
|
||||||
|
<div class="text-center py-5 animate-fade-in">
|
||||||
|
<div class="spinner-border text-primary mb-3" role="status" style="width: 3rem; height: 3rem;">
|
||||||
|
<span class="visually-hidden">Caricamento...</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted">Caricamento aste in corso...</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (errorMessage != null)
|
||||||
|
{
|
||||||
|
<div class="alert alert-warning d-flex align-items-center animate-scale-in">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill me-3" style="font-size: 1.5rem;"></i>
|
||||||
|
<div>
|
||||||
|
<strong>Attenzione</strong><br />
|
||||||
|
@errorMessage
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (filteredAuctions.Count == 0 && !string.IsNullOrEmpty(searchQuery))
|
||||||
|
{
|
||||||
|
<div class="text-center py-5 animate-fade-in">
|
||||||
|
<i class="bi bi-search text-muted" style="font-size: 4rem;"></i>
|
||||||
|
<p class="text-muted mt-3">Nessuna asta trovata per "<strong>@searchQuery</strong>"</p>
|
||||||
|
<button class="btn btn-outline-secondary" @onclick="ClearSearch">
|
||||||
|
<i class="bi bi-x-circle me-2"></i>Cancella Ricerca
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (auctions.Count == 0)
|
||||||
|
{
|
||||||
|
<div class="text-center py-5 animate-fade-in">
|
||||||
|
<i class="bi bi-inbox text-muted" style="font-size: 4rem;"></i>
|
||||||
|
<p class="text-muted mt-3">Nessuna asta trovata in questa categoria</p>
|
||||||
|
<button class="btn btn-primary" @onclick="LoadAuctions">
|
||||||
|
<i class="bi bi-arrow-clockwise me-2"></i>Ricarica
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<!-- Auctions Grid -->
|
||||||
|
<div class="auction-grid animate-fade-in">
|
||||||
|
@foreach (var auction in filteredAuctions)
|
||||||
|
{
|
||||||
|
<div class="auction-card @(auction.IsMonitored ? "monitored" : "") @(auction.IsSold ? "sold" : "")">
|
||||||
|
<!-- Image -->
|
||||||
|
<div class="auction-image">
|
||||||
|
@if (!string.IsNullOrEmpty(auction.ImageUrl))
|
||||||
|
{
|
||||||
|
<img src="@auction.ImageUrl" alt="@auction.Name" loading="lazy" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="placeholder-image">
|
||||||
|
<i class="bi bi-image"></i>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Badges -->
|
||||||
|
<div class="auction-badges">
|
||||||
|
@if (auction.IsCreditAuction)
|
||||||
|
{
|
||||||
|
<span class="badge bg-warning text-dark">
|
||||||
|
<i class="bi bi-coin"></i> @auction.CreditValue
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
@if (auction.IsManualOnly)
|
||||||
|
{
|
||||||
|
<span class="badge bg-info">
|
||||||
|
<i class="bi bi-hand-index"></i> Manuale
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
@if (auction.IsTurbo)
|
||||||
|
{
|
||||||
|
<span class="badge bg-danger">
|
||||||
|
<i class="bi bi-lightning"></i> @auction.TimerFrequency s
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (auction.IsSold)
|
||||||
|
{
|
||||||
|
<div class="sold-overlay">
|
||||||
|
<span>VENDUTO</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (auction.IsMonitored)
|
||||||
|
{
|
||||||
|
<div class="monitored-badge">
|
||||||
|
<i class="bi bi-check-circle-fill"></i>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Info -->
|
||||||
|
<div class="auction-info">
|
||||||
|
<h6 class="auction-name" title="@auction.Name">@auction.Name</h6>
|
||||||
|
|
||||||
|
<div class="auction-price">
|
||||||
|
<span class="current-price">@auction.CurrentPrice.ToString("0.00") €</span>
|
||||||
|
@if (auction.BuyNowPrice > 0)
|
||||||
|
{
|
||||||
|
<span class="buynow-price text-muted">
|
||||||
|
<small>Compra: @auction.BuyNowPrice.ToString("0.00") €</small>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="auction-bidder">
|
||||||
|
<i class="bi bi-person-fill text-muted me-1"></i>
|
||||||
|
<span>@(string.IsNullOrEmpty(auction.LastBidder) ? "—" : auction.LastBidder)</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="auction-timer @(auction.RemainingSeconds <= 3 ? "urgent" : "")">
|
||||||
|
<i class="bi bi-clock me-1"></i>
|
||||||
|
@auction.TimerDisplay
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="auction-actions">
|
||||||
|
<div class="d-flex gap-1 mb-1">
|
||||||
|
<button class="btn btn-outline-secondary btn-sm flex-grow-1"
|
||||||
|
@onclick="() => CopyAuctionLink(auction)"
|
||||||
|
title="Copia link">
|
||||||
|
<i class="bi bi-clipboard"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm flex-grow-1"
|
||||||
|
@onclick="() => OpenAuctionInNewTab(auction)"
|
||||||
|
title="Apri in nuova scheda">
|
||||||
|
<i class="bi bi-box-arrow-up-right"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
@if (auction.IsMonitored)
|
||||||
|
{
|
||||||
|
<button class="btn btn-warning btn-sm w-100" @onclick="() => RemoveFromMonitor(auction)">
|
||||||
|
<i class="bi bi-dash-lg me-1"></i>Togli dal Monitor
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<button class="btn btn-primary btn-sm w-100" @onclick="() => AddToMonitor(auction)">
|
||||||
|
<i class="bi bi-plus-lg me-1"></i>Aggiungi al Monitor
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Load More -->
|
||||||
|
@if (canLoadMore)
|
||||||
|
{
|
||||||
|
<div class="text-center mt-4">
|
||||||
|
<button class="btn btn-outline-primary btn-lg" @onclick="LoadMoreAuctions" disabled="@isLoadingMore">
|
||||||
|
@if (isLoadingMore)
|
||||||
|
{
|
||||||
|
<span class="spinner-border spinner-border-sm me-2"></span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<i class="bi bi-plus-circle me-2"></i>
|
||||||
|
}
|
||||||
|
Carica Altre Aste
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private List<BidooCategoryInfo> categories = new();
|
||||||
|
private List<BidooBrowserAuction> auctions = new();
|
||||||
|
private List<BidooBrowserAuction> filteredAuctions = new();
|
||||||
|
|
||||||
|
// 🔥 Usa stato persistente da AppState
|
||||||
|
private int selectedCategoryIndex
|
||||||
|
{
|
||||||
|
get => AppState.BrowserCategoryIndex;
|
||||||
|
set => AppState.BrowserCategoryIndex = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int currentPage = 0;
|
||||||
|
|
||||||
|
private bool isLoading = false;
|
||||||
|
private bool isLoadingMore = false;
|
||||||
|
private bool canLoadMore = true;
|
||||||
|
private string? errorMessage = null;
|
||||||
|
|
||||||
|
// 🔥 Usa stato persistente per la ricerca
|
||||||
|
private string searchQuery
|
||||||
|
{
|
||||||
|
get => AppState.BrowserSearchQuery;
|
||||||
|
set => AppState.BrowserSearchQuery = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private System.Threading.Timer? stateUpdateTimer;
|
||||||
|
private CancellationTokenSource? cts;
|
||||||
|
private bool isUpdatingInBackground = false;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
await LoadCategories();
|
||||||
|
|
||||||
|
// 🔥 Se c'è una categoria salvata, carica le aste
|
||||||
|
if (categories.Count > 0)
|
||||||
|
{
|
||||||
|
// Se selectedCategoryIndex è valido, carica quella categoria
|
||||||
|
if (selectedCategoryIndex >= 0 && selectedCategoryIndex < categories.Count)
|
||||||
|
{
|
||||||
|
await LoadAuctions();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Altrimenti carica la prima categoria
|
||||||
|
selectedCategoryIndex = 0;
|
||||||
|
await LoadAuctions();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-update states every 500ms for real-time price updates
|
||||||
|
stateUpdateTimer = new System.Threading.Timer(async _ =>
|
||||||
|
{
|
||||||
|
if (auctions.Count > 0 && !isUpdatingInBackground)
|
||||||
|
{
|
||||||
|
await UpdateAuctionStatesBackground();
|
||||||
|
}
|
||||||
|
}, null, TimeSpan.FromMilliseconds(500), TimeSpan.FromMilliseconds(500));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadCategories()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
categories = await BrowserService.GetCategoriesAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[Browser] Error loading categories: {ex.Message}");
|
||||||
|
errorMessage = "Errore nel caricamento delle categorie";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnCategoryChanged()
|
||||||
|
{
|
||||||
|
currentPage = 0;
|
||||||
|
canLoadMore = true;
|
||||||
|
auctions.Clear();
|
||||||
|
await LoadAuctions();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadAuctions()
|
||||||
|
{
|
||||||
|
if (categories.Count == 0 || selectedCategoryIndex < 0 || selectedCategoryIndex >= categories.Count)
|
||||||
|
return;
|
||||||
|
|
||||||
|
isLoading = true;
|
||||||
|
errorMessage = null;
|
||||||
|
cts?.Cancel();
|
||||||
|
cts = new CancellationTokenSource();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var category = categories[selectedCategoryIndex];
|
||||||
|
var newAuctions = await BrowserService.GetAuctionsAsync(category, currentPage, cts.Token);
|
||||||
|
|
||||||
|
auctions = newAuctions;
|
||||||
|
canLoadMore = newAuctions.Count >= 20; // Assume pagination at 20
|
||||||
|
|
||||||
|
// Mark already monitored auctions
|
||||||
|
UpdateMonitoredStatus();
|
||||||
|
|
||||||
|
// Get initial states
|
||||||
|
if (auctions.Count > 0)
|
||||||
|
{
|
||||||
|
await BrowserService.UpdateAuctionStatesAsync(auctions, cts.Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ? NUOVO: Applica filtro ricerca
|
||||||
|
ApplySearchFilter();
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// Ignore cancellation
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[Browser] Error loading auctions: {ex.Message}");
|
||||||
|
errorMessage = "Errore nel caricamento delle aste";
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
isLoading = false;
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ? NUOVO: Metodo per applicare il filtro di ricerca
|
||||||
|
private void ApplySearchFilter()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(searchQuery))
|
||||||
|
{
|
||||||
|
filteredAuctions = auctions.ToList();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var query = searchQuery.ToLowerInvariant().Trim();
|
||||||
|
|
||||||
|
filteredAuctions = auctions.Where(a =>
|
||||||
|
// Cerca nel nome
|
||||||
|
a.Name.ToLowerInvariant().Contains(query) ||
|
||||||
|
// Cerca nel prezzo corrente
|
||||||
|
a.CurrentPrice.ToString("F2").Contains(query) ||
|
||||||
|
// Cerca nel prezzo buy-now
|
||||||
|
(a.BuyNowPrice > 0 && a.BuyNowPrice.ToString("F2").Contains(query)) ||
|
||||||
|
// Cerca nel nome dell'ultimo puntatore
|
||||||
|
(!string.IsNullOrEmpty(a.LastBidder) && a.LastBidder.ToLowerInvariant().Contains(query)) ||
|
||||||
|
// Cerca nell'ID asta
|
||||||
|
a.AuctionId.Contains(query)
|
||||||
|
).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ? NUOVO: Callback quando cambia la ricerca
|
||||||
|
private void OnSearchChanged()
|
||||||
|
{
|
||||||
|
ApplySearchFilter();
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ? NUOVO: Pulisce la ricerca
|
||||||
|
private void ClearSearch()
|
||||||
|
{
|
||||||
|
searchQuery = "";
|
||||||
|
ApplySearchFilter();
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadMoreAuctions()
|
||||||
|
{
|
||||||
|
if (categories.Count == 0 || selectedCategoryIndex < 0 || auctions.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
isLoadingMore = true;
|
||||||
|
cts?.Cancel();
|
||||||
|
cts = new CancellationTokenSource();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var category = categories[selectedCategoryIndex];
|
||||||
|
var existingIds = auctions.Select(a => a.AuctionId).ToList();
|
||||||
|
|
||||||
|
// Usa GetMoreAuctionsAsync che evita duplicati
|
||||||
|
var newAuctions = await BrowserService.GetMoreAuctionsAsync(category, existingIds, cts.Token);
|
||||||
|
|
||||||
|
if (newAuctions.Count == 0)
|
||||||
|
{
|
||||||
|
canLoadMore = false;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
auctions.AddRange(newAuctions);
|
||||||
|
UpdateMonitoredStatus();
|
||||||
|
|
||||||
|
// Aggiorna stati delle nuove aste
|
||||||
|
await BrowserService.UpdateAuctionStatesAsync(newAuctions, cts.Token);
|
||||||
|
|
||||||
|
// ? NUOVO: Riapplica filtro dopo caricamento
|
||||||
|
ApplySearchFilter();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[Browser] Error loading more auctions: {ex.Message}");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
isLoadingMore = false;
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task UpdateAuctionStatesBackground()
|
||||||
|
{
|
||||||
|
if (isUpdatingInBackground) return;
|
||||||
|
|
||||||
|
isUpdatingInBackground = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await BrowserService.UpdateAuctionStatesAsync(auctions);
|
||||||
|
UpdateMonitoredStatus();
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Ignore background errors
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
isUpdatingInBackground = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RefreshAll()
|
||||||
|
{
|
||||||
|
await LoadCategories();
|
||||||
|
currentPage = 0;
|
||||||
|
canLoadMore = true;
|
||||||
|
auctions.Clear();
|
||||||
|
await LoadAuctions();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ClearAllAuctions()
|
||||||
|
{
|
||||||
|
// Cancella le aste e ferma il timer
|
||||||
|
cts?.Cancel();
|
||||||
|
auctions.Clear();
|
||||||
|
filteredAuctions.Clear();
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateMonitoredStatus()
|
||||||
|
{
|
||||||
|
var monitoredIds = AppState.Auctions.Select(a => a.AuctionId).ToHashSet();
|
||||||
|
foreach (var auction in auctions)
|
||||||
|
{
|
||||||
|
auction.IsMonitored = monitoredIds.Contains(auction.AuctionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddToMonitor(BidooBrowserAuction browserAuction)
|
||||||
|
{
|
||||||
|
if (browserAuction.IsMonitored) return;
|
||||||
|
|
||||||
|
// 🔥 Carica impostazioni di default
|
||||||
|
var settings = AutoBidder.Utilities.SettingsManager.Load();
|
||||||
|
|
||||||
|
// 🔥 Determina stato iniziale da impostazioni
|
||||||
|
bool isActive = false;
|
||||||
|
bool isPaused = false;
|
||||||
|
|
||||||
|
switch (settings.DefaultNewAuctionState)
|
||||||
|
{
|
||||||
|
case "Active":
|
||||||
|
isActive = true;
|
||||||
|
isPaused = false;
|
||||||
|
break;
|
||||||
|
case "Paused":
|
||||||
|
isActive = true;
|
||||||
|
isPaused = true;
|
||||||
|
break;
|
||||||
|
case "Stopped":
|
||||||
|
default:
|
||||||
|
isActive = false;
|
||||||
|
isPaused = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
var auctionInfo = new AuctionInfo
|
||||||
|
{
|
||||||
|
AuctionId = browserAuction.AuctionId,
|
||||||
|
Name = browserAuction.Name,
|
||||||
|
OriginalUrl = browserAuction.Url,
|
||||||
|
BuyNowPrice = (double)browserAuction.BuyNowPrice,
|
||||||
|
|
||||||
|
// 🔥 Applica valori dalle impostazioni
|
||||||
|
BidBeforeDeadlineMs = settings.DefaultBidBeforeDeadlineMs,
|
||||||
|
CheckAuctionOpenBeforeBid = settings.DefaultCheckAuctionOpenBeforeBid,
|
||||||
|
MinPrice = settings.DefaultMinPrice,
|
||||||
|
MaxPrice = settings.DefaultMaxPrice,
|
||||||
|
MinResets = settings.DefaultMinResets,
|
||||||
|
MaxResets = settings.DefaultMaxResets,
|
||||||
|
|
||||||
|
// 🔥 Usa stato da impostazioni invece di hardcoded
|
||||||
|
IsActive = isActive,
|
||||||
|
IsPaused = isPaused,
|
||||||
|
AddedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
AppState.AddAuction(auctionInfo);
|
||||||
|
|
||||||
|
// ?? FIX CRITICO: Registra l'asta nel monitor!
|
||||||
|
AuctionMonitor.AddAuction(auctionInfo);
|
||||||
|
|
||||||
|
browserAuction.IsMonitored = true;
|
||||||
|
|
||||||
|
// Save to disk
|
||||||
|
AutoBidder.Utilities.PersistenceManager.SaveAuctions(AppState.Auctions.ToList());
|
||||||
|
|
||||||
|
// ?? FIX CRITICO: Avvia il monitor se non è già attivo
|
||||||
|
if (!AppState.IsMonitoringActive)
|
||||||
|
{
|
||||||
|
AuctionMonitor.Start();
|
||||||
|
AppState.IsMonitoringActive = true;
|
||||||
|
Console.WriteLine($"[AuctionBrowser] Monitor auto-started for paused auction: {auctionInfo.Name}");
|
||||||
|
}
|
||||||
|
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RemoveFromMonitor(BidooBrowserAuction browserAuction)
|
||||||
|
{
|
||||||
|
if (!browserAuction.IsMonitored) return;
|
||||||
|
|
||||||
|
// Trova l'asta nel monitor
|
||||||
|
var auctionToRemove = AppState.Auctions.FirstOrDefault(a => a.AuctionId == browserAuction.AuctionId);
|
||||||
|
if (auctionToRemove != null)
|
||||||
|
{
|
||||||
|
AppState.RemoveAuction(auctionToRemove);
|
||||||
|
browserAuction.IsMonitored = false;
|
||||||
|
|
||||||
|
// Save to disk
|
||||||
|
AutoBidder.Utilities.PersistenceManager.SaveAuctions(AppState.Auctions.ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CopyAuctionLink(BidooBrowserAuction auction)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await JSRuntime.InvokeVoidAsync("navigator.clipboard.writeText", auction.Url);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[Browser] Error copying link: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OpenAuctionInNewTab(BidooBrowserAuction auction)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await JSRuntime.InvokeVoidAsync("window.open", auction.Url, "_blank");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[Browser] Error opening link: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
stateUpdateTimer?.Dispose();
|
||||||
|
cts?.Cancel();
|
||||||
|
cts?.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
@page "/freebids"
|
|
||||||
|
|
||||||
<PageTitle>Puntate Gratuite - AutoBidder</PageTitle>
|
|
||||||
|
|
||||||
<div class="freebids-container animate-fade-in p-4">
|
|
||||||
<div class="d-flex align-items-center mb-4 animate-fade-in-down">
|
|
||||||
<i class="bi bi-gift-fill text-warning me-3" style="font-size: 2.5rem;"></i>
|
|
||||||
<h2 class="mb-0 fw-bold">Puntate Gratuite</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Feature Under Development Notice - Conciso -->
|
|
||||||
<div class="alert alert-info border-0 shadow-sm animate-scale-in">
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<i class="bi bi-tools me-3" style="font-size: 2rem;"></i>
|
|
||||||
<div class="flex-grow-1">
|
|
||||||
<h5 class="mb-2"><strong>Funzionalità in Sviluppo</strong></h5>
|
|
||||||
<p class="mb-0">
|
|
||||||
Sistema di rilevamento, raccolta e utilizzo automatico delle puntate gratuite di Bidoo.
|
|
||||||
<br />
|
|
||||||
<small class="text-muted">Disponibile in una prossima versione con statistiche dettagliate.</small>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.freebids-container {
|
|
||||||
max-width: 1200px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
@page "/health"
|
@page "/health"
|
||||||
|
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||||
@inject DatabaseService DatabaseService
|
@inject DatabaseService DatabaseService
|
||||||
@inject AuctionMonitor AuctionMonitor
|
@inject AuctionMonitor AuctionMonitor
|
||||||
|
|
||||||
|
|||||||
+338
-241
@@ -1,4 +1,5 @@
|
|||||||
@page "/"
|
@page "/"
|
||||||
|
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||||
@inject AuctionMonitor AuctionMonitor
|
@inject AuctionMonitor AuctionMonitor
|
||||||
@inject AuctionStateService AuctionStateService
|
@inject AuctionStateService AuctionStateService
|
||||||
@inject IJSRuntime JSRuntime
|
@inject IJSRuntime JSRuntime
|
||||||
@@ -6,142 +7,155 @@
|
|||||||
|
|
||||||
<PageTitle>Monitor Aste - AutoBidder</PageTitle>
|
<PageTitle>Monitor Aste - AutoBidder</PageTitle>
|
||||||
|
|
||||||
<div class="auction-monitor animate-fade-in">
|
<div class="auction-monitor-container">
|
||||||
<!-- Toolbar Superiore -->
|
<!-- Toolbar Compatta -->
|
||||||
<div class="toolbar animate-fade-in-down">
|
<div class="toolbar-compact">
|
||||||
<!-- Box Sessione Utente - Compatto in linea -->
|
<!-- Pulsanti Azioni Massiva (senza conteggi) -->
|
||||||
<div class="toolbar-user-info">
|
<div class="btn-group-actions">
|
||||||
@if (!string.IsNullOrEmpty(sessionUsername))
|
<button class="action-btn success" @onclick="StartAll" title="Avvia tutte le aste">
|
||||||
{
|
<i class="bi bi-play-fill"></i>
|
||||||
<div class="user-card connected">
|
</button>
|
||||||
<i class="bi bi-person-circle user-icon"></i>
|
<button class="action-btn warning" @onclick="PauseAll" title="Metti in pausa tutte le aste">
|
||||||
<span class="user-name">@sessionUsername</span>
|
<i class="bi bi-pause-fill"></i>
|
||||||
<div class="divider"></div>
|
</button>
|
||||||
<div class="stat-compact">
|
<button class="action-btn secondary" @onclick="StopAll" title="Ferma tutte le aste">
|
||||||
<i class="bi bi-hand-index-thumb-fill"></i>
|
<i class="bi bi-stop-fill"></i>
|
||||||
<span class="stat-value @GetBidsClass()">@sessionRemainingBids</span>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="divider"></div>
|
|
||||||
<div class="stat-compact">
|
<!-- Indicatori Stato Aste (tutti gli stati) -->
|
||||||
<i class="bi bi-wallet2"></i>
|
<div class="status-indicators">
|
||||||
<span class="stat-value text-success">€@sessionShopCredit.ToString("F2")</span>
|
<div class="status-pill total" title="Totale aste">
|
||||||
|
<i class="bi bi-collection"></i>
|
||||||
|
<span>@(auctions?.Count ?? 0)</span>
|
||||||
</div>
|
</div>
|
||||||
@if (sessionAuctionsWon > 0)
|
<div class="status-pill active" title="Aste attive">
|
||||||
{
|
<i class="bi bi-play-circle-fill"></i>
|
||||||
<div class="divider"></div>
|
<span>@GetActiveAuctionsCount()</span>
|
||||||
<div class="stat-compact">
|
</div>
|
||||||
|
<div class="status-pill paused" title="Aste in pausa">
|
||||||
|
<i class="bi bi-pause-circle-fill"></i>
|
||||||
|
<span>@GetPausedAuctionsCount()</span>
|
||||||
|
</div>
|
||||||
|
<div class="status-pill stopped" title="Aste fermate">
|
||||||
|
<i class="bi bi-stop-circle-fill"></i>
|
||||||
|
<span>@GetStoppedAuctionsCount()</span>
|
||||||
|
</div>
|
||||||
|
<div class="status-pill won" title="Aste vinte">
|
||||||
<i class="bi bi-trophy-fill"></i>
|
<i class="bi bi-trophy-fill"></i>
|
||||||
<span class="stat-value text-warning">@sessionAuctionsWon</span>
|
<span>@GetWonAuctionsCount()</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
<div class="status-pill lost" title="Aste perse">
|
||||||
|
<i class="bi bi-x-circle-fill"></i>
|
||||||
|
<span>@GetLostAuctionsCount()</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<div class="user-card disconnected">
|
|
||||||
<i class="bi bi-person-x user-icon"></i>
|
|
||||||
<span class="user-name text-muted">Non connesso</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Pulsanti Azioni (Centro-Destra) -->
|
<!-- Pulsanti Gestione -->
|
||||||
<div class="toolbar-actions">
|
<div class="btn-group-manage">
|
||||||
<button class="btn btn-success hover-lift" @onclick="StartAll" disabled="@isMonitoringActive">
|
<button class="manage-btn primary" @onclick="ShowAddAuctionDialog" title="Aggiungi nuova asta">
|
||||||
<i class="bi bi-play-fill"></i> Avvia Tutto
|
<i class="bi bi-plus-lg"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-warning hover-lift" @onclick="PauseAll" disabled="@(!isMonitoringActive)">
|
<button class="manage-btn danger" @onclick="RemoveSelectedAuction" disabled="@(selectedAuction == null)" title="Rimuovi selezionata">
|
||||||
<i class="bi bi-pause-fill"></i> Pausa Tutto
|
<i class="bi bi-trash"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-danger hover-lift" @onclick="StopAll" disabled="@(!isMonitoringActive)">
|
<div class="manage-separator"></div>
|
||||||
<i class="bi bi-stop-fill"></i> Ferma Tutto
|
<button class="manage-btn outline-success" @onclick="RemoveActiveAuctions" disabled="@(GetActiveAuctionsCount() == 0)" title="Rimuovi attive">
|
||||||
|
<i class="bi bi-play-circle"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-primary ms-3 hover-lift" @onclick="ShowAddAuctionDialog">
|
<button class="manage-btn outline-warning" @onclick="RemovePausedAuctions" disabled="@(GetPausedAuctionsCount() == 0)" title="Rimuovi in pausa">
|
||||||
<i class="bi bi-plus-lg"></i> Aggiungi Asta
|
<i class="bi bi-pause-circle"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-secondary hover-lift" @onclick="RemoveSelectedAuction" disabled="@(selectedAuction == null)">
|
<button class="manage-btn outline-secondary" @onclick="RemoveStoppedAuctions" disabled="@(GetStoppedAuctionsCount() == 0)" title="Rimuovi fermate">
|
||||||
<i class="bi bi-trash"></i> Rimuovi
|
<i class="bi bi-stop-circle"></i>
|
||||||
|
</button>
|
||||||
|
<button class="manage-btn outline-gold" @onclick="RemoveWonAuctions" disabled="@(GetWonAuctionsCount() == 0)" title="Rimuovi vinte">
|
||||||
|
<i class="bi bi-trophy"></i>
|
||||||
|
</button>
|
||||||
|
<button class="manage-btn outline-danger" @onclick="RemoveLostAuctions" disabled="@(GetLostAuctionsCount() == 0)" title="Rimuovi perse">
|
||||||
|
<i class="bi bi-x-circle"></i>
|
||||||
|
</button>
|
||||||
|
<div class="manage-separator"></div>
|
||||||
|
<button class="manage-btn danger-fill" @onclick="RemoveAllAuctions" disabled="@((auctions?.Count ?? 0) == 0)" title="Rimuovi TUTTE">
|
||||||
|
<i class="bi bi-trash-fill"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="content-layout">
|
<!-- Area Principale con Layout a Griglia -->
|
||||||
<!-- GRIGLIA ASTE - PARTE SUPERIORE SINISTRA -->
|
<div class="main-content-area">
|
||||||
<div class="auctions-grid-section animate-fade-in-left delay-100 shadow-hover">
|
<!-- Riga Superiore: Aste + Log -->
|
||||||
<h3><i class="bi bi-list-check"></i> Aste Monitorate (@auctions.Count)</h3>
|
<div class="top-row" id="topRow">
|
||||||
@if (auctions.Count == 0)
|
<!-- Pannello Aste -->
|
||||||
|
<div class="panel panel-auctions" id="panelAuctions">
|
||||||
|
<div class="panel-header">
|
||||||
|
<span><i class="bi bi-list-check"></i> Aste Monitorate</span>
|
||||||
|
</div>
|
||||||
|
@if ((auctions?.Count ?? 0) == 0)
|
||||||
{
|
{
|
||||||
<div class="alert alert-info animate-fade-in-up">
|
<div class="alert alert-info animate-fade-in-up m-2">
|
||||||
<i class="bi bi-info-circle"></i> Nessuna asta monitorata. Clicca su "Aggiungi Asta" per iniziare.
|
<i class="bi bi-info-circle"></i> Nessuna asta monitorata. Clicca su <i class="bi bi-plus-lg"></i> per iniziare.
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<div class="table-responsive">
|
<div class="table-responsive panel-content">
|
||||||
<table class="table table-striped table-hover mb-0 table-fixed">
|
<table class="table table-striped table-hover mb-0 table-fixed table-compact">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="col-stato"><i class="bi bi-toggle-on"></i> Stato</th>
|
<th class="col-stato sortable-header" @onclick='() => SortAuctionsBy("stato")'>Stato @GetSortIndicator("stato")</th>
|
||||||
<th class="col-nome"><i class="bi bi-tag"></i> Nome</th>
|
<th class="col-nome sortable-header" @onclick='() => SortAuctionsBy("nome")'>Nome @GetSortIndicator("nome")</th>
|
||||||
<th class="col-prezzo"><i class="bi bi-currency-euro"></i> Prezzo</th>
|
<th class="col-prezzo sortable-header" @onclick='() => SortAuctionsBy("prezzo")'>€ @GetSortIndicator("prezzo")</th>
|
||||||
<th class="col-timer"><i class="bi bi-clock"></i> Timer</th>
|
<th class="col-timer sortable-header" @onclick='() => SortAuctionsBy("timer")'>Timer @GetSortIndicator("timer")</th>
|
||||||
<th class="col-ultimo"><i class="bi bi-person"></i> Ultimo</th>
|
<th class="col-ultimo">Ultimo</th>
|
||||||
<th class="col-click"><i class="bi bi-hand-index"></i> Click</th>
|
<th class="col-click sortable-header" @onclick='() => SortAuctionsBy("puntate")'>Punt. @GetSortIndicator("puntate")</th>
|
||||||
<th class="col-ping"><i class="bi bi-speedometer"></i> Ping</th>
|
<th class="col-ping sortable-header" @onclick='() => SortAuctionsBy("ping")'>Ping @GetSortIndicator("ping")</th>
|
||||||
<th class="col-azioni"><i class="bi bi-gear"></i> Azioni</th>
|
<th class="col-azioni">Azioni</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@foreach (var auction in auctions)
|
@foreach (var auction in GetSortedAuctions())
|
||||||
{
|
{
|
||||||
<tr class="@GetRowClass(auction) table-row-enter transition-all"
|
<tr class="@GetRowClass(auction) @(selectedAuction == auction ? "selected-row" : "")"
|
||||||
@onclick="() => SelectAuction(auction)"
|
@onclick="() => SelectAuction(auction)">
|
||||||
style="cursor: pointer;">
|
|
||||||
<td class="col-stato">
|
<td class="col-stato">
|
||||||
<span class="badge @GetStatusBadgeClass(auction) @GetStatusAnimationClass(auction)">
|
<span class="badge @GetStatusBadgeClass(auction) @GetStatusAnimationClass(auction)">
|
||||||
@((MarkupString)GetStatusIcon(auction)) @GetStatusText(auction)
|
@((MarkupString)GetStatusIcon(auction)) @GetStatusText(auction)
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="col-nome fw-semibold">@auction.Name</td>
|
<td class="col-nome">@auction.Name</td>
|
||||||
<td class="col-prezzo @GetPriceClass(auction)">@GetPriceDisplay(auction)</td>
|
<td class="col-prezzo @GetPriceClass(auction)">@GetPriceDisplay(auction)</td>
|
||||||
<td class="col-timer">@GetTimerDisplay(auction)</td>
|
<td class="col-timer">@GetTimerDisplay(auction)</td>
|
||||||
<td class="col-ultimo">@GetLastBidder(auction)</td>
|
<td class="col-ultimo">@GetLastBidder(auction)</td>
|
||||||
<td class="col-click"><span class="badge bg-info">@GetMyBidsCount(auction)</span></td>
|
<td class="col-click bids-column">@GetMyBidsCount(auction)</td>
|
||||||
<td class="col-ping">@GetPingDisplay(auction)</td>
|
<td class="col-ping @GetPingClass(auction)">@GetPingDisplay(auction)</td>
|
||||||
<td class="col-azioni">
|
<td class="col-azioni">
|
||||||
<div class="btn-group btn-group-sm" @onclick:stopPropagation="true">
|
<div class="btn-group btn-group-sm" @onclick:stopPropagation="true">
|
||||||
<button class="btn btn-primary hover-scale"
|
<button class="btn btn-xs btn-primary"
|
||||||
@onclick="() => ManualBidAuction(auction)"
|
@onclick="() => ManualBidAuction(auction)"
|
||||||
title="Punta Manualmente"
|
title="Punta"
|
||||||
disabled="@IsManualBidding(auction)">
|
disabled="@IsManualBidding(auction)">
|
||||||
@if (IsManualBidding(auction))
|
|
||||||
{
|
|
||||||
<span class="spinner-border spinner-border-sm" role="status"></span>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<i class="bi bi-hand-index-thumb"></i>
|
<i class="bi bi-hand-index-thumb"></i>
|
||||||
}
|
|
||||||
</button>
|
</button>
|
||||||
@if (auction.IsActive && !auction.IsPaused)
|
@if (auction.IsActive && !auction.IsPaused)
|
||||||
{
|
{
|
||||||
<button class="btn btn-warning hover-scale" @onclick="() => PauseAuction(auction)" title="Pausa">
|
<button class="btn btn-xs btn-warning" @onclick="() => PauseAuction(auction)" title="Pausa">
|
||||||
<i class="bi bi-pause-fill"></i>
|
<i class="bi bi-pause-fill"></i>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
else if (auction.IsPaused)
|
else if (auction.IsPaused)
|
||||||
{
|
{
|
||||||
<button class="btn btn-success hover-scale" @onclick="() => ResumeAuction(auction)" title="Riprendi">
|
<button class="btn btn-xs btn-success" @onclick="() => ResumeAuction(auction)" title="Riprendi">
|
||||||
<i class="bi bi-play-fill"></i>
|
<i class="bi bi-play-fill"></i>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<button class="btn btn-success hover-scale" @onclick="() => StartAuction(auction)" title="Avvia">
|
<button class="btn btn-xs btn-success" @onclick="() => StartAuction(auction)" title="Avvia">
|
||||||
<i class="bi bi-play-fill"></i>
|
<i class="bi bi-play-fill"></i>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
<button class="btn btn-danger hover-scale" @onclick="() => StopAuction(auction)" title="Ferma">
|
<button class="btn btn-xs btn-danger" @onclick="() => StopAuction(auction)" title="Ferma">
|
||||||
<i class="bi bi-stop-fill"></i>
|
<i class="bi bi-stop-fill"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -154,18 +168,18 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- SPLITTER VERTICALE -->
|
<!-- Splitter Verticale -->
|
||||||
<div class="splitter-vertical"></div>
|
<div class="gutter gutter-vertical" id="gutterVertical"></div>
|
||||||
|
|
||||||
<!-- LOG GLOBALE - PARTE SUPERIORE DESTRA -->
|
<!-- Pannello Log -->
|
||||||
<div class="global-log animate-fade-in-right delay-200">
|
<div class="panel panel-log" id="panelLog">
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
<div class="panel-header">
|
||||||
<h4 class="mb-0"><i class="bi bi-terminal"></i> Log Globale</h4>
|
<span><i class="bi bi-terminal"></i> Log</span>
|
||||||
<button class="btn btn-sm btn-secondary" @onclick="ClearGlobalLog">
|
<button class="btn btn-xs btn-secondary" @onclick="ClearGlobalLog">
|
||||||
<i class="bi bi-trash"></i>
|
<i class="bi bi-trash"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="log-box">
|
<div class="panel-content log-box" id="globalLogContainer" @ref="globalLogRef">
|
||||||
@if (globalLog.Count == 0)
|
@if (globalLog.Count == 0)
|
||||||
{
|
{
|
||||||
<div class="text-muted"><i class="bi bi-inbox"></i> Nessun log ancora...</div>
|
<div class="text-muted"><i class="bi bi-inbox"></i> Nessun log ancora...</div>
|
||||||
@@ -181,14 +195,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- SPLITTER ORIZZONTALE -->
|
<!-- Splitter Orizzontale -->
|
||||||
<div class="splitter-horizontal"></div>
|
<div class="gutter gutter-horizontal" id="gutterHorizontal"></div>
|
||||||
|
|
||||||
<!-- DETTAGLI ASTA CON TABS - PARTE INFERIORE (full width) -->
|
<!-- Riga Inferiore: Dettagli Asta -->
|
||||||
|
<div class="bottom-row" id="bottomRow">
|
||||||
|
<div class="panel panel-details" id="panelDetails">
|
||||||
@if (selectedAuction != null)
|
@if (selectedAuction != null)
|
||||||
{
|
{
|
||||||
<div class="auction-details-tabs animate-fade-in-up delay-300 shadow-hover">
|
<div class="auction-details-content">
|
||||||
<h3><i class="bi bi-info-circle-fill"></i> @selectedAuction.Name <small class="text-muted">(ID: @selectedAuction.AuctionId)</small></h3>
|
<div class="details-header">
|
||||||
|
<i class="bi bi-info-circle-fill"></i> @selectedAuction.Name
|
||||||
|
<small class="text-muted">(ID: @selectedAuction.AuctionId)</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ul class="nav nav-tabs" role="tablist">
|
<ul class="nav nav-tabs" role="tablist">
|
||||||
<li class="nav-item" role="presentation">
|
<li class="nav-item" role="presentation">
|
||||||
@@ -224,52 +243,58 @@
|
|||||||
<div class="tab-panel-content">
|
<div class="tab-panel-content">
|
||||||
<div class="info-group">
|
<div class="info-group">
|
||||||
<label><i class="bi bi-link-45deg"></i> URL:</label>
|
<label><i class="bi bi-link-45deg"></i> URL:</label>
|
||||||
<div class="input-group">
|
<div class="input-group input-group-sm">
|
||||||
<input type="text" class="form-control" value="@selectedAuction.OriginalUrl" readonly />
|
<input type="text" class="form-control form-control-sm" value="@selectedAuction.OriginalUrl" readonly />
|
||||||
<button class="btn btn-outline-secondary" @onclick="() => CopyToClipboard(selectedAuction.OriginalUrl)" title="Copia">
|
<button class="btn btn-outline-secondary btn-sm" @onclick="() => CopyToClipboard(selectedAuction.OriginalUrl)" title="Copia">
|
||||||
<i class="bi bi-clipboard"></i>
|
<i class="bi bi-clipboard"></i>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="btn btn-outline-primary btn-sm" @onclick="() => OpenAuctionInNewTab(selectedAuction.OriginalUrl)" title="Apri">
|
||||||
|
<i class="bi bi-box-arrow-up-right"></i>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
<div class="settings-grid-compact">
|
||||||
<div class="col-md-6 info-group">
|
<div class="setting-item">
|
||||||
<label><i class="bi bi-speedometer2"></i> Anticipo (ms):</label>
|
<label><i class="bi bi-speedometer2"></i> Anticipo (ms)</label>
|
||||||
<input type="number" class="form-control" @bind="selectedAuction.BidBeforeDeadlineMs" @bind:after="SaveAuctions" />
|
<input type="number" class="form-control form-control-sm input-narrow" @bind="selectedAuction.BidBeforeDeadlineMs" @bind:after="SaveAuctions" />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6 info-group">
|
<div class="setting-item">
|
||||||
<label><i class="bi bi-hand-index-thumb"></i> Max Click:</label>
|
<label><i class="bi bi-currency-euro"></i> Min €</label>
|
||||||
<input type="number" class="form-control" @bind="selectedAuction.MaxClicks" @bind:after="SaveAuctions" />
|
<input type="number" step="0.01" class="form-control form-control-sm input-narrow" @bind="selectedAuction.MinPrice" @bind:after="SaveAuctions" />
|
||||||
|
</div>
|
||||||
|
<div class="setting-item">
|
||||||
|
<label><i class="bi bi-currency-euro"></i> Max €</label>
|
||||||
|
<input type="number" step="0.01" class="form-control form-control-sm input-narrow" @bind="selectedAuction.MaxPrice" @bind:after="SaveAuctions" />
|
||||||
|
</div>
|
||||||
|
<div class="setting-item">
|
||||||
|
<label><i class="bi bi-hand-index-thumb"></i> Max Puntate</label>
|
||||||
|
<input type="number" class="form-control form-control-sm input-narrow" @bind="selectedAuction.MaxBidsOverride" @bind:after="SaveAuctions" placeholder="0=?" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
<div class="mt-2 pt-2 border-top">
|
||||||
<div class="col-md-6 info-group">
|
<button class="btn btn-outline-primary btn-sm w-100"
|
||||||
<label><i class="bi bi-currency-euro"></i> Min €:</label>
|
@onclick="ApplyRecommendedLimitsToSelected"
|
||||||
<input type="number" step="0.01" class="form-control" @bind="selectedAuction.MinPrice" @bind:after="SaveAuctions" />
|
disabled="@isLoadingRecommendations">
|
||||||
|
@if (isLoadingRecommendations)
|
||||||
|
{
|
||||||
|
<span class="spinner-border spinner-border-sm me-1"></span>
|
||||||
|
<span>Caricamento...</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<i class="bi bi-magic me-1"></i>
|
||||||
|
<span>Applica Limiti Consigliati</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
@if (!string.IsNullOrEmpty(recommendationMessage))
|
||||||
|
{
|
||||||
|
<div class="alert @(recommendationSuccess ? "alert-success" : "alert-warning") mt-2 mb-0 py-1 small">
|
||||||
|
<i class="bi @(recommendationSuccess ? "bi-check-circle" : "bi-exclamation-triangle") me-1"></i>
|
||||||
|
@recommendationMessage
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6 info-group">
|
}
|
||||||
<label><i class="bi bi-currency-euro"></i> Max €:</label>
|
|
||||||
<input type="number" step="0.01" class="form-control" @bind="selectedAuction.MaxPrice" @bind:after="SaveAuctions" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-6 info-group">
|
|
||||||
<label><i class="bi bi-arrow-repeat"></i> Min Reset:</label>
|
|
||||||
<input type="number" class="form-control" @bind="selectedAuction.MinResets" @bind:after="SaveAuctions" />
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6 info-group">
|
|
||||||
<label><i class="bi bi-arrow-repeat"></i> Max Reset:</label>
|
|
||||||
<input type="number" class="form-control" @bind="selectedAuction.MaxResets" @bind:after="SaveAuctions" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-check mt-2">
|
|
||||||
<input type="checkbox" class="form-check-input" id="checkOpen" @bind="selectedAuction.CheckAuctionOpenBeforeBid" @bind:after="SaveAuctions" />
|
|
||||||
<label class="form-check-label" for="checkOpen">
|
|
||||||
Verifica asta aperta prima di puntare
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -279,7 +304,6 @@
|
|||||||
<div class="tab-panel-content">
|
<div class="tab-panel-content">
|
||||||
@if (selectedAuction.CalculatedValue != null)
|
@if (selectedAuction.CalculatedValue != null)
|
||||||
{
|
{
|
||||||
<!-- Sezione Principale - Compatta -->
|
|
||||||
<div class="product-info-compact">
|
<div class="product-info-compact">
|
||||||
<div class="info-cards">
|
<div class="info-cards">
|
||||||
<div class="info-card primary">
|
<div class="info-card primary">
|
||||||
@@ -289,7 +313,6 @@
|
|||||||
<strong>@GetBuyNowPriceDisplay(selectedAuction)</strong>
|
<strong>@GetBuyNowPriceDisplay(selectedAuction)</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="info-card info">
|
<div class="info-card info">
|
||||||
<i class="bi bi-truck"></i>
|
<i class="bi bi-truck"></i>
|
||||||
<div>
|
<div>
|
||||||
@@ -298,41 +321,33 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Calcoli in linea -->
|
|
||||||
<div class="calc-inline">
|
<div class="calc-inline">
|
||||||
<div class="calc-item">
|
<div class="calc-item">
|
||||||
<i class="bi bi-currency-euro"></i>
|
<i class="bi bi-currency-euro"></i>
|
||||||
<span class="label">Prezzo attuale</span>
|
<span class="label">Prezzo attuale</span>
|
||||||
<span class="value">€@selectedAuction.CalculatedValue.CurrentPrice.ToString("F2")</span>
|
<span class="value">€@selectedAuction.CalculatedValue.CurrentPrice.ToString("F2")</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="calc-item">
|
<div class="calc-item">
|
||||||
<i class="bi bi-hand-index"></i>
|
<i class="bi bi-hand-index"></i>
|
||||||
<span class="label">Totale puntate</span>
|
<span class="label">Totale puntate</span>
|
||||||
<span class="value">@selectedAuction.CalculatedValue.TotalBids</span>
|
<span class="value">@selectedAuction.CalculatedValue.TotalBids</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="calc-item highlight">
|
<div class="calc-item highlight">
|
||||||
<i class="bi bi-person-check-fill"></i>
|
<i class="bi bi-person-check-fill"></i>
|
||||||
<span class="label">Tue puntate</span>
|
<span class="label">Tue puntate</span>
|
||||||
<span class="value">@selectedAuction.CalculatedValue.MyBids</span>
|
<span class="value">@selectedAuction.CalculatedValue.MyBids</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="calc-item">
|
<div class="calc-item">
|
||||||
<i class="bi bi-cash-coin"></i>
|
<i class="bi bi-cash-coin"></i>
|
||||||
<span class="label">Costo puntate</span>
|
<span class="label">Costo puntate</span>
|
||||||
<span class="value">€@selectedAuction.CalculatedValue.MyBidsCost.ToString("F2")</span>
|
<span class="value">€@selectedAuction.CalculatedValue.MyBidsCost.ToString("F2")</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Totali compatti -->
|
|
||||||
<div class="totals-compact">
|
<div class="totals-compact">
|
||||||
<div class="total-item warning">
|
<div class="total-item warning">
|
||||||
<span>Costo Totale se vinci</span>
|
<span>Costo Totale se vinci</span>
|
||||||
<strong>€@selectedAuction.CalculatedValue.TotalCostIfWin.ToString("F2")</strong>
|
<strong>€@selectedAuction.CalculatedValue.TotalCostIfWin.ToString("F2")</strong>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="total-item @(selectedAuction.CalculatedValue.Savings > 0 ? "success" : "danger")">
|
<div class="total-item @(selectedAuction.CalculatedValue.Savings > 0 ? "success" : "danger")">
|
||||||
<span>
|
<span>
|
||||||
<i class="bi bi-@(selectedAuction.CalculatedValue.Savings > 0 ? "arrow-down-circle-fill" : "arrow-up-circle-fill")"></i>
|
<i class="bi bi-@(selectedAuction.CalculatedValue.Savings > 0 ? "arrow-down-circle-fill" : "arrow-up-circle-fill")"></i>
|
||||||
@@ -340,25 +355,17 @@
|
|||||||
</span>
|
</span>
|
||||||
<strong>@GetSavingsDisplay(selectedAuction)</strong>
|
<strong>@GetSavingsDisplay(selectedAuction)</strong>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="verdict-badge @(selectedAuction.CalculatedValue.Savings > 0 ? "success" : "danger")">
|
<div class="verdict-badge @(selectedAuction.CalculatedValue.Savings > 0 ? "success" : "danger")">
|
||||||
<i class="bi bi-@(selectedAuction.CalculatedValue.Savings > 0 ? "check-circle-fill" : "x-circle-fill")"></i>
|
<i class="bi bi-@(selectedAuction.CalculatedValue.Savings > 0 ? "check-circle-fill" : "x-circle-fill")"></i>
|
||||||
@(selectedAuction.CalculatedValue.Savings > 0 ? "Conveniente!" : "Non conveniente")
|
@(selectedAuction.CalculatedValue.Savings > 0 ? "Conveniente!" : "Non conveniente")
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (!string.IsNullOrEmpty(selectedAuction.CalculatedValue.Summary))
|
|
||||||
{
|
|
||||||
<div class="alert alert-info mt-3 mb-0">
|
|
||||||
<i class="bi bi-info-circle"></i> @selectedAuction.CalculatedValue.Summary
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<div class="alert alert-secondary">
|
<div class="alert alert-secondary">
|
||||||
<i class="bi bi-hourglass-split"></i> Informazioni prodotto non ancora disponibili. Verranno caricate automaticamente.
|
<i class="bi bi-hourglass-split"></i> Informazioni prodotto non ancora disponibili.
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -367,29 +374,32 @@
|
|||||||
<!-- TAB STORIA PUNTATE -->
|
<!-- TAB STORIA PUNTATE -->
|
||||||
<div class="tab-pane fade" id="content-history" role="tabpanel">
|
<div class="tab-pane fade" id="content-history" role="tabpanel">
|
||||||
<div class="tab-panel-content">
|
<div class="tab-panel-content">
|
||||||
@if (selectedAuction.RecentBids != null && selectedAuction.RecentBids.Any())
|
@{
|
||||||
|
var recentBidsList = GetRecentBidsSafe(selectedAuction);
|
||||||
|
var filteredBids = new List<BidHistoryEntry>();
|
||||||
|
BidHistoryEntry? lastBid = null;
|
||||||
|
foreach (var bid in recentBidsList)
|
||||||
|
{
|
||||||
|
if (lastBid != null &&
|
||||||
|
Math.Abs(bid.Price - lastBid.Price) < 0.001m &&
|
||||||
|
bid.Username.Equals(lastBid.Username, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
filteredBids.Add(bid);
|
||||||
|
lastBid = bid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@if (filteredBids.Any())
|
||||||
{
|
{
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-sm table-striped">
|
<table class="table table-sm table-striped">
|
||||||
<thead>
|
<thead><tr><th>Utente</th><th>Prezzo</th><th>Data/Ora</th><th>Tipo</th></tr></thead>
|
||||||
<tr>
|
|
||||||
<th>Utente</th>
|
|
||||||
<th>Prezzo</th>
|
|
||||||
<th>Data/Ora</th>
|
|
||||||
<th>Tipo</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
<tbody>
|
||||||
@foreach (var bid in selectedAuction.RecentBids.Take(50))
|
@foreach (var bid in filteredBids.Take(50))
|
||||||
{
|
{
|
||||||
<tr class="@(bid.IsMyBid ? "table-success" : "")">
|
<tr class="@(bid.IsMyBid ? "my-bid-row" : "")">
|
||||||
<td>
|
<td>@if (bid.IsMyBid){<strong class="text-success">@bid.Username</strong><span class="badge bg-success ms-1">TU</span>}else{@bid.Username}</td>
|
||||||
@bid.Username
|
|
||||||
@if (bid.IsMyBid)
|
|
||||||
{
|
|
||||||
<span class="badge bg-success ms-1">TU</span>
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
<td class="fw-bold">€@bid.PriceFormatted</td>
|
<td class="fw-bold">€@bid.PriceFormatted</td>
|
||||||
<td class="text-muted small">@bid.TimeFormatted</td>
|
<td class="text-muted small">@bid.TimeFormatted</td>
|
||||||
<td><span class="badge bg-secondary">@bid.BidType</span></td>
|
<td><span class="badge bg-secondary">@bid.BidType</span></td>
|
||||||
@@ -401,9 +411,7 @@
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<div class="alert alert-secondary">
|
<div class="alert alert-secondary"><i class="bi bi-inbox"></i> Nessuna puntata registrata.</div>
|
||||||
<i class="bi bi-inbox"></i> Nessuna puntata registrata per questa asta.
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -411,52 +419,29 @@
|
|||||||
<!-- TAB PUNTATORI -->
|
<!-- TAB PUNTATORI -->
|
||||||
<div class="tab-pane fade" id="content-bidders" role="tabpanel">
|
<div class="tab-pane fade" id="content-bidders" role="tabpanel">
|
||||||
<div class="tab-panel-content">
|
<div class="tab-panel-content">
|
||||||
@if (selectedAuction.RecentBids != null && selectedAuction.RecentBids.Any())
|
@{
|
||||||
|
var bidderStatsCopy = selectedAuction.BidderStats.Values.OrderByDescending(b => b.BidCount).ToList();
|
||||||
|
var myOfficialBidsCount = selectedAuction.BidsUsedOnThisAuction ?? 0;
|
||||||
|
var currentUsername = GetCurrentUsername();
|
||||||
|
}
|
||||||
|
@if (bidderStatsCopy.Any())
|
||||||
{
|
{
|
||||||
// Crea una copia locale per evitare modifiche durante l'enumerazione
|
var totalBidsCumulative = bidderStatsCopy.Sum(b => b.BidCount);
|
||||||
var recentBidsCopy = selectedAuction.RecentBids.ToList();
|
|
||||||
|
|
||||||
var bidderStats = recentBidsCopy
|
|
||||||
.GroupBy(b => b.Username)
|
|
||||||
.Select(g => new { Username = g.Key, Count = g.Count(), IsMe = g.First().IsMyBid })
|
|
||||||
.OrderByDescending(s => s.Count)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
|
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-sm table-striped">
|
<table class="table table-sm table-striped">
|
||||||
<thead>
|
<thead><tr><th>#</th><th>Utente</th><th>Puntate</th><th>%</th></tr></thead>
|
||||||
<tr>
|
|
||||||
<th>Posizione</th>
|
|
||||||
<th>Utente</th>
|
|
||||||
<th>Puntate</th>
|
|
||||||
<th>Percentuale</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
<tbody>
|
||||||
@for (int i = 0; i < bidderStats.Count; i++)
|
@for (int i = 0; i < bidderStatsCopy.Count; i++)
|
||||||
{
|
{
|
||||||
var bidder = bidderStats[i];
|
var bidder = bidderStatsCopy[i];
|
||||||
var percentage = (bidder.Count * 100.0 / recentBidsCopy.Count);
|
var isMe = bidder.Username.Equals(currentUsername, StringComparison.OrdinalIgnoreCase);
|
||||||
<tr class="@(bidder.IsMe ? "table-success" : "")">
|
var displayCount = isMe && myOfficialBidsCount > bidder.BidCount ? myOfficialBidsCount : bidder.BidCount;
|
||||||
<td><span class="badge bg-primary">#{i + 1}</span></td>
|
var percentage = totalBidsCumulative > 0 ? (displayCount * 100.0 / totalBidsCumulative) : 0;
|
||||||
<td>
|
<tr class="@(isMe ? "my-bid-row" : "")">
|
||||||
@bidder.Username
|
<td><span class="badge bg-primary">#@(i + 1)</span></td>
|
||||||
@if (bidder.IsMe)
|
<td>@if (isMe){<strong class="text-success">@bidder.Username</strong>}else{@bidder.Username}</td>
|
||||||
{
|
<td class="fw-bold">@displayCount</td>
|
||||||
<span class="badge bg-success ms-1">TU</span>
|
<td><div class="progress" style="height:16px;"><div class="progress-bar @(isMe?"bg-success":"bg-primary")" style="width:@percentage.ToString("F0")%">@percentage.ToString("F0")%</div></div></td>
|
||||||
}
|
|
||||||
</td>
|
|
||||||
<td class="fw-bold">@bidder.Count</td>
|
|
||||||
<td>
|
|
||||||
<div class="progress" style="height: 20px;">
|
|
||||||
<div class="progress-bar @(bidder.IsMe ? "bg-success" : "bg-primary")"
|
|
||||||
role="progressbar"
|
|
||||||
style="width: @percentage.ToString("F1")%">
|
|
||||||
@percentage.ToString("F1")%
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -465,43 +450,63 @@
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<div class="alert alert-secondary">
|
<div class="alert alert-secondary"><i class="bi bi-inbox"></i> Nessun dato sui puntatori.</div>
|
||||||
<i class="bi bi-inbox"></i> Nessun dato sui puntatori disponibile.
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- TAB LOG -->
|
<!-- TAB LOG -->
|
||||||
<div class="tab-pane fade" id="content-log" role="tabpanel">
|
<div class="tab-pane fade" id="content-log" role="tabpanel">
|
||||||
<div class="tab-panel-content">
|
<div class="tab-panel-content p-0">
|
||||||
<div class="log-box-compact">
|
|
||||||
@if (selectedAuction.AuctionLog.Any())
|
@if (selectedAuction.AuctionLog.Any())
|
||||||
{
|
{
|
||||||
@foreach (var logEntry in GetAuctionLog(selectedAuction))
|
<div class="auction-log-grid">
|
||||||
|
<div class="alog-header">
|
||||||
|
<span class="alog-col-time">Ora</span>
|
||||||
|
<span class="alog-col-level">Livello</span>
|
||||||
|
<span class="alog-col-cat">Categoria</span>
|
||||||
|
<span class="alog-col-msg">Messaggio</span>
|
||||||
|
</div>
|
||||||
|
<div class="alog-body">
|
||||||
|
@foreach (var entry in GetAuctionLog(selectedAuction))
|
||||||
{
|
{
|
||||||
<div class="log-entry">@logEntry</div>
|
<div class="alog-row @entry.LevelClass">
|
||||||
|
<span class="alog-col-time">@entry.TimeDisplay</span>
|
||||||
|
<span class="alog-col-level">
|
||||||
|
<i class="bi @entry.LevelIcon"></i> @entry.LevelLabel
|
||||||
|
</span>
|
||||||
|
<span class="alog-col-cat">@entry.CategoryLabel</span>
|
||||||
|
<span class="alog-col-msg">
|
||||||
|
@entry.Message
|
||||||
|
@if (entry.RepeatCount > 1)
|
||||||
|
{
|
||||||
|
<span class="alog-repeat">x@entry.RepeatCount</span>
|
||||||
}
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<div class="text-muted"><i class="bi bi-inbox"></i> Nessun log disponibile per questa asta.</div>
|
<div class="text-muted p-3"><i class="bi bi-inbox"></i> Nessun log disponibile.</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<div class="auction-details-tabs animate-fade-in shadow-hover">
|
<div class="details-placeholder">
|
||||||
<div class="alert alert-secondary text-center my-5">
|
<i class="bi bi-arrow-up"></i>
|
||||||
<i class="bi bi-arrow-up" style="font-size: 2rem; display: block; margin-bottom: 0.5rem;"></i>
|
<p>Seleziona un'asta per visualizzare i dettagli</p>
|
||||||
<p class="mb-0">Seleziona un'asta dalla griglia per visualizzare i dettagli</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Modal Aggiungi Asta -->
|
<!-- Modal Aggiungi Asta -->
|
||||||
@@ -509,7 +514,7 @@
|
|||||||
{
|
{
|
||||||
<div class="modal show d-block" tabindex="-1" style="background: rgba(0,0,0,0.5);">
|
<div class="modal show d-block" tabindex="-1" style="background: rgba(0,0,0,0.5);">
|
||||||
<div class="modal-dialog modal-dialog-centered">
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
<div class="modal-content animate-scale-in">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h5 class="modal-title"><i class="bi bi-plus-circle"></i> Aggiungi Nuova Asta</h5>
|
<h5 class="modal-title"><i class="bi bi-plus-circle"></i> Aggiungi Nuova Asta</h5>
|
||||||
<button type="button" class="btn-close" @onclick="CloseAddDialog"></button>
|
<button type="button" class="btn-close" @onclick="CloseAddDialog"></button>
|
||||||
@@ -517,34 +522,126 @@
|
|||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label fw-bold"><i class="bi bi-link-45deg"></i> URL Asta:</label>
|
<label class="form-label fw-bold"><i class="bi bi-link-45deg"></i> URL Asta:</label>
|
||||||
<input type="text" class="form-control transition-colors @(addDialogError != null ? "is-invalid" : "")"
|
<input type="text" class="form-control @(addDialogError != null ? "is-invalid" : "")"
|
||||||
@bind="addDialogUrl"
|
@bind="addDialogUrl"
|
||||||
placeholder="https://it.bidoo.com/asta/..." />
|
placeholder="https://it.bidoo.com/asta/..." />
|
||||||
@if (addDialogError != null)
|
@if (addDialogError != null)
|
||||||
{
|
{
|
||||||
<div class="invalid-feedback d-block animate-shake">
|
<div class="invalid-feedback d-block">
|
||||||
<i class="bi bi-exclamation-triangle"></i> @addDialogError
|
<i class="bi bi-exclamation-triangle"></i> @addDialogError
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<small class="form-text text-muted">
|
|
||||||
<i class="bi bi-info-circle"></i> Inserisci l'URL completo dell'asta da Bidoo.com. Il nome sarà rilevato automaticamente.
|
|
||||||
</small>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-secondary hover-lift" @onclick="CloseAddDialog">
|
<button type="button" class="btn btn-secondary" @onclick="CloseAddDialog">Annulla</button>
|
||||||
<i class="bi bi-x-circle"></i> Annulla
|
<button type="button" class="btn btn-primary" @onclick="AddAuction" disabled="@string.IsNullOrWhiteSpace(addDialogUrl)">Aggiungi</button>
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-primary hover-lift" @onclick="AddAuction" disabled="@string.IsNullOrWhiteSpace(addDialogUrl)">
|
|
||||||
<i class="bi bi-plus-lg"></i> Aggiungi
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- Versione in basso a destra -->
|
@* Script Splitter - Versione Fixed senza sovrapposizioni *@
|
||||||
<div class="version-badge">
|
<script suppress-error="BL9992">
|
||||||
<i class="bi bi-box-seam"></i> v1.0.0
|
(function() {
|
||||||
</div>
|
function initSplitters() {
|
||||||
|
const gutterV = document.getElementById('gutterVertical');
|
||||||
|
const gutterH = document.getElementById('gutterHorizontal');
|
||||||
|
const panelAuctions = document.getElementById('panelAuctions');
|
||||||
|
const panelLog = document.getElementById('panelLog');
|
||||||
|
const topRow = document.getElementById('topRow');
|
||||||
|
const bottomRow = document.getElementById('bottomRow');
|
||||||
|
|
||||||
|
if (!gutterV || !gutterH || !panelAuctions || !panelLog || !topRow || !bottomRow) {
|
||||||
|
setTimeout(initSplitters, 200);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let active = null;
|
||||||
|
let startPos = 0;
|
||||||
|
let startSizeA = 0;
|
||||||
|
let startSizeB = 0;
|
||||||
|
let containerSize = 0;
|
||||||
|
|
||||||
|
function onMouseDown(e, type, elA, elB) {
|
||||||
|
active = { type, elA, elB };
|
||||||
|
startPos = type === 'v' ? e.clientX : e.clientY;
|
||||||
|
|
||||||
|
// Calcola dimensioni attuali
|
||||||
|
if (type === 'v') {
|
||||||
|
startSizeA = elA.offsetWidth;
|
||||||
|
startSizeB = elB.offsetWidth;
|
||||||
|
containerSize = elA.parentElement.offsetWidth - gutterV.offsetWidth;
|
||||||
|
} else {
|
||||||
|
startSizeA = elA.offsetHeight;
|
||||||
|
startSizeB = elB.offsetHeight;
|
||||||
|
containerSize = elA.parentElement.offsetHeight - gutterH.offsetHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.style.cursor = type === 'v' ? 'col-resize' : 'row-resize';
|
||||||
|
document.body.style.userSelect = 'none';
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
gutterV.onmousedown = (e) => onMouseDown(e, 'v', panelAuctions, panelLog);
|
||||||
|
gutterH.onmousedown = (e) => onMouseDown(e, 'h', topRow, bottomRow);
|
||||||
|
|
||||||
|
document.onmousemove = function(e) {
|
||||||
|
if (!active) return;
|
||||||
|
const { type, elA, elB } = active;
|
||||||
|
const pos = type === 'v' ? e.clientX : e.clientY;
|
||||||
|
const diff = pos - startPos;
|
||||||
|
|
||||||
|
let newA = startSizeA + diff;
|
||||||
|
let newB = startSizeB - diff;
|
||||||
|
|
||||||
|
// Limiti minimi
|
||||||
|
const minA = type === 'v' ? 300 : 200;
|
||||||
|
const minB = type === 'v' ? 200 : 150;
|
||||||
|
|
||||||
|
// Applica limiti
|
||||||
|
if (newA < minA) {
|
||||||
|
newA = minA;
|
||||||
|
newB = containerSize - newA;
|
||||||
|
}
|
||||||
|
if (newB < minB) {
|
||||||
|
newB = minB;
|
||||||
|
newA = containerSize - newB;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assicura che la somma sia corretta (no sovrapposizioni/gap)
|
||||||
|
const totalCheck = newA + newB;
|
||||||
|
if (Math.abs(totalCheck - containerSize) > 1) {
|
||||||
|
// Normalizza le dimensioni
|
||||||
|
const ratio = containerSize / totalCheck;
|
||||||
|
newA = Math.round(newA * ratio);
|
||||||
|
newB = containerSize - newA;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Applica le nuove dimensioni con flex none per dimensioni fisse
|
||||||
|
if (type === 'v') {
|
||||||
|
elA.style.width = newA + 'px';
|
||||||
|
elA.style.flex = 'none';
|
||||||
|
elB.style.width = newB + 'px';
|
||||||
|
elB.style.flex = 'none';
|
||||||
|
} else {
|
||||||
|
elA.style.height = newA + 'px';
|
||||||
|
elA.style.flex = 'none';
|
||||||
|
elB.style.height = newB + 'px';
|
||||||
|
elB.style.flex = 'none';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.onmouseup = function() {
|
||||||
|
if (active) {
|
||||||
|
active = null;
|
||||||
|
document.body.style.cursor = '';
|
||||||
|
document.body.style.userSelect = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(initSplitters, 300);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|||||||
+787
-48
File diff suppressed because it is too large
Load Diff
+835
-124
File diff suppressed because it is too large
Load Diff
+1325
-187
File diff suppressed because it is too large
Load Diff
@@ -9,9 +9,13 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<base href="~/" />
|
<base href="~/" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet" />
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet" />
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet" />
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet" />
|
||||||
<link href="css/app-wpf.css" rel="stylesheet" />
|
<link href="css/app-wpf.css" rel="stylesheet" />
|
||||||
|
<link href="css/modern-pages.css" rel="stylesheet" />
|
||||||
<link href="css/animations.css" rel="stylesheet" />
|
<link href="css/animations.css" rel="stylesheet" />
|
||||||
<component type="typeof(HeadOutlet)" render-mode="ServerPrerendered" />
|
<component type="typeof(HeadOutlet)" render-mode="ServerPrerendered" />
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
+378
-221
@@ -1,33 +1,101 @@
|
|||||||
using AutoBidder.Services;
|
using AutoBidder.Services;
|
||||||
using AutoBidder.Data;
|
using AutoBidder.Data;
|
||||||
|
using AutoBidder.Models;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.AspNetCore.Components.Web;
|
|
||||||
using Microsoft.AspNetCore.DataProtection;
|
using Microsoft.AspNetCore.DataProtection;
|
||||||
using System.Data.Common;
|
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
// Configura Kestrel per accesso remoto con supporto HTTPS
|
// FORCE ASPNETCORE_URLS to prevent any override
|
||||||
builder.WebHost.ConfigureKestrel(options =>
|
// Questo garantisce che il container ascolti SEMPRE sulla porta configurata
|
||||||
|
if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("ASPNETCORE_URLS")))
|
||||||
{
|
{
|
||||||
options.ListenAnyIP(5000); // HTTP
|
builder.WebHost.UseUrls("http://+:8080");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
builder.WebHost.UseUrls(Environment.GetEnvironmentVariable("ASPNETCORE_URLS")!);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configura Kestrel solo per HTTPS opzionale
|
||||||
|
// HTTP è gestito da ASPNETCORE_URLS (default: http://+:8080 nel Dockerfile)
|
||||||
|
var enableHttps = builder.Configuration.GetValue<bool>("Kestrel:EnableHttps", false);
|
||||||
|
|
||||||
|
if (enableHttps)
|
||||||
|
{
|
||||||
|
builder.WebHost.ConfigureKestrel(options =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// In produzione, cerca il certificato da configurazione
|
||||||
|
var certPath = builder.Configuration["Kestrel:Certificates:Default:Path"];
|
||||||
|
var certPassword = builder.Configuration["Kestrel:Certificates:Default:Password"];
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(certPath) && File.Exists(certPath))
|
||||||
|
{
|
||||||
|
options.ListenAnyIP(8443, listenOptions =>
|
||||||
|
{
|
||||||
|
listenOptions.UseHttps(certPath, certPassword);
|
||||||
|
Console.WriteLine($"[Kestrel] HTTPS enabled with certificate: {certPath}");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else if (builder.Environment.IsDevelopment())
|
||||||
|
{
|
||||||
|
// Certificato di sviluppo SOLO in ambiente Development
|
||||||
options.ListenAnyIP(5001, listenOptions =>
|
options.ListenAnyIP(5001, listenOptions =>
|
||||||
{
|
{
|
||||||
listenOptions.UseHttps(); // HTTPS
|
listenOptions.UseHttps();
|
||||||
|
Console.WriteLine("[Kestrel] HTTPS enabled with development certificate");
|
||||||
});
|
});
|
||||||
});
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Console.WriteLine("[Kestrel] HTTPS requested but no certificate found");
|
||||||
|
Console.WriteLine("[Kestrel] Running in HTTP-only mode");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[Kestrel] Failed to enable HTTPS: {ex.Message}");
|
||||||
|
Console.WriteLine("[Kestrel] Running in HTTP-only mode");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Console.WriteLine("[Kestrel] HTTPS disabled - running in HTTP-only mode");
|
||||||
|
Console.WriteLine("[Kestrel] Use a reverse proxy (nginx/traefik) for SSL termination");
|
||||||
|
Console.WriteLine($"[Kestrel] Listening on: {Environment.GetEnvironmentVariable("ASPNETCORE_URLS") ?? "http://+:8080"}");
|
||||||
|
}
|
||||||
|
|
||||||
// Add services to the container
|
// Add services to the container
|
||||||
builder.Services.AddRazorPages();
|
builder.Services.AddRazorPages();
|
||||||
builder.Services.AddServerSideBlazor();
|
builder.Services.AddServerSideBlazor();
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// CONFIGURAZIONE DATABASE - Path configurabile via DATA_PATH
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// Determina il path base per tutti i database e dati persistenti
|
||||||
|
// DATA_PATH può essere impostato nel docker-compose per usare un volume persistente
|
||||||
|
var dataBasePath = Environment.GetEnvironmentVariable("DATA_PATH");
|
||||||
|
if (string.IsNullOrEmpty(dataBasePath))
|
||||||
|
{
|
||||||
|
// Fallback: usa directory relativa all'applicazione
|
||||||
|
dataBasePath = Path.Combine(AppContext.BaseDirectory, "Data");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crea directory se non esiste
|
||||||
|
if (!Directory.Exists(dataBasePath))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(dataBasePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine($"[Startup] Data path: {dataBasePath}");
|
||||||
|
|
||||||
// Configura Data Protection per evitare CryptographicException
|
// Configura Data Protection per evitare CryptographicException
|
||||||
var dataProtectionPath = Path.Combine(
|
var dataProtectionPath = Path.Combine(dataBasePath, "DataProtection-Keys");
|
||||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
|
||||||
"AutoBidder",
|
|
||||||
"DataProtection-Keys"
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!Directory.Exists(dataProtectionPath))
|
if (!Directory.Exists(dataProtectionPath))
|
||||||
{
|
{
|
||||||
@@ -38,6 +106,57 @@ builder.Services.AddDataProtection()
|
|||||||
.PersistKeysToFileSystem(new DirectoryInfo(dataProtectionPath))
|
.PersistKeysToFileSystem(new DirectoryInfo(dataProtectionPath))
|
||||||
.SetApplicationName("AutoBidder");
|
.SetApplicationName("AutoBidder");
|
||||||
|
|
||||||
|
// Database per Identity (SQLite)
|
||||||
|
var identityDbPath = Path.Combine(dataBasePath, "identity.db");
|
||||||
|
|
||||||
|
builder.Services.AddDbContext<ApplicationDbContext>(options =>
|
||||||
|
{
|
||||||
|
options.UseSqlite($"Data Source={identityDbPath}");
|
||||||
|
});
|
||||||
|
|
||||||
|
// ASP.NET Core Identity
|
||||||
|
builder.Services.AddIdentity<ApplicationUser, IdentityRole>(options =>
|
||||||
|
{
|
||||||
|
// Password settings (SICUREZZA FORTE)
|
||||||
|
options.Password.RequireDigit = true;
|
||||||
|
options.Password.RequireLowercase = true;
|
||||||
|
options.Password.RequireUppercase = true;
|
||||||
|
options.Password.RequireNonAlphanumeric = true;
|
||||||
|
options.Password.RequiredLength = 12;
|
||||||
|
options.Password.RequiredUniqueChars = 4;
|
||||||
|
|
||||||
|
// Lockout settings (protezione brute-force)
|
||||||
|
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15);
|
||||||
|
options.Lockout.MaxFailedAccessAttempts = 5;
|
||||||
|
options.Lockout.AllowedForNewUsers = true;
|
||||||
|
|
||||||
|
// User settings
|
||||||
|
options.User.RequireUniqueEmail = false;
|
||||||
|
options.SignIn.RequireConfirmedAccount = false;
|
||||||
|
})
|
||||||
|
.AddEntityFrameworkStores<ApplicationDbContext>()
|
||||||
|
.AddDefaultTokenProviders();
|
||||||
|
|
||||||
|
// Cookie configuration (SICUREZZA TAILSCALE)
|
||||||
|
builder.Services.ConfigureApplicationCookie(options =>
|
||||||
|
{
|
||||||
|
options.Cookie.Name = "AutoBidder.Auth";
|
||||||
|
options.Cookie.HttpOnly = true;
|
||||||
|
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest; // HTTP su Tailscale OK
|
||||||
|
options.Cookie.SameSite = SameSiteMode.Lax;
|
||||||
|
options.ExpireTimeSpan = TimeSpan.FromDays(7);
|
||||||
|
options.SlidingExpiration = true;
|
||||||
|
|
||||||
|
// Redirect per autenticazione (Razor Pages)
|
||||||
|
options.LoginPath = "/Account/Login";
|
||||||
|
options.LogoutPath = "/Account/Logout";
|
||||||
|
options.AccessDeniedPath = "/Account/Login";
|
||||||
|
});
|
||||||
|
|
||||||
|
// Authorization
|
||||||
|
builder.Services.AddAuthorization();
|
||||||
|
builder.Services.AddCascadingAuthenticationState();
|
||||||
|
|
||||||
// Configura HTTPS Redirection per produzione
|
// Configura HTTPS Redirection per produzione
|
||||||
if (!builder.Environment.IsDevelopment())
|
if (!builder.Environment.IsDevelopment())
|
||||||
{
|
{
|
||||||
@@ -49,71 +168,6 @@ if (!builder.Environment.IsDevelopment())
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Configura Database SQLite per statistiche (fallback locale)
|
|
||||||
builder.Services.AddDbContext<StatisticsContext>(options =>
|
|
||||||
{
|
|
||||||
var dbPath = Path.Combine(
|
|
||||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
|
||||||
"AutoBidder",
|
|
||||||
"statistics.db"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Crea directory se non esiste
|
|
||||||
var directory = Path.GetDirectoryName(dbPath);
|
|
||||||
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
|
|
||||||
{
|
|
||||||
Directory.CreateDirectory(directory);
|
|
||||||
}
|
|
||||||
|
|
||||||
options.UseSqlite($"Data Source={dbPath}");
|
|
||||||
});
|
|
||||||
|
|
||||||
// Configura Database PostgreSQL per statistiche avanzate
|
|
||||||
var usePostgres = builder.Configuration.GetValue<bool>("Database:UsePostgres", false);
|
|
||||||
if (usePostgres)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var connString = builder.Environment.IsProduction()
|
|
||||||
? builder.Configuration.GetConnectionString("PostgresStatsProduction")
|
|
||||||
: builder.Configuration.GetConnectionString("PostgresStats");
|
|
||||||
|
|
||||||
// Sostituisci variabili ambiente in production
|
|
||||||
if (builder.Environment.IsProduction())
|
|
||||||
{
|
|
||||||
connString = connString?
|
|
||||||
.Replace("${POSTGRES_USER}", Environment.GetEnvironmentVariable("POSTGRES_USER") ?? "autobidder")
|
|
||||||
.Replace("${POSTGRES_PASSWORD}", Environment.GetEnvironmentVariable("POSTGRES_PASSWORD") ?? "");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(connString))
|
|
||||||
{
|
|
||||||
builder.Services.AddDbContext<AutoBidder.Data.PostgresStatsContext>(options =>
|
|
||||||
{
|
|
||||||
options.UseNpgsql(connString, npgsqlOptions =>
|
|
||||||
{
|
|
||||||
npgsqlOptions.EnableRetryOnFailure(3);
|
|
||||||
npgsqlOptions.CommandTimeout(30);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
Console.WriteLine("[Startup] PostgreSQL configured for statistics");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Console.WriteLine("[Startup] PostgreSQL connection string not found - using SQLite only");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"[Startup] PostgreSQL configuration failed: {ex.Message} - using SQLite only");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Console.WriteLine("[Startup] PostgreSQL disabled in configuration - using SQLite only");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Registra servizi applicazione come Singleton per condividere stato
|
// Registra servizi applicazione come Singleton per condividere stato
|
||||||
var htmlCacheService = new HtmlCacheService(
|
var htmlCacheService = new HtmlCacheService(
|
||||||
maxConcurrentRequests: 3,
|
maxConcurrentRequests: 3,
|
||||||
@@ -122,31 +176,18 @@ var htmlCacheService = new HtmlCacheService(
|
|||||||
maxRetries: 2
|
maxRetries: 2
|
||||||
);
|
);
|
||||||
|
|
||||||
var auctionMonitor = new AuctionMonitor();
|
var bidStrategyService = new BidStrategyService();
|
||||||
|
var auctionMonitor = new AuctionMonitor(bidStrategyService);
|
||||||
htmlCacheService.OnLog += (msg) => Console.WriteLine(msg);
|
htmlCacheService.OnLog += (msg) => Console.WriteLine(msg);
|
||||||
|
|
||||||
|
builder.Services.AddSingleton(bidStrategyService);
|
||||||
builder.Services.AddSingleton(auctionMonitor);
|
builder.Services.AddSingleton(auctionMonitor);
|
||||||
builder.Services.AddSingleton(htmlCacheService);
|
builder.Services.AddSingleton(htmlCacheService);
|
||||||
builder.Services.AddSingleton(sp => new SessionService(auctionMonitor.GetApiClient()));
|
builder.Services.AddSingleton(sp => new SessionService(auctionMonitor.GetApiClient()));
|
||||||
builder.Services.AddSingleton<DatabaseService>();
|
builder.Services.AddSingleton<DatabaseService>();
|
||||||
builder.Services.AddSingleton<ApplicationStateService>();
|
builder.Services.AddSingleton<ApplicationStateService>();
|
||||||
builder.Services.AddScoped<StatsService>(sp =>
|
builder.Services.AddSingleton<BidooBrowserService>();
|
||||||
{
|
builder.Services.AddScoped<StatsService>();
|
||||||
var db = sp.GetRequiredService<DatabaseService>();
|
|
||||||
|
|
||||||
// Prova a ottenere PostgreSQL context (potrebbe essere null)
|
|
||||||
AutoBidder.Data.PostgresStatsContext? postgresDb = null;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
postgresDb = sp.GetService<AutoBidder.Data.PostgresStatsContext>();
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// PostgreSQL non disponibile, usa solo SQLite
|
|
||||||
}
|
|
||||||
|
|
||||||
return new StatsService(db, postgresDb);
|
|
||||||
});
|
|
||||||
builder.Services.AddScoped<AuctionStateService>();
|
builder.Services.AddScoped<AuctionStateService>();
|
||||||
|
|
||||||
// Configura SignalR per real-time updates
|
// Configura SignalR per real-time updates
|
||||||
@@ -158,6 +199,63 @@ builder.Services.AddSignalR(options =>
|
|||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// INIZIALIZZAZIONE DATABASE IDENTITY
|
||||||
|
// ============================================
|
||||||
|
using (var scope = app.Services.CreateScope())
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var identityDb = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||||
|
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
|
||||||
|
|
||||||
|
// Crea database Identity
|
||||||
|
await identityDb.Database.EnsureCreatedAsync();
|
||||||
|
Console.WriteLine("[Identity] Database initialized");
|
||||||
|
|
||||||
|
// Crea utente admin se non esiste
|
||||||
|
var adminUsername = Environment.GetEnvironmentVariable("ADMIN_USERNAME") ?? "admin";
|
||||||
|
var adminPassword = Environment.GetEnvironmentVariable("ADMIN_PASSWORD");
|
||||||
|
|
||||||
|
// Password di default se non configurata (stessa per debug e container)
|
||||||
|
if (string.IsNullOrEmpty(adminPassword))
|
||||||
|
{
|
||||||
|
adminPassword = "Admin@Password123!";
|
||||||
|
}
|
||||||
|
|
||||||
|
var existingAdmin = await userManager.FindByNameAsync(adminUsername);
|
||||||
|
if (existingAdmin == null)
|
||||||
|
{
|
||||||
|
var adminUser = new ApplicationUser
|
||||||
|
{
|
||||||
|
UserName = adminUsername,
|
||||||
|
Email = $"{adminUsername}@autobidder.local",
|
||||||
|
EmailConfirmed = true,
|
||||||
|
IsActive = true,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await userManager.CreateAsync(adminUser, adminPassword);
|
||||||
|
if (result.Succeeded)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[Identity] Admin user created: {adminUsername}");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[Identity] Failed to create admin user: {string.Join(", ", result.Errors.Select(e => e.Description))}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[Identity] Admin user exists: {adminUsername}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[Identity] Initialization error: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ??? NUOVO: Inizializza DatabaseService
|
// ??? NUOVO: Inizializza DatabaseService
|
||||||
using (var scope = app.Services.CreateScope())
|
using (var scope = app.Services.CreateScope())
|
||||||
{
|
{
|
||||||
@@ -180,139 +278,126 @@ using (var scope = app.Services.CreateScope())
|
|||||||
// Verifica salute database
|
// Verifica salute database
|
||||||
var isHealthy = await databaseService.CheckDatabaseHealthAsync();
|
var isHealthy = await databaseService.CheckDatabaseHealthAsync();
|
||||||
Console.WriteLine($"[DB] Database health check: {(isHealthy ? "OK" : "FAILED")}");
|
Console.WriteLine($"[DB] Database health check: {(isHealthy ? "OK" : "FAILED")}");
|
||||||
|
|
||||||
|
// 🔥 MANUTENZIONE AUTOMATICA DATABASE
|
||||||
|
var settings = AutoBidder.Utilities.SettingsManager.Load();
|
||||||
|
|
||||||
|
if (settings.DatabaseAutoCleanupDuplicates)
|
||||||
|
{
|
||||||
|
Console.WriteLine("[DB] Checking for duplicate records...");
|
||||||
|
var duplicateCount = await databaseService.CountDuplicateAuctionResultsAsync();
|
||||||
|
if (duplicateCount > 0)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[DB] Found {duplicateCount} duplicates - removing...");
|
||||||
|
var removed = await databaseService.RemoveDuplicateAuctionResultsAsync();
|
||||||
|
Console.WriteLine($"[DB] ✓ Removed {removed} duplicate auction results");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Console.WriteLine("[DB] ✓ No duplicates found");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings.DatabaseAutoCleanupIncomplete)
|
||||||
|
{
|
||||||
|
Console.WriteLine("[DB] Checking for incomplete records...");
|
||||||
|
var incompleteCount = await databaseService.CountIncompleteAuctionResultsAsync();
|
||||||
|
if (incompleteCount > 0)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[DB] Found {incompleteCount} incomplete records - removing...");
|
||||||
|
var removed = await databaseService.RemoveIncompleteAuctionResultsAsync();
|
||||||
|
Console.WriteLine($"[DB] ✓ Removed {removed} incomplete auction results");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Console.WriteLine("[DB] ✓ No incomplete records found");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings.DatabaseMaxRetentionDays > 0)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[DB] Checking for records older than {settings.DatabaseMaxRetentionDays} days...");
|
||||||
|
var oldCount = await databaseService.RemoveOldAuctionResultsAsync(settings.DatabaseMaxRetentionDays);
|
||||||
|
if (oldCount > 0)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[DB] ✓ Removed {oldCount} old auction results");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[DB] ✓ No old records to remove");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🆕 Esegui diagnostica completa se ci sono problemi o se richiesto
|
||||||
|
var runDiagnostics = Environment.GetEnvironmentVariable("DB_DIAGNOSTICS")?.ToLower() == "true";
|
||||||
|
if (!isHealthy || runDiagnostics)
|
||||||
|
{
|
||||||
|
Console.WriteLine("[DB] Running full diagnostics...");
|
||||||
|
await databaseService.RunDatabaseDiagnosticsAsync();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Console.WriteLine($"[DB ERROR] Failed to initialize database: {ex.Message}");
|
Console.WriteLine($"[DB ERROR] Failed to initialize database: {ex.Message}");
|
||||||
Console.WriteLine($"[DB ERROR] Stack trace: {ex.StackTrace}");
|
Console.WriteLine($"[DB ERROR] Stack trace: {ex.StackTrace}");
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Crea database statistiche se non esiste (senza migrations)
|
|
||||||
using (var scope = app.Services.CreateScope())
|
|
||||||
{
|
|
||||||
var db = scope.ServiceProvider.GetRequiredService<StatisticsContext>();
|
|
||||||
|
|
||||||
|
// In caso di errore, esegui sempre la diagnostica
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Log percorso database
|
await databaseService.RunDatabaseDiagnosticsAsync();
|
||||||
var connection = db.Database.GetDbConnection();
|
|
||||||
Console.WriteLine($"[STATS DB] Database path: {connection.DataSource}");
|
|
||||||
|
|
||||||
// Verifica se database esiste
|
|
||||||
var dbExists = db.Database.CanConnect();
|
|
||||||
Console.WriteLine($"[STATS DB] Database exists: {dbExists}");
|
|
||||||
|
|
||||||
// Forza creazione tabelle se non esistono
|
|
||||||
if (!dbExists || !db.ProductStats.Any())
|
|
||||||
{
|
|
||||||
Console.WriteLine("[STATS DB] Creating database schema...");
|
|
||||||
db.Database.EnsureDeleted(); // Elimina database vecchio
|
|
||||||
db.Database.EnsureCreated(); // Ricrea con schema aggiornato
|
|
||||||
Console.WriteLine("[STATS DB] Database schema created successfully");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Console.WriteLine($"[STATS DB] Database already exists with {db.ProductStats.Count()} records");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"[STATS DB ERROR] Failed to initialize database: {ex.Message}");
|
|
||||||
Console.WriteLine($"[STATS DB ERROR] Stack trace: {ex.StackTrace}");
|
|
||||||
|
|
||||||
// Prova a ricreare forzatamente
|
|
||||||
try
|
|
||||||
{
|
|
||||||
Console.WriteLine("[STATS DB] Attempting forced recreation...");
|
|
||||||
db.Database.EnsureDeleted();
|
|
||||||
db.Database.EnsureCreated();
|
|
||||||
Console.WriteLine("[STATS DB] Forced recreation successful");
|
|
||||||
}
|
|
||||||
catch (Exception ex2)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"[STATS DB ERROR] Forced recreation failed: {ex2.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Inizializza PostgreSQL per statistiche avanzate
|
|
||||||
using (var scope = app.Services.CreateScope())
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var postgresDb = scope.ServiceProvider.GetService<AutoBidder.Data.PostgresStatsContext>();
|
|
||||||
|
|
||||||
if (postgresDb != null)
|
|
||||||
{
|
|
||||||
Console.WriteLine("[PostgreSQL] Initializing PostgreSQL statistics database...");
|
|
||||||
|
|
||||||
var autoCreateSchema = app.Configuration.GetValue<bool>("Database:AutoCreateSchema", true);
|
|
||||||
|
|
||||||
if (autoCreateSchema)
|
|
||||||
{
|
|
||||||
// Usa il metodo EnsureSchemaAsync che gestisce la creazione automatica
|
|
||||||
var schemaCreated = await postgresDb.EnsureSchemaAsync();
|
|
||||||
|
|
||||||
if (schemaCreated)
|
|
||||||
{
|
|
||||||
// Valida che tutte le tabelle siano state create
|
|
||||||
var schemaValid = await postgresDb.ValidateSchemaAsync();
|
|
||||||
|
|
||||||
if (schemaValid)
|
|
||||||
{
|
|
||||||
Console.WriteLine("[PostgreSQL] Statistics features ENABLED");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Console.WriteLine("[PostgreSQL] Schema validation failed");
|
|
||||||
Console.WriteLine("[PostgreSQL] Statistics features DISABLED (missing tables)");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Console.WriteLine("[PostgreSQL] Cannot connect to database");
|
|
||||||
Console.WriteLine("[PostgreSQL] Statistics features will use SQLite fallback");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Console.WriteLine("[PostgreSQL] Auto-create schema disabled");
|
|
||||||
|
|
||||||
// Prova comunque a validare lo schema esistente
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var schemaValid = await postgresDb.ValidateSchemaAsync();
|
|
||||||
if (schemaValid)
|
|
||||||
{
|
|
||||||
Console.WriteLine("[PostgreSQL] Existing schema validated successfully");
|
|
||||||
Console.WriteLine("[PostgreSQL] Statistics features ENABLED");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Console.WriteLine("[PostgreSQL] Statistics features DISABLED (schema not found)");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
Console.WriteLine("[PostgreSQL] Statistics features DISABLED (schema not found)");
|
// Ignora errori nella diagnostica stessa
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Console.WriteLine("[PostgreSQL] Not configured - Statistics will use SQLite only");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"[PostgreSQL ERROR] Initialization failed: {ex.Message}");
|
|
||||||
Console.WriteLine($"[PostgreSQL ERROR] Stack trace: {ex.StackTrace}");
|
|
||||||
Console.WriteLine($"[PostgreSQL] Statistics features will use SQLite fallback");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ??? NUOVO: Ripristina aste salvate e riprendi monitoraggio se configurato
|
// ?? NUOVO: Collega evento OnAuctionCompleted per salvare statistiche
|
||||||
|
{
|
||||||
|
var dbService = app.Services.GetRequiredService<DatabaseService>();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
auctionMonitor.OnAuctionCompleted += async (auction, state, won) =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Console.WriteLine($"");
|
||||||
|
Console.WriteLine($"╔════════════════════════════════════════════════════════════════");
|
||||||
|
Console.WriteLine($"║ [EVENTO] Asta Terminata - Salvataggio Statistiche");
|
||||||
|
Console.WriteLine($"╠════════════════════════════════════════════════════════════════");
|
||||||
|
Console.WriteLine($"║ Asta: {auction.Name}");
|
||||||
|
Console.WriteLine($"║ ID: {auction.AuctionId}");
|
||||||
|
Console.WriteLine($"║ Stato: {(won ? "✓ VINTA" : "✗ PERSA")}");
|
||||||
|
Console.WriteLine($"╚════════════════════════════════════════════════════════════════");
|
||||||
|
Console.WriteLine($"");
|
||||||
|
|
||||||
|
// Crea un nuovo scope per StatsService (è Scoped)
|
||||||
|
using var scope = app.Services.CreateScope();
|
||||||
|
var statsService = scope.ServiceProvider.GetRequiredService<StatsService>();
|
||||||
|
|
||||||
|
await statsService.RecordAuctionCompletedAsync(auction, state, won);
|
||||||
|
|
||||||
|
// ✅ CORRETTO: Log di successo SOLO se non ci sono eccezioni
|
||||||
|
Console.WriteLine($"");
|
||||||
|
Console.WriteLine($"[EVENTO] ✓ Asta salvata con successo nel database");
|
||||||
|
Console.WriteLine($"");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"");
|
||||||
|
Console.WriteLine($"[EVENTO ERROR] ✗ Errore durante salvataggio statistiche:");
|
||||||
|
Console.WriteLine($"[EVENTO ERROR] {ex.Message}");
|
||||||
|
Console.WriteLine($"[EVENTO ERROR] Stack: {ex.StackTrace}");
|
||||||
|
Console.WriteLine($"");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Console.WriteLine("[STARTUP] OnAuctionCompleted event handler registered");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ? NUOVO: Ripristina aste salvate e riprendi monitoraggio se configurato
|
||||||
using (var scope = app.Services.CreateScope())
|
using (var scope = app.Services.CreateScope())
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -347,15 +432,26 @@ using (var scope = app.Services.CreateScope())
|
|||||||
// Gestisci comportamento di avvio
|
// Gestisci comportamento di avvio
|
||||||
if (settings.RememberAuctionStates)
|
if (settings.RememberAuctionStates)
|
||||||
{
|
{
|
||||||
// Modalità "Ricorda Stato": mantiene lo stato salvato di ogni asta
|
// Modalità "Ricorda Stato": mantiene lo stato salvato di ogni asta
|
||||||
var activeAuctions = savedAuctions.Where(a => a.IsActive && !a.IsPaused).ToList();
|
// 🔥 FIX CRITICO: Avvia monitor anche per aste in pausa (IsActive=true)
|
||||||
|
var activeAuctions = savedAuctions.Where(a => a.IsActive).ToList();
|
||||||
|
var resumeAuctions = savedAuctions.Where(a => a.IsActive && !a.IsPaused).ToList();
|
||||||
|
var pausedAuctions = savedAuctions.Where(a => a.IsActive && a.IsPaused).ToList();
|
||||||
|
|
||||||
if (activeAuctions.Any())
|
if (activeAuctions.Any())
|
||||||
{
|
{
|
||||||
Console.WriteLine($"[STARTUP] Resuming monitoring for {activeAuctions.Count} active auctions");
|
Console.WriteLine($"[STARTUP] Starting monitor for {activeAuctions.Count} active auctions ({resumeAuctions.Count} active, {pausedAuctions.Count} paused)");
|
||||||
monitor.Start();
|
monitor.Start();
|
||||||
appState.IsMonitoringActive = true;
|
appState.IsMonitoringActive = true;
|
||||||
appState.AddLog($"[STARTUP] Ripristinato stato salvato: {activeAuctions.Count} aste attive");
|
|
||||||
|
if (pausedAuctions.Any())
|
||||||
|
{
|
||||||
|
appState.AddLog($"[STARTUP] Ripristinate {resumeAuctions.Count} aste attive + {pausedAuctions.Count} in pausa (polling attivo)");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
appState.AddLog($"[STARTUP] Ripristinato stato salvato: {resumeAuctions.Count} aste attive");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -365,7 +461,7 @@ using (var scope = app.Services.CreateScope())
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Modalità "Default": applica DefaultStartAuctionsOnLoad a tutte le aste
|
// Modalità "Default": applica DefaultStartAuctionsOnLoad a tutte le aste
|
||||||
switch (settings.DefaultStartAuctionsOnLoad)
|
switch (settings.DefaultStartAuctionsOnLoad)
|
||||||
{
|
{
|
||||||
case "Active":
|
case "Active":
|
||||||
@@ -424,18 +520,79 @@ using (var scope = app.Services.CreateScope())
|
|||||||
if (!app.Environment.IsDevelopment())
|
if (!app.Environment.IsDevelopment())
|
||||||
{
|
{
|
||||||
app.UseExceptionHandler("/Error");
|
app.UseExceptionHandler("/Error");
|
||||||
|
|
||||||
|
// Abilita HSTS solo se HTTPS è attivo
|
||||||
|
if (enableHttps)
|
||||||
|
{
|
||||||
app.UseHsts();
|
app.UseHsts();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
app.UseDeveloperExceptionPage();
|
app.UseDeveloperExceptionPage();
|
||||||
}
|
}
|
||||||
|
|
||||||
app.UseHttpsRedirection();
|
// Abilita HTTPS redirection solo se HTTPS è configurato
|
||||||
|
if (enableHttps)
|
||||||
|
{
|
||||||
|
app.UseHttpsRedirection();
|
||||||
|
}
|
||||||
|
|
||||||
app.UseStaticFiles();
|
app.UseStaticFiles();
|
||||||
app.UseRouting();
|
app.UseRouting();
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// MIDDLEWARE AUTENTICAZIONE E AUTORIZZAZIONE
|
||||||
|
// ============================================
|
||||||
|
app.UseAuthentication();
|
||||||
|
app.UseAuthorization();
|
||||||
|
|
||||||
|
app.MapRazorPages(); // ? AGGIUNTO: abilita Razor Pages (Login, Logout)
|
||||||
app.MapBlazorHub();
|
app.MapBlazorHub();
|
||||||
app.MapFallbackToPage("/_Host");
|
app.MapFallbackToPage("/_Host");
|
||||||
|
|
||||||
|
// ?????????????????????????????????????????????????????????????????
|
||||||
|
// TIMER PULIZIA MEMORIA PERIODICA
|
||||||
|
// ?????????????????????????????????????????????????????????????????
|
||||||
|
|
||||||
|
// Timer per pulizia periodica della memoria (ogni 5 minuti)
|
||||||
|
var memoryCleanupTimer = new System.Threading.Timer(async _ =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var scope = app.Services.CreateScope();
|
||||||
|
var appState = scope.ServiceProvider.GetRequiredService<ApplicationStateService>();
|
||||||
|
var htmlCache = scope.ServiceProvider.GetRequiredService<HtmlCacheService>();
|
||||||
|
|
||||||
|
// Pulisci cache HTML scaduta
|
||||||
|
htmlCache.CleanExpiredCache();
|
||||||
|
|
||||||
|
// Compatta dati aste completate
|
||||||
|
appState.CleanupCompletedAuctions();
|
||||||
|
|
||||||
|
// Forza garbage collection leggera
|
||||||
|
GC.Collect(1, GCCollectionMode.Optimized, false);
|
||||||
|
|
||||||
|
// Log statistiche memoria
|
||||||
|
var stats = appState.GetMemoryStats();
|
||||||
|
var memoryMB = GC.GetTotalMemory(false) / 1024.0 / 1024.0;
|
||||||
|
Console.WriteLine($"[MEMORY] Cleanup: {stats.AuctionsCount} aste, " +
|
||||||
|
$"{stats.TotalBidHistoryEntries} bid history, " +
|
||||||
|
$"{stats.TotalRecentBidsEntries} recent bids, " +
|
||||||
|
$"{stats.GlobalLogEntries} global log, " +
|
||||||
|
$"RAM: {memoryMB:F1}MB");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[MEMORY ERROR] Cleanup failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
}, null, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(5));
|
||||||
|
|
||||||
|
// Assicura che il timer venga disposto quando l'app si chiude
|
||||||
|
app.Lifetime.ApplicationStopping.Register(() =>
|
||||||
|
{
|
||||||
|
Console.WriteLine("[SHUTDOWN] Disposing memory cleanup timer...");
|
||||||
|
memoryCleanupTimer.Dispose();
|
||||||
|
});
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|||||||
@@ -0,0 +1,424 @@
|
|||||||
|
# ?? AutoBidder - Sistema Automatizzato Gestione Aste Bidoo
|
||||||
|
|
||||||
|
[](CHANGELOG.md)
|
||||||
|
[](https://dotnet.microsoft.com/)
|
||||||
|
[](https://dotnet.microsoft.com/apps/aspnet/web-apps/blazor)
|
||||||
|
[](Dockerfile)
|
||||||
|
[](SECURITY.md)
|
||||||
|
[](LICENSE)
|
||||||
|
|
||||||
|
Sistema Blazor .NET 8 per il monitoraggio e la partecipazione automatica alle aste Bidoo, con **autenticazione sicura** per deploy Tailscale.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ?? Quick Start
|
||||||
|
|
||||||
|
### ?? NUOVO v1.2.0: Configurazione Sicurezza
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Copia e configura credenziali
|
||||||
|
cp .env.example .env
|
||||||
|
nano .env # Imposta ADMIN_PASSWORD
|
||||||
|
|
||||||
|
# 2. Avvia container
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# 3. Primo login
|
||||||
|
# Browser: http://localhost:5000/login
|
||||||
|
# Username: admin
|
||||||
|
# Password: (valore ADMIN_PASSWORD)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker (CONSIGLIATO)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Pull ultima versione da Gitea
|
||||||
|
docker pull gitea.encke-hake.ts.net/alby96/autobidder:1.2.0
|
||||||
|
|
||||||
|
# Avvia container CON AUTENTICAZIONE
|
||||||
|
docker run -d \
|
||||||
|
--name autobidder \
|
||||||
|
-p 5000:8080 \
|
||||||
|
-e ADMIN_USERNAME=admin \
|
||||||
|
-e ADMIN_PASSWORD="TuaPasswordSicura123!" \
|
||||||
|
-v /path/to/data:/app/Data \
|
||||||
|
gitea.encke-hake.ts.net/alby96/autobidder:1.2.0
|
||||||
|
|
||||||
|
# Accedi a http://localhost:5000/login
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker Compose
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Configura .env
|
||||||
|
cp .env.example .env
|
||||||
|
# Imposta ADMIN_PASSWORD in .env
|
||||||
|
|
||||||
|
# 2. Avvia stack
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Development Locale
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Imposta password admin
|
||||||
|
export ADMIN_PASSWORD="DevPassword123!"
|
||||||
|
|
||||||
|
# Avvia applicazione
|
||||||
|
dotnet run --project AutoBidder.csproj
|
||||||
|
|
||||||
|
# Accedi a http://localhost:8080/login
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ?? Versione Corrente: `1.2.0`
|
||||||
|
|
||||||
|
**Release:** 2025-01-18
|
||||||
|
**Tipo:** MINOR (feature sicurezza + autenticazione)
|
||||||
|
|
||||||
|
### ?? Novità v1.2.0 - SICUREZZA
|
||||||
|
|
||||||
|
- ?? **Sistema autenticazione completo**
|
||||||
|
- Login username/password con ASP.NET Core Identity
|
||||||
|
- Protezione brute-force (lockout 15 min dopo 5 tentativi)
|
||||||
|
- Cookie sicuri (HttpOnly, SameSite)
|
||||||
|
- Password policy forte (min 12 caratteri)
|
||||||
|
|
||||||
|
- ??? **Protezione route**
|
||||||
|
- Tutte le pagine richiedono autenticazione
|
||||||
|
- Redirect automatico a `/login`
|
||||||
|
- Gestione sessioni sicura
|
||||||
|
|
||||||
|
- ?? **Configurazione utente admin**
|
||||||
|
- Username/password via environment variables
|
||||||
|
- Password temporanea se non configurata (?? da cambiare!)
|
||||||
|
- Database Identity SQLite persistente
|
||||||
|
|
||||||
|
**[?? Changelog Completo](CHANGELOG.md)** | **[?? Guida Sicurezza](SECURITY.md)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ? Caratteristiche Principali
|
||||||
|
|
||||||
|
### ?? Monitoraggio Aste Real-time
|
||||||
|
- Rilevamento automatico nuove aste
|
||||||
|
- Tracking partecipanti e offerte
|
||||||
|
- Calcolo valore prodotto e probabilità vittoria
|
||||||
|
- Notifiche eventi importanti
|
||||||
|
|
||||||
|
### ?? Sistema Offerte Automatico
|
||||||
|
- Strategie configurabili per tipo prodotto
|
||||||
|
- Gestione budget e limiti
|
||||||
|
- Auto-bid su aste promettenti
|
||||||
|
- Prevenzione overbid
|
||||||
|
|
||||||
|
### ?? Statistiche Avanzate
|
||||||
|
- Database PostgreSQL per analytics
|
||||||
|
- Storico aste chiuse
|
||||||
|
- Analisi performance prodotti
|
||||||
|
- Dashboard interattive
|
||||||
|
|
||||||
|
### ?? Gestione Sessione Sicura
|
||||||
|
- Login automatico Bidoo
|
||||||
|
- Session persistence
|
||||||
|
- Cookie management
|
||||||
|
- Auto-refresh token
|
||||||
|
|
||||||
|
### ?? Persistenza Dati
|
||||||
|
- SQLite per dati operativi
|
||||||
|
- PostgreSQL per statistiche
|
||||||
|
- Backup automatici
|
||||||
|
- Export/Import configurazioni
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ?? Struttura Progetto
|
||||||
|
|
||||||
|
```
|
||||||
|
AutoBidder/
|
||||||
|
??? ?? AutoBidder.csproj # Configurazione progetto (.NET 8)
|
||||||
|
??? ?? Dockerfile # Container image definition
|
||||||
|
??? ?? docker-compose.yml # Stack completo (app + PostgreSQL)
|
||||||
|
??? ?? Program.cs # Entry point applicazione
|
||||||
|
??? ?? CHANGELOG.md # Storico versioni
|
||||||
|
??? ?? VERSIONING.md # Sistema versionamento
|
||||||
|
?
|
||||||
|
??? ?? Pages/ # Blazor Pages
|
||||||
|
? ??? Index.razor # Dashboard principale
|
||||||
|
? ??? FreeBids.razor # Gestione crediti gratuiti
|
||||||
|
? ??? Settings.razor # Configurazione
|
||||||
|
? ??? Statistics.razor # Analytics avanzate
|
||||||
|
?
|
||||||
|
??? ?? Services/ # Business Logic
|
||||||
|
? ??? AuctionMonitor.cs # Core monitoring engine
|
||||||
|
? ??? BidooApiClient.cs # API client Bidoo
|
||||||
|
? ??? SessionManager.cs # Gestione autenticazione
|
||||||
|
? ??? StatsService.cs # Analytics service
|
||||||
|
? ??? DatabaseService.cs # Data persistence
|
||||||
|
?
|
||||||
|
??? ?? Data/ # Database Contexts
|
||||||
|
? ??? StatisticsContext.cs # SQLite context
|
||||||
|
? ??? PostgresStatsContext.cs # PostgreSQL context
|
||||||
|
?
|
||||||
|
??? ?? Models/ # Data Models
|
||||||
|
? ??? AuctionInfo.cs
|
||||||
|
? ??? BidderInfo.cs
|
||||||
|
? ??? ProductStat.cs
|
||||||
|
? ??? ...
|
||||||
|
?
|
||||||
|
??? ?? Properties/PublishProfiles/ # Profili pubblicazione
|
||||||
|
??? GiteaRegistry.pubxml # Gitea Container Registry
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ?? Configurazione
|
||||||
|
|
||||||
|
### Variabili Ambiente
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Ambiente ASP.NET
|
||||||
|
ASPNETCORE_ENVIRONMENT=Production
|
||||||
|
ASPNETCORE_URLS=http://+:8080
|
||||||
|
|
||||||
|
# Kestrel
|
||||||
|
Kestrel__EnableHttps=false
|
||||||
|
|
||||||
|
# Database
|
||||||
|
Database__SQLitePath=/app/Data/autobidder.db
|
||||||
|
Database__PostgreSQLConnection=Host=postgres;Database=autobidder_stats;Username=autobidder;Password=***
|
||||||
|
|
||||||
|
# Bidoo
|
||||||
|
Bidoo__Username=your_email@example.com
|
||||||
|
Bidoo__Password=your_password
|
||||||
|
Bidoo__MonitorInterval=5000
|
||||||
|
|
||||||
|
# Backup
|
||||||
|
Backup__Enabled=true
|
||||||
|
Backup__IntervalHours=24
|
||||||
|
```
|
||||||
|
|
||||||
|
### Porte
|
||||||
|
|
||||||
|
| Ambiente | Host | Container | Protocollo |
|
||||||
|
|----------|------|-----------|------------|
|
||||||
|
| Development | 5001 | 5001 | HTTPS |
|
||||||
|
| Docker | 5000 | 8080 | HTTP |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ?? Documentazione
|
||||||
|
|
||||||
|
### Per Utenti
|
||||||
|
|
||||||
|
- [?? Guida Rapida](docs/QUICK_START.md)
|
||||||
|
- [?? Configurazione](docs/CONFIGURATION.md)
|
||||||
|
- [? FAQ](docs/FAQ.md)
|
||||||
|
- [?? Troubleshooting](docs/TROUBLESHOOTING.md)
|
||||||
|
|
||||||
|
### Per Sviluppatori
|
||||||
|
|
||||||
|
- [?? Docker Publishing Guide](DOCKER_PUBLISH_GUIDE.md)
|
||||||
|
- [?? Sistema Versionamento](VERSIONING.md)
|
||||||
|
- [?? Setup Ambiente Dev](docs/DEVELOPMENT.md)
|
||||||
|
- [??? Architettura](docs/ARCHITECTURE.md)
|
||||||
|
|
||||||
|
### Changelog & Release
|
||||||
|
|
||||||
|
- [?? CHANGELOG](CHANGELOG.md) - Storico modifiche
|
||||||
|
- [?? VERSIONING](VERSIONING.md) - Sistema versioning
|
||||||
|
- [?? Bump Version Script](bump-version.ps1) - Automazione
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ?? Deployment
|
||||||
|
|
||||||
|
### Docker (Production)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Pull versione specifica (CONSIGLIATO)
|
||||||
|
docker pull gitea.encke-hake.ts.net/alby96/autobidder:1.1.0
|
||||||
|
|
||||||
|
# Avvia con docker-compose
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# Verifica logs
|
||||||
|
docker-compose logs -f autobidder
|
||||||
|
|
||||||
|
# Accedi
|
||||||
|
http://localhost:5000
|
||||||
|
```
|
||||||
|
|
||||||
|
### Unraid
|
||||||
|
|
||||||
|
1. **Aggiungi Container**
|
||||||
|
- Repository: `gitea.encke-hake.ts.net/alby96/autobidder:1.1.0`
|
||||||
|
- Port: `5000` (host) ? `8080` (container)
|
||||||
|
- Volume: `/mnt/user/appdata/autobidder/data` ? `/app/Data`
|
||||||
|
- Volume: `/mnt/user/appdata/autobidder/logs` ? `/app/logs`
|
||||||
|
|
||||||
|
2. **Variabili Ambiente**
|
||||||
|
- `ASPNETCORE_ENVIRONMENT=Production`
|
||||||
|
- `Bidoo__Username=email@example.com`
|
||||||
|
- `Bidoo__Password=***`
|
||||||
|
|
||||||
|
3. **Avvia Container**
|
||||||
|
|
||||||
|
**[?? Guida Completa Deployment](deployment/README.md)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ?? Aggiornamento Versione
|
||||||
|
|
||||||
|
### Da v1.0.0 a v1.1.0
|
||||||
|
|
||||||
|
**Breaking Changes:**
|
||||||
|
- ?? Porta container: `5000` ? `8080`
|
||||||
|
- ?? HTTPS disabilitato di default
|
||||||
|
|
||||||
|
**Aggiornamento:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Stop container vecchio
|
||||||
|
docker stop autobidder
|
||||||
|
docker rm autobidder
|
||||||
|
|
||||||
|
# 2. Pull nuova versione
|
||||||
|
docker pull gitea.encke-hake.ts.net/alby96/autobidder:1.1.0
|
||||||
|
|
||||||
|
# 3. Aggiorna port mapping (5000:8080 invece di 5000:5000)
|
||||||
|
docker run -d \
|
||||||
|
--name autobidder \
|
||||||
|
-p 5000:8080 \
|
||||||
|
-v /data:/app/Data \
|
||||||
|
gitea.encke-hake.ts.net/alby96/autobidder:1.1.0
|
||||||
|
|
||||||
|
# 4. Verifica
|
||||||
|
docker logs -f autobidder
|
||||||
|
```
|
||||||
|
|
||||||
|
**[?? Note di Migrazione Complete](CHANGELOG.md#note-di-migrazione)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ??? Sviluppo
|
||||||
|
|
||||||
|
### Build Locale
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Restore dipendenze
|
||||||
|
dotnet restore
|
||||||
|
|
||||||
|
# Build
|
||||||
|
dotnet build
|
||||||
|
|
||||||
|
# Run
|
||||||
|
dotnet run
|
||||||
|
|
||||||
|
# Test
|
||||||
|
dotnet test
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build immagine
|
||||||
|
docker build -t autobidder:dev .
|
||||||
|
|
||||||
|
# Test locale
|
||||||
|
docker run -p 5000:8080 autobidder:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pubblicazione su Gitea
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Da Visual Studio
|
||||||
|
# Tasto destro ? Pubblica ? GiteaRegistry
|
||||||
|
|
||||||
|
# Da CLI
|
||||||
|
dotnet publish /p:PublishProfile=GiteaRegistry
|
||||||
|
```
|
||||||
|
|
||||||
|
### Incremento Versione
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Bug fix (1.1.0 ? 1.1.1)
|
||||||
|
.\bump-version.ps1 -Type patch
|
||||||
|
|
||||||
|
# Nuova feature (1.1.0 ? 1.2.0)
|
||||||
|
.\bump-version.ps1 -Type minor
|
||||||
|
|
||||||
|
# Breaking change (1.1.0 ? 2.0.0)
|
||||||
|
.\bump-version.ps1 -Type major
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ?? Contribuire
|
||||||
|
|
||||||
|
1. Fork del repository
|
||||||
|
2. Crea feature branch (`git checkout -b feature/amazing-feature`)
|
||||||
|
3. Commit modifiche (`git commit -m 'feat: add amazing feature'`)
|
||||||
|
4. Push al branch (`git push origin feature/amazing-feature`)
|
||||||
|
5. Apri Pull Request
|
||||||
|
|
||||||
|
**[?? Contribution Guidelines](CONTRIBUTING.md)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ?? License
|
||||||
|
|
||||||
|
Questo progetto è rilasciato sotto licenza MIT. Vedi [LICENSE](LICENSE) per dettagli.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ?? Supporto
|
||||||
|
|
||||||
|
- ?? [Segnala Bug](https://gitea.encke-hake.ts.net/Alby96/Mimante/issues)
|
||||||
|
- ?? [Richiedi Feature](https://gitea.encke-hake.ts.net/Alby96/Mimante/issues)
|
||||||
|
- ?? Email: [support@example.com](mailto:support@example.com)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ?? Ringraziamenti
|
||||||
|
|
||||||
|
- [.NET Team](https://dotnet.microsoft.com/) per .NET 8 e Blazor
|
||||||
|
- [PostgreSQL](https://www.postgresql.org/) per il database
|
||||||
|
- Community open source
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ?? Roadmap
|
||||||
|
|
||||||
|
### v1.2.0 (Q1 2025)
|
||||||
|
- [ ] Notifiche email per aste vinte
|
||||||
|
- [ ] Export statistiche CSV/Excel
|
||||||
|
- [ ] Dashboard mobile-responsive
|
||||||
|
|
||||||
|
### v1.3.0 (Q2 2025)
|
||||||
|
- [ ] API REST pubblica
|
||||||
|
- [ ] Integrazione webhook
|
||||||
|
- [ ] Multi-utente support
|
||||||
|
|
||||||
|
### v2.0.0 (Q3 2025)
|
||||||
|
- [ ] Architettura microservizi
|
||||||
|
- [ ] Supporto multi-piattaforma aste
|
||||||
|
- [ ] Machine learning per predizioni
|
||||||
|
|
||||||
|
**[?? Roadmap Completa](docs/ROADMAP.md)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
**Fatto con ?? usando .NET 8 e Blazor**
|
||||||
|
|
||||||
|
[?? Home](https://gitea.encke-hake.ts.net/Alby96/Mimante) •
|
||||||
|
[?? Docs](docs/) •
|
||||||
|
[?? Changelog](CHANGELOG.md) •
|
||||||
|
[?? Issues](https://gitea.encke-hake.ts.net/Alby96/Mimante/issues)
|
||||||
|
|
||||||
|
**? Se ti piace il progetto, lascia una stella!**
|
||||||
|
|
||||||
|
</div>
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using AutoBidder.Models;
|
using AutoBidder.Models;
|
||||||
|
using AutoBidder.Utilities;
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
@@ -52,6 +53,67 @@ namespace AutoBidder.Services
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ottiene riferimento diretto alla lista per lettura veloce (NO COPY).
|
||||||
|
/// ATTENZIONE: Non modificare la lista, usare solo per lettura!
|
||||||
|
/// </summary>
|
||||||
|
public List<AuctionInfo> GetAuctionsDirectRef()
|
||||||
|
{
|
||||||
|
return _auctions; // Accesso diretto senza lock per velocità
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ottiene riferimento diretto al log per lettura veloce (NO COPY).
|
||||||
|
/// </summary>
|
||||||
|
public List<LogEntry> GetLogDirectRef()
|
||||||
|
{
|
||||||
|
return _globalLog;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Imposta l'asta selezionata SENZA notificare eventi async.
|
||||||
|
/// Usare per risposta UI immediata.
|
||||||
|
/// </summary>
|
||||||
|
public void SetSelectedAuctionDirect(AuctionInfo? auction)
|
||||||
|
{
|
||||||
|
_selectedAuction = auction;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ottiene la lista originale delle aste per il salvataggio.
|
||||||
|
/// ATTENZIONE: Usare solo per persistenza, non per iterazione durante modifiche!
|
||||||
|
/// </summary>
|
||||||
|
public List<AuctionInfo> GetAuctionsForPersistence()
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
return _auctions;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Forza il salvataggio delle aste correnti su disco.
|
||||||
|
/// </summary>
|
||||||
|
public void PersistAuctions()
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
AutoBidder.Utilities.PersistenceManager.SaveAuctions(_auctions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ottiene l'asta modificabile per ID.
|
||||||
|
/// IMPORTANTE: Dopo modifiche, chiamare PersistAuctions() per salvare!
|
||||||
|
/// </summary>
|
||||||
|
public AuctionInfo? GetAuctionById(string auctionId)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
return _auctions.FirstOrDefault(a => a.AuctionId == auctionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public AuctionInfo? SelectedAuction
|
public AuctionInfo? SelectedAuction
|
||||||
{
|
{
|
||||||
get
|
get
|
||||||
@@ -112,6 +174,47 @@ namespace AutoBidder.Services
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === STATO AUCTION BROWSER ===
|
||||||
|
|
||||||
|
private int _browserCategoryIndex = 0;
|
||||||
|
private string _browserSearchQuery = "";
|
||||||
|
|
||||||
|
public int BrowserCategoryIndex
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
return _browserCategoryIndex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
set
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
_browserCategoryIndex = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string BrowserSearchQuery
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
return _browserSearchQuery;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
set
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
_browserSearchQuery = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// === METODI GESTIONE ASTE ===
|
// === METODI GESTIONE ASTE ===
|
||||||
|
|
||||||
public void SetAuctions(List<AuctionInfo> auctions)
|
public void SetAuctions(List<AuctionInfo> auctions)
|
||||||
@@ -200,15 +303,16 @@ namespace AutoBidder.Services
|
|||||||
{
|
{
|
||||||
_globalLog.Add(entry);
|
_globalLog.Add(entry);
|
||||||
|
|
||||||
// Mantieni solo gli ultimi 1000 log
|
// Mantieni solo gli ultimi 500 log (ridotto da 1000 per RAM)
|
||||||
if (_globalLog.Count > 1000)
|
if (_globalLog.Count > 500)
|
||||||
{
|
{
|
||||||
_globalLog.RemoveRange(0, _globalLog.Count - 1000);
|
_globalLog.RemoveRange(0, _globalLog.Count - 500);
|
||||||
|
_globalLog.TrimExcess();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = NotifyLogAddedAsync(message);
|
// RIMOSSO: NotifyStateChangedAsync qui causava troppi re-render
|
||||||
_ = NotifyStateChangedAsync();
|
// I log vengono visualizzati al prossimo refresh naturale
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ClearLog()
|
public void ClearLog()
|
||||||
@@ -314,6 +418,80 @@ namespace AutoBidder.Services
|
|||||||
{
|
{
|
||||||
_ = NotifyStateChangedAsync();
|
_ = NotifyStateChangedAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ???????????????????????????????????????????????????????????????????
|
||||||
|
// GESTIONE MEMORIA
|
||||||
|
// ???????????????????????????????????????????????????????????????????
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Compatta i dati di tutte le aste per ridurre il consumo RAM
|
||||||
|
/// </summary>
|
||||||
|
public void CompactAllAuctions()
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
foreach (var auction in _auctions)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
auction.CompactData();
|
||||||
|
}
|
||||||
|
catch { /* Ignora errori */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Console.WriteLine($"[AppState] Compattati dati di {_auctions.Count} aste");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pulisce i dati delle aste terminate dalla memoria
|
||||||
|
/// </summary>
|
||||||
|
public void CleanupCompletedAuctions()
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
foreach (var auction in _auctions.Where(a => !a.IsActive))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Per le aste terminate, mantieni solo dati essenziali
|
||||||
|
auction.CompactData(maxBidHistory: 20, maxRecentBids: 10, maxLogLines: 50);
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ritorna statistiche sull'uso della memoria
|
||||||
|
/// </summary>
|
||||||
|
public MemoryStats GetMemoryStats()
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
return new MemoryStats
|
||||||
|
{
|
||||||
|
AuctionsCount = _auctions.Count,
|
||||||
|
ActiveAuctionsCount = _auctions.Count(a => a.IsActive),
|
||||||
|
TotalBidHistoryEntries = _auctions.Sum(a => a.BidHistory?.Count ?? 0),
|
||||||
|
TotalRecentBidsEntries = _auctions.Sum(a => a.RecentBids?.Count ?? 0),
|
||||||
|
TotalLogEntries = _auctions.Sum(a => a.AuctionLog?.Count ?? 0),
|
||||||
|
GlobalLogEntries = _globalLog.Count
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Statistiche memoria per debug
|
||||||
|
/// </summary>
|
||||||
|
public class MemoryStats
|
||||||
|
{
|
||||||
|
public int AuctionsCount { get; set; }
|
||||||
|
public int ActiveAuctionsCount { get; set; }
|
||||||
|
public int TotalBidHistoryEntries { get; set; }
|
||||||
|
public int TotalRecentBidsEntries { get; set; }
|
||||||
|
public int TotalLogEntries { get; set; }
|
||||||
|
public int GlobalLogEntries { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
+832
-295
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,544 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using AutoBidder.Models;
|
||||||
|
using AutoBidder.Utilities;
|
||||||
|
|
||||||
|
namespace AutoBidder.Services
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Servizio per strategie avanzate di puntata.
|
||||||
|
/// Implementa: adaptive latency, jitter, dynamic offset, heat metric,
|
||||||
|
/// competition detection, soft retreat, probabilistic bidding, opponent profiling.
|
||||||
|
/// </summary>
|
||||||
|
public class BidStrategyService
|
||||||
|
{
|
||||||
|
private readonly Random _random = new();
|
||||||
|
private int _sessionTotalBids = 0;
|
||||||
|
private DateTime _sessionStartedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Aggiorna heat metric per un'asta
|
||||||
|
/// </summary>
|
||||||
|
public void UpdateHeatMetric(AuctionInfo auction, AppSettings settings, string currentUsername = "")
|
||||||
|
{
|
||||||
|
if (!settings.CompetitionDetectionEnabled) return;
|
||||||
|
|
||||||
|
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||||
|
var windowStart = now - settings.CompetitionWindowSeconds;
|
||||||
|
|
||||||
|
// Conta bidder unici nella finestra temporale (escludo me stesso)
|
||||||
|
var recentBids = auction.RecentBids
|
||||||
|
.Where(b => b.Timestamp >= windowStart)
|
||||||
|
.Where(b => !b.Username.Equals(currentUsername, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
auction.ActiveBiddersCount = recentBids
|
||||||
|
.Select(b => b.Username)
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Count();
|
||||||
|
|
||||||
|
// Conta collisioni (puntate nello stesso secondo)
|
||||||
|
var bidsBySecond = recentBids
|
||||||
|
.GroupBy(b => b.Timestamp)
|
||||||
|
.Where(g => g.Count() > 1)
|
||||||
|
.Count();
|
||||||
|
|
||||||
|
auction.CollisionCount = bidsBySecond;
|
||||||
|
|
||||||
|
// Calcola heat metric (0-100)
|
||||||
|
// Fattori: bidder attivi (40%), frequenza puntate (30%), collisioni (30%)
|
||||||
|
|
||||||
|
int bidderScore = Math.Min(auction.ActiveBiddersCount * 15, 40); // Max 40 punti
|
||||||
|
int frequencyScore = Math.Min(recentBids.Count * 3, 30); // Max 30 punti
|
||||||
|
int collisionScore = Math.Min(auction.CollisionCount * 10, 30); // Max 30 punti
|
||||||
|
|
||||||
|
auction.HeatMetric = bidderScore + frequencyScore + collisionScore;
|
||||||
|
|
||||||
|
// Identifica bidder aggressivi e situazioni di duello
|
||||||
|
if (settings.OpponentProfilingEnabled)
|
||||||
|
{
|
||||||
|
UpdateAggressiveBidders(auction, settings, currentUsername);
|
||||||
|
DetectDuelSituation(auction, settings, currentUsername);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Identifica e tracca bidder aggressivi (basato su ultime N puntate, esclude utente corrente)
|
||||||
|
/// </summary>
|
||||||
|
private void UpdateAggressiveBidders(AuctionInfo auction, AppSettings settings, string currentUsername)
|
||||||
|
{
|
||||||
|
// ?? FIX: Usa finestra scorrevole di ultime N puntate
|
||||||
|
var windowSize = settings.AggressiveBidderWindowSize > 0 ? settings.AggressiveBidderWindowSize : 30;
|
||||||
|
var recentWindow = auction.RecentBids
|
||||||
|
.Take(windowSize)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var bidCounts = recentWindow
|
||||||
|
.GroupBy(b => b.Username, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Select(g => new { Username = g.Key, Count = g.Count(), Percentage = (double)g.Count() / recentWindow.Count * 100 })
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
auction.AggressiveBidders.Clear();
|
||||||
|
|
||||||
|
foreach (var bidder in bidCounts)
|
||||||
|
{
|
||||||
|
// ?? FIX: NON aggiungere l'utente corrente come aggressivo!
|
||||||
|
if (bidder.Username.Equals(currentUsername, StringComparison.OrdinalIgnoreCase))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
// ?? FIX: Soglia più permissiva - usa percentuale invece di conteggio assoluto
|
||||||
|
// Un bidder è "aggressivo" se ha più del 40% delle puntate nella finestra (configurabile)
|
||||||
|
var percentageThreshold = settings.AggressiveBidderPercentageThreshold > 0 ? settings.AggressiveBidderPercentageThreshold : 40.0;
|
||||||
|
|
||||||
|
if (bidder.Percentage >= percentageThreshold || bidder.Count >= settings.AggressiveBidderThreshold)
|
||||||
|
{
|
||||||
|
auction.AggressiveBidders.Add(bidder.Username);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Rileva situazione di "duello" (solo 2 bidder attivi che si contendono l'asta)
|
||||||
|
/// In questa situazione bisogna essere pronti perché se uno si ritira l'altro vince
|
||||||
|
/// </summary>
|
||||||
|
private void DetectDuelSituation(AuctionInfo auction, AppSettings settings, string currentUsername)
|
||||||
|
{
|
||||||
|
var windowSize = settings.DuelDetectionWindowSize > 0 ? settings.DuelDetectionWindowSize : 20;
|
||||||
|
var recentWindow = auction.RecentBids.Take(windowSize).ToList();
|
||||||
|
|
||||||
|
if (recentWindow.Count < 6) // Serve un minimo di puntate per rilevare un pattern
|
||||||
|
{
|
||||||
|
auction.IsDuelSituation = false;
|
||||||
|
auction.DuelOpponent = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var bidders = recentWindow
|
||||||
|
.GroupBy(b => b.Username, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Select(g => new { Username = g.Key, Count = g.Count(), Percentage = (double)g.Count() / recentWindow.Count * 100 })
|
||||||
|
.OrderByDescending(b => b.Count)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// Duello: esattamente 2 bidder dominanti che coprono almeno l'80% delle puntate
|
||||||
|
if (bidders.Count >= 2)
|
||||||
|
{
|
||||||
|
var top2Percentage = bidders.Take(2).Sum(b => b.Percentage);
|
||||||
|
|
||||||
|
if (top2Percentage >= 80 && bidders.Count <= 3)
|
||||||
|
{
|
||||||
|
auction.IsDuelSituation = true;
|
||||||
|
|
||||||
|
// Trova l'avversario (chi NON sono io)
|
||||||
|
var opponent = bidders.FirstOrDefault(b =>
|
||||||
|
!b.Username.Equals(currentUsername, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
auction.DuelOpponent = opponent?.Username;
|
||||||
|
|
||||||
|
// Calcola chi sta dominando
|
||||||
|
var myStats = bidders.FirstOrDefault(b =>
|
||||||
|
b.Username.Equals(currentUsername, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
auction.DuelAdvantage = myStats != null && opponent != null
|
||||||
|
? myStats.Percentage - opponent.Percentage
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
auction.IsDuelSituation = false;
|
||||||
|
auction.DuelOpponent = null;
|
||||||
|
auction.DuelAdvantage = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
auction.IsDuelSituation = false;
|
||||||
|
auction.DuelOpponent = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifica se è il caso di puntare considerando tutte le strategie
|
||||||
|
/// </summary>
|
||||||
|
public BidDecision ShouldPlaceBid(AuctionInfo auction, AuctionState state, AppSettings settings, string currentUsername)
|
||||||
|
{
|
||||||
|
var decision = new BidDecision { ShouldBid = true };
|
||||||
|
|
||||||
|
// Se le strategie avanzate sono disabilitate per questa asta, salta tutto
|
||||||
|
if (auction.AdvancedStrategiesEnabled == false)
|
||||||
|
{
|
||||||
|
return decision;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ? RIMOSSO: Entry Point - Era sbagliato!
|
||||||
|
// I limiti MinPrice/MaxPrice impostati dall'utente sono RIGIDI.
|
||||||
|
// Se l'utente imposta MaxPrice=2€, vuole puntare FINO A 2€, non fino al 70%!
|
||||||
|
// I controlli MinPrice/MaxPrice sono già gestiti in AuctionMonitor.ShouldBid()
|
||||||
|
// L'Entry Point può essere usato SOLO per calcolare limiti CONSIGLIATI, non per bloccare.
|
||||||
|
|
||||||
|
// ?? 1. ANTI-BOT - Rileva pattern bot (timing identico)
|
||||||
|
if (settings.AntiBotDetectionEnabled && !string.IsNullOrEmpty(state.LastBidder))
|
||||||
|
{
|
||||||
|
var botCheck = DetectBotPattern(auction, state.LastBidder, currentUsername);
|
||||||
|
if (botCheck.IsBot)
|
||||||
|
{
|
||||||
|
decision.ShouldBid = false;
|
||||||
|
decision.Reason = $"Anti-bot: {state.LastBidder} pattern sospetto (var={botCheck.TimingVarianceMs:F0}ms)";
|
||||||
|
return decision;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ?? 2. USER EXHAUSTION - Sfrutta utenti stanchi (info solo, non blocca)
|
||||||
|
if (settings.UserExhaustionEnabled && !string.IsNullOrEmpty(state.LastBidder))
|
||||||
|
{
|
||||||
|
var exhaustionCheck = CheckUserExhaustion(auction, state.LastBidder, currentUsername);
|
||||||
|
// Non blocchiamo, ma potremmo loggare per info
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Verifica soft retreat
|
||||||
|
if (settings.SoftRetreatEnabled || (auction.SoftRetreatEnabledOverride ?? settings.SoftRetreatEnabled))
|
||||||
|
{
|
||||||
|
if (auction.IsInSoftRetreat)
|
||||||
|
{
|
||||||
|
var retreatEnd = auction.LastSoftRetreatAt?.AddSeconds(settings.SoftRetreatDurationSeconds);
|
||||||
|
if (retreatEnd > DateTime.UtcNow)
|
||||||
|
{
|
||||||
|
decision.ShouldBid = false;
|
||||||
|
decision.Reason = $"Soft retreat attivo (termina tra {(retreatEnd.Value - DateTime.UtcNow).TotalSeconds:F0}s)";
|
||||||
|
return decision;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Fine soft retreat
|
||||||
|
auction.IsInSoftRetreat = false;
|
||||||
|
auction.ConsecutiveCollisions = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verifica se attivare soft retreat
|
||||||
|
if (auction.ConsecutiveCollisions >= settings.SoftRetreatAfterCollisions)
|
||||||
|
{
|
||||||
|
auction.IsInSoftRetreat = true;
|
||||||
|
auction.LastSoftRetreatAt = DateTime.UtcNow;
|
||||||
|
decision.ShouldBid = false;
|
||||||
|
decision.Reason = $"Soft retreat attivato dopo {auction.ConsecutiveCollisions} collisioni";
|
||||||
|
return decision;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Verifica competition threshold
|
||||||
|
if (settings.CompetitionDetectionEnabled)
|
||||||
|
{
|
||||||
|
if (auction.ActiveBiddersCount >= settings.CompetitionThreshold)
|
||||||
|
{
|
||||||
|
// Controlla se l'ultimo bidder sono io - se sì, posso continuare
|
||||||
|
var lastBid = auction.RecentBids.OrderByDescending(b => b.Timestamp).FirstOrDefault();
|
||||||
|
if (lastBid != null && !lastBid.Username.Equals(currentUsername, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
if (settings.AutoPauseHotAuctions && auction.HeatMetric >= settings.HeatThresholdForPause)
|
||||||
|
{
|
||||||
|
decision.ShouldBid = false;
|
||||||
|
decision.Reason = $"Asta troppo calda (heat={auction.HeatMetric}%, bidder={auction.ActiveBiddersCount})";
|
||||||
|
return decision;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Verifica opponent profiling
|
||||||
|
if (settings.OpponentProfilingEnabled && auction.AggressiveBidders.Count > 0)
|
||||||
|
{
|
||||||
|
if (settings.AggressiveBidderAction == "Avoid")
|
||||||
|
{
|
||||||
|
decision.ShouldBid = false;
|
||||||
|
decision.Reason = $"Bidder aggressivi rilevati: {string.Join(", ", auction.AggressiveBidders.Take(3))}";
|
||||||
|
return decision;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Probabilistic bidding
|
||||||
|
if (settings.ProbabilisticBiddingEnabled)
|
||||||
|
{
|
||||||
|
var probability = CalculateBidProbability(auction, settings);
|
||||||
|
var roll = _random.NextDouble();
|
||||||
|
|
||||||
|
if (roll > probability)
|
||||||
|
{
|
||||||
|
decision.ShouldBid = false;
|
||||||
|
decision.Reason = $"Skip probabilistico (p={probability:P0}, roll={roll:P0})";
|
||||||
|
return decision;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Bankroll manager
|
||||||
|
if (settings.BankrollManagerEnabled)
|
||||||
|
{
|
||||||
|
var bankrollCheck = CheckBankrollLimits(auction, settings);
|
||||||
|
if (!bankrollCheck.CanBid)
|
||||||
|
{
|
||||||
|
decision.ShouldBid = false;
|
||||||
|
decision.Reason = bankrollCheck.Reason;
|
||||||
|
return decision;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ? RIMOSSO: DetectLastSecondSniper - causava falsi positivi
|
||||||
|
// In un duello, TUTTI i bidder hanno pattern regolari (ogni reset del timer)
|
||||||
|
// Questa strategia bloccava puntate legittime e faceva perdere aste
|
||||||
|
|
||||||
|
// ?? 7. STRATEGIA: Price Momentum (con soglia più alta)
|
||||||
|
// Se il prezzo sta salendo TROPPO velocemente, pausa
|
||||||
|
var priceVelocity = CalculatePriceVelocity(auction);
|
||||||
|
if (priceVelocity > 0.10) // +10 centesimi/secondo = MOLTO veloce
|
||||||
|
{
|
||||||
|
decision.ShouldBid = false;
|
||||||
|
decision.Reason = $"Prezzo sale troppo veloce ({priceVelocity:F3}€/s)";
|
||||||
|
return decision;
|
||||||
|
}
|
||||||
|
|
||||||
|
return decision;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Calcola la velocità di crescita del prezzo (€/secondo)
|
||||||
|
/// </summary>
|
||||||
|
private double CalculatePriceVelocity(AuctionInfo auction)
|
||||||
|
{
|
||||||
|
if (auction.RecentBids.Count < 5) return 0;
|
||||||
|
|
||||||
|
var recentBids = auction.RecentBids.Take(10).ToList();
|
||||||
|
if (recentBids.Count < 2) return 0;
|
||||||
|
|
||||||
|
var first = recentBids.Last();
|
||||||
|
var last = recentBids.First();
|
||||||
|
|
||||||
|
var timeDiffSeconds = last.Timestamp - first.Timestamp;
|
||||||
|
if (timeDiffSeconds <= 0) return 0;
|
||||||
|
|
||||||
|
var priceDiff = last.Price - first.Price;
|
||||||
|
return (double)priceDiff / timeDiffSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Rileva pattern bot analizzando i delta timing degli ultimi bid
|
||||||
|
/// </summary>
|
||||||
|
private (bool IsBot, double TimingVarianceMs) DetectBotPattern(AuctionInfo auction, string? lastBidder, string currentUsername)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(lastBidder) || lastBidder.Equals(currentUsername, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return (false, 999);
|
||||||
|
|
||||||
|
// Ottieni gli ultimi 3+ bid di questo utente
|
||||||
|
var userBids = auction.RecentBids
|
||||||
|
.Where(b => b.Username.Equals(lastBidder, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.OrderByDescending(b => b.Timestamp)
|
||||||
|
.Take(4)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (userBids.Count < 3)
|
||||||
|
return (false, 999);
|
||||||
|
|
||||||
|
// Calcola i delta tra bid consecutivi
|
||||||
|
var deltas = new List<long>();
|
||||||
|
for (int i = 0; i < userBids.Count - 1; i++)
|
||||||
|
{
|
||||||
|
deltas.Add(userBids[i].Timestamp - userBids[i + 1].Timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deltas.Count < 2)
|
||||||
|
return (false, 999);
|
||||||
|
|
||||||
|
// Calcola varianza dei delta
|
||||||
|
var avg = deltas.Average();
|
||||||
|
var variance = deltas.Sum(d => Math.Pow(d - avg, 2)) / deltas.Count;
|
||||||
|
var stdDev = Math.Sqrt(variance) * 1000; // Converti in ms
|
||||||
|
|
||||||
|
// Se la varianza è < 50ms, probabilmente è un bot
|
||||||
|
return (stdDev < 50, stdDev);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifica se un utente è esausto (molte puntate, può mollare)
|
||||||
|
/// </summary>
|
||||||
|
private (bool ShouldExploit, string Reason) CheckUserExhaustion(AuctionInfo auction, string? lastBidder, string currentUsername)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(lastBidder) || lastBidder.Equals(currentUsername, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return (false, "");
|
||||||
|
|
||||||
|
// Verifica se l'utente è un "heavy user" (>50 puntate totali)
|
||||||
|
if (auction.BidderStats.TryGetValue(lastBidder, out var stats))
|
||||||
|
{
|
||||||
|
if (stats.BidCount > 50)
|
||||||
|
{
|
||||||
|
// Se ci sono pochi altri bidder attivi, può essere un buon momento
|
||||||
|
var activeBidders = auction.BidderStats.Values.Count(b => b.BidCount > 5);
|
||||||
|
if (activeBidders <= 3)
|
||||||
|
{
|
||||||
|
return (true, $"{lastBidder} ha {stats.BidCount} puntate, potrebbe mollare");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (false, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Calcola probabilità di puntata basata su competizione e ROI
|
||||||
|
/// </summary>
|
||||||
|
private double CalculateBidProbability(AuctionInfo auction, AppSettings settings)
|
||||||
|
{
|
||||||
|
var probability = settings.BaseBidProbability;
|
||||||
|
|
||||||
|
// Riduci probabilità per ogni bidder attivo oltre la soglia
|
||||||
|
var extraBidders = Math.Max(0, auction.ActiveBiddersCount - settings.CompetitionThreshold);
|
||||||
|
probability -= extraBidders * settings.ProbabilityReductionPerBidder;
|
||||||
|
|
||||||
|
// Riduci per heat metric alto
|
||||||
|
if (auction.HeatMetric > 70)
|
||||||
|
{
|
||||||
|
probability -= 0.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aumenta se abbiamo un buon ROI potenziale
|
||||||
|
if (auction.CalculatedValue?.Savings > 0)
|
||||||
|
{
|
||||||
|
probability += 0.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.Clamp(probability, 0.1, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifica limiti bankroll
|
||||||
|
/// </summary>
|
||||||
|
private BankrollCheckResult CheckBankrollLimits(AuctionInfo auction, AppSettings settings)
|
||||||
|
{
|
||||||
|
var result = new BankrollCheckResult { CanBid = true };
|
||||||
|
|
||||||
|
// Limite puntate per asta
|
||||||
|
var maxPerAuction = auction.MaxBidsOverride ?? settings.MaxBidsPerAuction;
|
||||||
|
if (maxPerAuction > 0 && auction.SessionBidCount >= maxPerAuction)
|
||||||
|
{
|
||||||
|
result.CanBid = false;
|
||||||
|
result.Reason = $"Limite puntate per asta raggiunto ({auction.SessionBidCount}/{maxPerAuction})";
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limite puntate per sessione
|
||||||
|
if (settings.MaxBidsPerSession > 0 && _sessionTotalBids >= settings.MaxBidsPerSession)
|
||||||
|
{
|
||||||
|
result.CanBid = false;
|
||||||
|
result.Reason = $"Limite puntate per sessione raggiunto ({_sessionTotalBids}/{settings.MaxBidsPerSession})";
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Budget giornaliero
|
||||||
|
if (settings.DailyBudgetEuro > 0)
|
||||||
|
{
|
||||||
|
var spent = _sessionTotalBids * settings.AverageBidCostEuro;
|
||||||
|
if (spent >= settings.DailyBudgetEuro)
|
||||||
|
{
|
||||||
|
result.CanBid = false;
|
||||||
|
result.Reason = $"Budget giornaliero esaurito (€{spent:F2}/€{settings.DailyBudgetEuro:F2})";
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registra una puntata effettuata (per tracking)
|
||||||
|
/// </summary>
|
||||||
|
public void RecordBidAttempt(AuctionInfo auction, bool success, bool collision = false)
|
||||||
|
{
|
||||||
|
auction.SessionBidCount++;
|
||||||
|
_sessionTotalBids++;
|
||||||
|
|
||||||
|
if (success)
|
||||||
|
{
|
||||||
|
auction.SuccessfulBidCount++;
|
||||||
|
auction.ConsecutiveCollisions = 0;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
auction.FailedBidCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (collision)
|
||||||
|
{
|
||||||
|
auction.CollisionCount++;
|
||||||
|
auction.ConsecutiveCollisions++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registra un timer scaduto
|
||||||
|
/// </summary>
|
||||||
|
public void RecordTimerExpired(AuctionInfo auction)
|
||||||
|
{
|
||||||
|
auction.TimerExpiredCount++;
|
||||||
|
auction.ConsecutiveCollisions++; // Conta come "mancato"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reset contatori sessione
|
||||||
|
/// </summary>
|
||||||
|
public void ResetSession()
|
||||||
|
{
|
||||||
|
_sessionTotalBids = 0;
|
||||||
|
_sessionStartedAt = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ottiene statistiche sessione corrente
|
||||||
|
/// </summary>
|
||||||
|
public SessionStats GetSessionStats()
|
||||||
|
{
|
||||||
|
return new SessionStats
|
||||||
|
{
|
||||||
|
TotalBids = _sessionTotalBids,
|
||||||
|
SessionDuration = DateTime.UtcNow - _sessionStartedAt
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Risultato calcolo timing puntata
|
||||||
|
/// </summary>
|
||||||
|
public class BidTimingResult
|
||||||
|
{
|
||||||
|
public int BaseOffsetMs { get; set; }
|
||||||
|
public int LatencyCompensationMs { get; set; }
|
||||||
|
public int DynamicAdjustmentMs { get; set; }
|
||||||
|
public int JitterMs { get; set; }
|
||||||
|
public int FinalOffsetMs { get; set; }
|
||||||
|
public bool ShouldBid { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Decisione se puntare
|
||||||
|
/// </summary>
|
||||||
|
public class BidDecision
|
||||||
|
{
|
||||||
|
public bool ShouldBid { get; set; }
|
||||||
|
public string? Reason { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Risultato verifica bankroll
|
||||||
|
/// </summary>
|
||||||
|
public class BankrollCheckResult
|
||||||
|
{
|
||||||
|
public bool CanBid { get; set; }
|
||||||
|
public string? Reason { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Statistiche sessione
|
||||||
|
/// </summary>
|
||||||
|
public class SessionStats
|
||||||
|
{
|
||||||
|
public int TotalBids { get; set; }
|
||||||
|
public TimeSpan SessionDuration { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,742 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using AutoBidder.Models;
|
||||||
|
|
||||||
|
namespace AutoBidder.Services
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Servizio per navigare le aste pubbliche di Bidoo senza autenticazione
|
||||||
|
/// Permette di esplorare le categorie e visualizzare le aste disponibili
|
||||||
|
/// </summary>
|
||||||
|
public class BidooBrowserService
|
||||||
|
{
|
||||||
|
private readonly HttpClient _httpClient;
|
||||||
|
private readonly List<BidooCategoryInfo> _cachedCategories = new();
|
||||||
|
private DateTime _categoriesCachedAt = DateTime.MinValue;
|
||||||
|
private readonly TimeSpan _categoryCacheExpiry = TimeSpan.FromMinutes(30);
|
||||||
|
|
||||||
|
public BidooBrowserService()
|
||||||
|
{
|
||||||
|
var handler = new HttpClientHandler
|
||||||
|
{
|
||||||
|
UseCookies = false,
|
||||||
|
AutomaticDecompression = System.Net.DecompressionMethods.All
|
||||||
|
};
|
||||||
|
|
||||||
|
_httpClient = new HttpClient(handler)
|
||||||
|
{
|
||||||
|
Timeout = TimeSpan.FromSeconds(15)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Aggiunge headers browser-like per evitare blocchi
|
||||||
|
/// </summary>
|
||||||
|
private void AddBrowserHeaders(HttpRequestMessage request, string? referer = null)
|
||||||
|
{
|
||||||
|
request.Headers.Add("User-Agent",
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36");
|
||||||
|
request.Headers.Add("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8");
|
||||||
|
request.Headers.Add("Accept-Language", "it-IT,it;q=0.9,en-US;q=0.8,en;q=0.7");
|
||||||
|
request.Headers.Add("Accept-Encoding", "gzip, deflate, br");
|
||||||
|
request.Headers.Add("Cache-Control", "no-cache");
|
||||||
|
request.Headers.Add("Pragma", "no-cache");
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(referer))
|
||||||
|
{
|
||||||
|
request.Headers.Add("Referer", referer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ottiene la lista delle categorie disponibili (con cache)
|
||||||
|
/// </summary>
|
||||||
|
public async Task<List<BidooCategoryInfo>> GetCategoriesAsync(bool forceRefresh = false, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
// Controlla cache
|
||||||
|
if (!forceRefresh && _cachedCategories.Count > 0 && DateTime.UtcNow - _categoriesCachedAt < _categoryCacheExpiry)
|
||||||
|
{
|
||||||
|
return _cachedCategories.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
var categories = new List<BidooCategoryInfo>();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Get, "https://it.bidoo.com/");
|
||||||
|
AddBrowserHeaders(request);
|
||||||
|
|
||||||
|
var response = await _httpClient.SendAsync(request, cancellationToken);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var html = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
|
||||||
|
// Aggiungi categorie speciali prima
|
||||||
|
categories.Add(new BidooCategoryInfo { TabId = 3, TagId = 0, DisplayName = "Tutte le aste", Slug = "", IsSpecialCategory = true, Icon = "bi-grid-3x3-gap" });
|
||||||
|
categories.Add(new BidooCategoryInfo { TabId = 1, TagId = 0, DisplayName = "Aste di Puntate", Slug = "", IsSpecialCategory = true, Icon = "bi-coin" });
|
||||||
|
categories.Add(new BidooCategoryInfo { TabId = 5, TagId = 0, DisplayName = "Aste Manuali", Slug = "", IsSpecialCategory = true, Icon = "bi-hand-index" });
|
||||||
|
|
||||||
|
// Parse categorie dal CategoryMenu
|
||||||
|
// Pattern: javascript:selectBids(4, true, false, 6); con data-tag="6" e testo "Buoni"
|
||||||
|
var categoryPattern = new Regex(
|
||||||
|
@"<a\s+href=""\s*javascript:selectBids\(4,\s*true,\s*false,\s*(\d+)\);\s*""\s+data-tab=""4""\s+data-slug=""([^""]*)""\s+data-tag=""(\d+)""><span[^>]*>([^<]+)</span></a>",
|
||||||
|
RegexOptions.IgnoreCase | RegexOptions.Singleline);
|
||||||
|
|
||||||
|
var matches = categoryPattern.Matches(html);
|
||||||
|
foreach (Match match in matches)
|
||||||
|
{
|
||||||
|
if (match.Success && match.Groups.Count >= 5)
|
||||||
|
{
|
||||||
|
int.TryParse(match.Groups[1].Value, out int tagId1);
|
||||||
|
var slug = match.Groups[2].Value.Trim();
|
||||||
|
int.TryParse(match.Groups[3].Value, out int tagId2);
|
||||||
|
var name = match.Groups[4].Value.Trim();
|
||||||
|
|
||||||
|
// Usa tagId1 o tagId2 (dovrebbero essere uguali)
|
||||||
|
var tagId = tagId1 > 0 ? tagId1 : tagId2;
|
||||||
|
|
||||||
|
if (tagId > 0 && !string.IsNullOrWhiteSpace(name))
|
||||||
|
{
|
||||||
|
categories.Add(new BidooCategoryInfo
|
||||||
|
{
|
||||||
|
TabId = 4,
|
||||||
|
TagId = tagId,
|
||||||
|
Slug = slug,
|
||||||
|
DisplayName = name,
|
||||||
|
IsSpecialCategory = false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se non abbiamo trovato categorie dal parsing, usa lista predefinita
|
||||||
|
if (categories.Count <= 3)
|
||||||
|
{
|
||||||
|
categories.AddRange(GetDefaultCategories());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aggiorna cache
|
||||||
|
_cachedCategories.Clear();
|
||||||
|
_cachedCategories.AddRange(categories);
|
||||||
|
_categoriesCachedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
|
Console.WriteLine($"[BidooBrowser] Caricate {categories.Count} categorie");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[BidooBrowser] Errore caricamento categorie: {ex.Message}");
|
||||||
|
|
||||||
|
// Fallback a categorie predefinite
|
||||||
|
if (_cachedCategories.Count == 0)
|
||||||
|
{
|
||||||
|
categories.AddRange(GetDefaultCategories());
|
||||||
|
_cachedCategories.AddRange(categories);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return _cachedCategories.ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return categories;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Categorie predefinite come fallback
|
||||||
|
/// </summary>
|
||||||
|
private static List<BidooCategoryInfo> GetDefaultCategories()
|
||||||
|
{
|
||||||
|
return new List<BidooCategoryInfo>
|
||||||
|
{
|
||||||
|
new() { TabId = 4, TagId = 6, DisplayName = "Buoni", Slug = "buoni" },
|
||||||
|
new() { TabId = 4, TagId = 5, DisplayName = "Smartphone", Slug = "smartphone" },
|
||||||
|
new() { TabId = 4, TagId = 7, DisplayName = "Apple", Slug = "apple" },
|
||||||
|
new() { TabId = 4, TagId = 13, DisplayName = "Bellezza", Slug = "bellezza" },
|
||||||
|
new() { TabId = 4, TagId = 8, DisplayName = "Cucina", Slug = "cucina" },
|
||||||
|
new() { TabId = 4, TagId = 18, DisplayName = "Casa & Giardino", Slug = "casa_e_giardino" },
|
||||||
|
new() { TabId = 4, TagId = 11, DisplayName = "Elettrodomestici", Slug = "elettrodomestici" },
|
||||||
|
new() { TabId = 4, TagId = 9, DisplayName = "Videogame", Slug = "videogame" },
|
||||||
|
new() { TabId = 4, TagId = 41, DisplayName = "Giocattoli", Slug = "giocattoli" },
|
||||||
|
new() { TabId = 4, TagId = 14, DisplayName = "Tablet e PC", Slug = "tablet-e-pc" },
|
||||||
|
new() { TabId = 4, TagId = 20, DisplayName = "Hobby", Slug = "hobby" },
|
||||||
|
new() { TabId = 4, TagId = 22, DisplayName = "Smartwatch", Slug = "smartwatch" },
|
||||||
|
new() { TabId = 4, TagId = 37, DisplayName = "Animali Domestici", Slug = "animali_domestici" },
|
||||||
|
new() { TabId = 4, TagId = 12, DisplayName = "Moda", Slug = "moda" },
|
||||||
|
new() { TabId = 4, TagId = 10, DisplayName = "Smart TV", Slug = "smart-tv" },
|
||||||
|
new() { TabId = 4, TagId = 21, DisplayName = "Fai da Te", Slug = "fai_da_te" },
|
||||||
|
new() { TabId = 4, TagId = 26, DisplayName = "Luxury", Slug = "luxury" },
|
||||||
|
new() { TabId = 4, TagId = 19, DisplayName = "Cuffie e Audio", Slug = "cuffie-e-audio" },
|
||||||
|
new() { TabId = 4, TagId = 23, DisplayName = "Back to school", Slug = "back-to-school" },
|
||||||
|
new() { TabId = 4, TagId = 38, DisplayName = "Prima Infanzia", Slug = "prima-infanzia" }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ottiene le aste di una categoria specifica
|
||||||
|
/// Bidoo usa un sistema AJAX per caricare le aste dinamicamente
|
||||||
|
/// </summary>
|
||||||
|
public async Task<List<BidooBrowserAuction>> GetAuctionsAsync(
|
||||||
|
BidooCategoryInfo category,
|
||||||
|
int page = 0,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var auctions = new List<BidooBrowserAuction>();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Bidoo carica le aste tramite chiamata AJAX a index.php con parametri POST-like in query string
|
||||||
|
// Il pattern è: index.php?selectBids=1&tab=X&tag=Y&offset=Z
|
||||||
|
string url;
|
||||||
|
|
||||||
|
if (category.IsSpecialCategory)
|
||||||
|
{
|
||||||
|
// Categorie speciali: BIDS (1), ALL (3), MANUAL (5)
|
||||||
|
var tabValue = category.TabId;
|
||||||
|
url = $"https://it.bidoo.com/index.php?selectBids=1&tab={tabValue}&tag=0&offset={page * 20}";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Categorie normali: tab=4 + tag specifico
|
||||||
|
url = $"https://it.bidoo.com/index.php?selectBids=1&tab=4&tag={category.TagId}&offset={page * 20}";
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine($"[BidooBrowser] Fetching category '{category.DisplayName}': {url}");
|
||||||
|
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||||
|
AddBrowserHeaders(request, "https://it.bidoo.com/");
|
||||||
|
request.Headers.Add("X-Requested-With", "XMLHttpRequest");
|
||||||
|
|
||||||
|
var response = await _httpClient.SendAsync(request, cancellationToken);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var html = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
|
||||||
|
// Parse aste dall'HTML (fragment AJAX)
|
||||||
|
auctions = ParseAuctionsFromHtml(html);
|
||||||
|
|
||||||
|
Console.WriteLine($"[BidooBrowser] Trovate {auctions.Count} aste nella categoria {category.DisplayName}");
|
||||||
|
|
||||||
|
// ?? DEBUG: Verifica quante aste hanno IsCreditAuction = true
|
||||||
|
if (category.IsSpecialCategory && category.TabId == 1)
|
||||||
|
{
|
||||||
|
var creditCount = auctions.Count(a => a.IsCreditAuction);
|
||||||
|
Console.WriteLine($"[BidooBrowser] DEBUG Aste di Puntate: {creditCount}/{auctions.Count} hanno IsCreditAuction=true");
|
||||||
|
|
||||||
|
// Log primi 3 nomi per debug
|
||||||
|
foreach (var a in auctions.Take(3))
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[BidooBrowser] - {a.Name} (ID: {a.AuctionId}, IsCreditAuction: {a.IsCreditAuction})");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[BidooBrowser] Errore caricamento aste: {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return auctions;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetTabName(int tabId)
|
||||||
|
{
|
||||||
|
return tabId switch
|
||||||
|
{
|
||||||
|
1 => "BIDS",
|
||||||
|
2 => "FAV",
|
||||||
|
3 => "ALL",
|
||||||
|
5 => "MANUAL",
|
||||||
|
_ => "ALL"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parsa le aste dall'HTML della pagina
|
||||||
|
/// </summary>
|
||||||
|
private List<BidooBrowserAuction> ParseAuctionsFromHtml(string html)
|
||||||
|
{
|
||||||
|
var auctions = new List<BidooBrowserAuction>();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Pattern per estrarre i div delle aste
|
||||||
|
// <div id="divAsta85584421" class="..." data-id="85584421" data-url="27_Puntate_85584421" data-freq="8" ...>
|
||||||
|
var auctionDivPattern = new Regex(
|
||||||
|
@"<div\s+id=""divAsta(\d+)""[^>]*" +
|
||||||
|
@"data-id=""(\d+)""[^>]*" +
|
||||||
|
@"data-url=""([^""]+)""[^>]*" +
|
||||||
|
@"data-freq=""(\d+)""[^>]*" +
|
||||||
|
@"(?:data-credit=""(\d+)"")?[^>]*" +
|
||||||
|
@"(?:data-credit-value=""(\d+)"")?[^>]*" +
|
||||||
|
@"(?:data-id-product=""(\d+)"")?",
|
||||||
|
RegexOptions.IgnoreCase | RegexOptions.Singleline);
|
||||||
|
|
||||||
|
// Pattern alternativo più semplice per catturare attributi
|
||||||
|
var simplePattern = new Regex(
|
||||||
|
@"<div[^>]+id=""divAsta(\d+)""[^>]*>",
|
||||||
|
RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
|
var divMatches = simplePattern.Matches(html);
|
||||||
|
|
||||||
|
foreach (Match divMatch in divMatches)
|
||||||
|
{
|
||||||
|
if (!divMatch.Success) continue;
|
||||||
|
|
||||||
|
var auctionId = divMatch.Groups[1].Value;
|
||||||
|
|
||||||
|
// Trova il blocco completo dell'asta
|
||||||
|
var startIndex = divMatch.Index;
|
||||||
|
var endPattern = @"<!--/ \.bid -->";
|
||||||
|
var endIndex = html.IndexOf(endPattern, startIndex);
|
||||||
|
if (endIndex < 0) endIndex = html.IndexOf("</div><!--", startIndex + 1000);
|
||||||
|
if (endIndex < 0) continue;
|
||||||
|
|
||||||
|
var auctionHtml = html.Substring(startIndex, Math.Min(endIndex - startIndex + 100, html.Length - startIndex));
|
||||||
|
|
||||||
|
var auction = ParseSingleAuction(auctionId, auctionHtml);
|
||||||
|
if (auction != null)
|
||||||
|
{
|
||||||
|
auctions.Add(auction);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[BidooBrowser] Errore parsing HTML: {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return auctions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parsa una singola asta dal suo blocco HTML
|
||||||
|
/// </summary>
|
||||||
|
private BidooBrowserAuction? ParseSingleAuction(string auctionId, string html)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var auction = new BidooBrowserAuction { AuctionId = auctionId };
|
||||||
|
|
||||||
|
// Estrai data-url
|
||||||
|
var urlMatch = Regex.Match(html, @"data-url=""([^""]+)""");
|
||||||
|
if (urlMatch.Success)
|
||||||
|
{
|
||||||
|
auction.Url = $"https://it.bidoo.com/auction.php?a={urlMatch.Groups[1].Value}";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Estrai data-freq
|
||||||
|
var freqMatch = Regex.Match(html, @"data-freq=""(\d+)""");
|
||||||
|
if (freqMatch.Success && int.TryParse(freqMatch.Groups[1].Value, out int freq))
|
||||||
|
{
|
||||||
|
auction.TimerFrequency = freq;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Estrai data-credit e data-credit-value
|
||||||
|
var creditMatch = Regex.Match(html, @"data-credit=""(\d+)""");
|
||||||
|
if (creditMatch.Success && creditMatch.Groups[1].Value == "1")
|
||||||
|
{
|
||||||
|
auction.IsCreditAuction = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var creditValueMatch = Regex.Match(html, @"data-credit-value=""(\d+)""");
|
||||||
|
if (creditValueMatch.Success && int.TryParse(creditValueMatch.Groups[1].Value, out int creditVal))
|
||||||
|
{
|
||||||
|
auction.CreditValue = creditVal;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Estrai data-id-product
|
||||||
|
var productMatch = Regex.Match(html, @"data-id-product=""(\d+)""");
|
||||||
|
if (productMatch.Success && int.TryParse(productMatch.Groups[1].Value, out int productId))
|
||||||
|
{
|
||||||
|
auction.ProductId = productId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Estrai immagine
|
||||||
|
var imgMatch = Regex.Match(html, @"<img[^>]+class=""img_small[^""]*""[^>]+src=""([^""]+)""");
|
||||||
|
if (imgMatch.Success)
|
||||||
|
{
|
||||||
|
auction.ImageUrl = imgMatch.Groups[1].Value;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Pattern alternativo
|
||||||
|
imgMatch = Regex.Match(html, @"src=""(https://[^""]+/products/[^""]+)""");
|
||||||
|
if (imgMatch.Success)
|
||||||
|
{
|
||||||
|
auction.ImageUrl = imgMatch.Groups[1].Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Estrai nome prodotto
|
||||||
|
var nameMatch = Regex.Match(html, @"<a[^>]+class=""name[^""]*""[^>]*>([^<]+)</a>", RegexOptions.IgnoreCase);
|
||||||
|
if (nameMatch.Success)
|
||||||
|
{
|
||||||
|
var name = System.Net.WebUtility.HtmlDecode(nameMatch.Groups[1].Value.Trim());
|
||||||
|
// ?? FIX: Sostituisci entità HTML non standard con +
|
||||||
|
name = name
|
||||||
|
.Replace("+", "+")
|
||||||
|
.Replace("&plus;", "+")
|
||||||
|
.Replace("&", "&"); // Decodifica & residui
|
||||||
|
auction.Name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Estrai prezzo compralo subito
|
||||||
|
var buyNowMatch = Regex.Match(html, @"buy-rapid-now[^>]*>[^<]*<i[^>]*></i>\s*([0-9,\.]+)\s*€", RegexOptions.IgnoreCase);
|
||||||
|
if (buyNowMatch.Success)
|
||||||
|
{
|
||||||
|
var priceStr = buyNowMatch.Groups[1].Value.Replace(",", ".").Trim();
|
||||||
|
if (decimal.TryParse(priceStr, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out decimal buyNow))
|
||||||
|
{
|
||||||
|
auction.BuyNowPrice = buyNow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Controlla se è manuale (bi-noauto)
|
||||||
|
auction.IsManualOnly = html.Contains("bi-noauto", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
// Prezzo e bidder verranno aggiornati dalla chiamata a data.php
|
||||||
|
auction.CurrentPrice = 0.01m;
|
||||||
|
auction.LastBidder = "";
|
||||||
|
auction.RemainingSeconds = auction.TimerFrequency;
|
||||||
|
|
||||||
|
return auction;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[BidooBrowser] Errore parsing asta {auctionId}: {ex.Message}");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Aggiorna lo stato delle aste usando data.php con LISTID (polling multiplo)
|
||||||
|
/// Formato chiamata: data.php?LISTID=id1,id2,id3&chk=timestamp
|
||||||
|
/// Formato risposta: timestamp*(id;status;expiry;price;bidder;timer;countdown#id2;...)
|
||||||
|
/// </summary>
|
||||||
|
public async Task UpdateAuctionStatesAsync(List<BidooBrowserAuction> auctions, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (auctions.Count == 0) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Costruisci la lista di ID per il polling (formato LISTID)
|
||||||
|
var auctionIds = string.Join(",", auctions.Select(a => a.AuctionId));
|
||||||
|
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||||
|
var url = $"https://it.bidoo.com/data.php?LISTID={auctionIds}&chk={timestamp}";
|
||||||
|
|
||||||
|
Console.WriteLine($"[BidooBrowser] Polling {auctions.Count} aste...");
|
||||||
|
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||||
|
AddBrowserHeaders(request, "https://it.bidoo.com/");
|
||||||
|
request.Headers.Add("X-Requested-With", "XMLHttpRequest");
|
||||||
|
|
||||||
|
var response = await _httpClient.SendAsync(request, cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[BidooBrowser] Polling fallito: {response.StatusCode}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var responseText = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
|
||||||
|
// Parse risposta formato LISTID
|
||||||
|
ParseListIdResponse(responseText, auctions);
|
||||||
|
|
||||||
|
foreach (var auction in auctions)
|
||||||
|
{
|
||||||
|
auction.LastUpdated = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[BidooBrowser] Errore aggiornamento stati: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parsa la risposta di data.php formato LISTID
|
||||||
|
/// Formato: serverTimestamp*(id;status;expiry;price;;#id2;status2;...)
|
||||||
|
/// Esempio: 1769073106*(85559629;ON;1769082240;1;;#85559630;ON;1769082240;1;;)
|
||||||
|
/// Il timestamp del server viene usato come riferimento per calcolare il tempo rimanente
|
||||||
|
/// </summary>
|
||||||
|
private void ParseListIdResponse(string response, List<BidooBrowserAuction> auctions)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Trova inizio dati dopo timestamp*
|
||||||
|
var starIndex = response.IndexOf('*');
|
||||||
|
if (starIndex == -1)
|
||||||
|
{
|
||||||
|
Console.WriteLine("[BidooBrowser] Risposta non valida: manca '*'");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Estrai il timestamp del server (prima di *)
|
||||||
|
var serverTimestampStr = response.Substring(0, starIndex);
|
||||||
|
long serverTimestamp = 0;
|
||||||
|
long.TryParse(serverTimestampStr, out serverTimestamp);
|
||||||
|
|
||||||
|
var mainData = response.Substring(starIndex + 1);
|
||||||
|
|
||||||
|
// Rimuovi parentesi se presenti
|
||||||
|
if (mainData.StartsWith("(") && mainData.EndsWith(")"))
|
||||||
|
{
|
||||||
|
mainData = mainData.Substring(1, mainData.Length - 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split per ogni asta (separatore #)
|
||||||
|
var auctionEntries = mainData.Split('#', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
int updatedCount = 0;
|
||||||
|
|
||||||
|
foreach (var entry in auctionEntries)
|
||||||
|
{
|
||||||
|
// Formato: id;status;expiry;price;; (bidder e timer possono essere vuoti)
|
||||||
|
var fields = entry.Split(';');
|
||||||
|
if (fields.Length < 4) continue;
|
||||||
|
|
||||||
|
var id = fields[0].Trim();
|
||||||
|
var status = fields[1].Trim(); // ON/OFF
|
||||||
|
var expiryStr = fields[2].Trim(); // timestamp scadenza (stesso formato del server)
|
||||||
|
var priceStr = fields[3].Trim(); // prezzo (centesimi)
|
||||||
|
var bidder = fields.Length > 4 ? fields[4].Trim() : ""; // ultimo bidder (può essere vuoto)
|
||||||
|
|
||||||
|
var auction = auctions.FirstOrDefault(a => a.AuctionId == id);
|
||||||
|
if (auction == null) continue;
|
||||||
|
|
||||||
|
// Aggiorna prezzo (è in centesimi, convertire in euro)
|
||||||
|
if (int.TryParse(priceStr, out int priceCents))
|
||||||
|
{
|
||||||
|
auction.CurrentPrice = priceCents / 100m;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aggiorna bidder solo se non vuoto
|
||||||
|
if (!string.IsNullOrEmpty(bidder))
|
||||||
|
{
|
||||||
|
auction.LastBidder = bidder;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calcola tempo rimanente usando il timestamp del server come riferimento
|
||||||
|
if (long.TryParse(expiryStr, out long expiryTimestamp) && serverTimestamp > 0)
|
||||||
|
{
|
||||||
|
// Il tempo rimanente è: expiry - serverTime (entrambi nello stesso formato)
|
||||||
|
var remainingSeconds = expiryTimestamp - serverTimestamp;
|
||||||
|
auction.RemainingSeconds = remainingSeconds > 0 ? (int)remainingSeconds : 0;
|
||||||
|
}
|
||||||
|
else if (status == "ON")
|
||||||
|
{
|
||||||
|
// Se non riusciamo a calcolare, usa il timer frequency come fallback
|
||||||
|
if (auction.RemainingSeconds <= 0)
|
||||||
|
{
|
||||||
|
auction.RemainingSeconds = auction.TimerFrequency;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status: ON = attiva in countdown, OFF = terminata/in pausa
|
||||||
|
auction.IsActive = status == "ON";
|
||||||
|
auction.IsSold = status != "ON" && auction.RemainingSeconds <= 0;
|
||||||
|
|
||||||
|
updatedCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine($"[BidooBrowser] Aggiornate {updatedCount} aste su {auctionEntries.Length}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[BidooBrowser] Errore parsing LISTID response: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converte countdown string in secondi
|
||||||
|
/// Formati: "7m", "1h 16m", "00:08", vuoto (usa timer frequency)
|
||||||
|
/// </summary>
|
||||||
|
private int ParseCountdown(string countdown, int defaultSeconds)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(countdown))
|
||||||
|
{
|
||||||
|
return defaultSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Formato ore e minuti: "1h 16m"
|
||||||
|
var hourMatch = Regex.Match(countdown, @"(\d+)h");
|
||||||
|
var minMatch = Regex.Match(countdown, @"(\d+)m");
|
||||||
|
|
||||||
|
int totalSeconds = 0;
|
||||||
|
|
||||||
|
if (hourMatch.Success && int.TryParse(hourMatch.Groups[1].Value, out int hours))
|
||||||
|
{
|
||||||
|
totalSeconds += hours * 3600;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (minMatch.Success && int.TryParse(minMatch.Groups[1].Value, out int mins))
|
||||||
|
{
|
||||||
|
totalSeconds += mins * 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalSeconds > 0)
|
||||||
|
{
|
||||||
|
return totalSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Formato "00:08" (mm:ss o ss)
|
||||||
|
if (countdown.Contains(":"))
|
||||||
|
{
|
||||||
|
var parts = countdown.Split(':');
|
||||||
|
if (parts.Length == 2 &&
|
||||||
|
int.TryParse(parts[0], out int p1) &&
|
||||||
|
int.TryParse(parts[1], out int p2))
|
||||||
|
{
|
||||||
|
return p1 * 60 + p2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Solo numero = secondi
|
||||||
|
if (int.TryParse(countdown, out int secs))
|
||||||
|
{
|
||||||
|
return secs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
|
||||||
|
return defaultSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Carica nuove aste usando get_auction_updates.php (simula scrolling infinito)
|
||||||
|
/// Questa API restituisce aste che non sono ancora state caricate
|
||||||
|
/// </summary>
|
||||||
|
public async Task<List<BidooBrowserAuction>> GetMoreAuctionsAsync(
|
||||||
|
BidooCategoryInfo category,
|
||||||
|
List<string> existingAuctionIds,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var newAuctions = new List<BidooBrowserAuction>();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var existingIdsSet = existingAuctionIds.ToHashSet();
|
||||||
|
|
||||||
|
// Prepara la chiamata POST a get_auction_updates.php
|
||||||
|
var url = "https://it.bidoo.com/get_auction_updates.php";
|
||||||
|
|
||||||
|
// Costruisci il body della richiesta
|
||||||
|
var viewIds = string.Join(",", existingAuctionIds);
|
||||||
|
var tabValue = category.IsSpecialCategory ? category.TabId : 4;
|
||||||
|
var tagValue = category.IsSpecialCategory ? 0 : category.TagId;
|
||||||
|
|
||||||
|
var formContent = new FormUrlEncodedContent(new[]
|
||||||
|
{
|
||||||
|
new KeyValuePair<string, string>("prefetch", "true"),
|
||||||
|
new KeyValuePair<string, string>("view", viewIds),
|
||||||
|
new KeyValuePair<string, string>("tab", tabValue.ToString()),
|
||||||
|
new KeyValuePair<string, string>("tag", tagValue.ToString())
|
||||||
|
});
|
||||||
|
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Post, url)
|
||||||
|
{
|
||||||
|
Content = formContent
|
||||||
|
};
|
||||||
|
|
||||||
|
AddBrowserHeaders(request, "https://it.bidoo.com/");
|
||||||
|
request.Headers.Add("X-Requested-With", "XMLHttpRequest");
|
||||||
|
|
||||||
|
Console.WriteLine($"[BidooBrowser] Fetching more auctions with {existingAuctionIds.Count} existing IDs...");
|
||||||
|
|
||||||
|
var response = await _httpClient.SendAsync(request, cancellationToken);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var responseText = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
|
||||||
|
// Parse la risposta JSON
|
||||||
|
// Formato: {"gc":[],"int":[],"list":[id1,id2,...],"items":["<html>","<html>",...]}
|
||||||
|
newAuctions = ParseGetAuctionUpdatesResponse(responseText, existingIdsSet);
|
||||||
|
|
||||||
|
Console.WriteLine($"[BidooBrowser] Trovate {newAuctions.Count} nuove aste");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[BidooBrowser] Errore caricamento nuove aste: {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return newAuctions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parsa la risposta di get_auction_updates.php
|
||||||
|
/// </summary>
|
||||||
|
private List<BidooBrowserAuction> ParseGetAuctionUpdatesResponse(string json, HashSet<string> existingIds)
|
||||||
|
{
|
||||||
|
var auctions = new List<BidooBrowserAuction>();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Parse JSON manuale per estrarre items[]
|
||||||
|
// Cerchiamo "items":["...","..."]
|
||||||
|
var itemsMatch = Regex.Match(json, @"""items"":\s*\[(.*?)\](?=,""|\})", RegexOptions.Singleline);
|
||||||
|
if (!itemsMatch.Success)
|
||||||
|
{
|
||||||
|
Console.WriteLine("[BidooBrowser] Nessun items trovato nella risposta");
|
||||||
|
return auctions;
|
||||||
|
}
|
||||||
|
|
||||||
|
var itemsContent = itemsMatch.Groups[1].Value;
|
||||||
|
|
||||||
|
// Gli items sono stringhe HTML escaped, dobbiamo parsarle
|
||||||
|
// Ogni item è una stringa JSON che contiene HTML
|
||||||
|
var htmlPattern = new Regex(@"""((?:[^""\\]|\\.)*?)""", RegexOptions.Singleline);
|
||||||
|
var htmlMatches = htmlPattern.Matches(itemsContent);
|
||||||
|
|
||||||
|
foreach (Match htmlMatch in htmlMatches)
|
||||||
|
{
|
||||||
|
if (!htmlMatch.Success) continue;
|
||||||
|
|
||||||
|
// Unescape la stringa JSON
|
||||||
|
var escapedHtml = htmlMatch.Groups[1].Value;
|
||||||
|
var html = UnescapeJsonString(escapedHtml);
|
||||||
|
|
||||||
|
// Estrai l'ID dell'asta
|
||||||
|
var idMatch = Regex.Match(html, @"id=""divAsta(\d+)""");
|
||||||
|
if (!idMatch.Success) continue;
|
||||||
|
|
||||||
|
var auctionId = idMatch.Groups[1].Value;
|
||||||
|
|
||||||
|
// Salta se già esiste
|
||||||
|
if (existingIds.Contains(auctionId)) continue;
|
||||||
|
|
||||||
|
// Parsa l'asta dall'HTML
|
||||||
|
var auction = ParseSingleAuction(auctionId, html);
|
||||||
|
if (auction != null)
|
||||||
|
{
|
||||||
|
auctions.Add(auction);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[BidooBrowser] Errore parsing get_auction_updates response: {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return auctions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unescape di una stringa JSON
|
||||||
|
/// </summary>
|
||||||
|
private static string UnescapeJsonString(string escaped)
|
||||||
|
{
|
||||||
|
return escaped
|
||||||
|
.Replace("\\/", "/")
|
||||||
|
.Replace("\\n", "\n")
|
||||||
|
.Replace("\\r", "\r")
|
||||||
|
.Replace("\\t", "\t")
|
||||||
|
.Replace("\\\"", "\"")
|
||||||
|
.Replace("\\\\", "\\");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1348
-32
File diff suppressed because it is too large
Load Diff
@@ -28,6 +28,7 @@ namespace AutoBidder.Services
|
|||||||
private readonly int _maxConcurrentRequests;
|
private readonly int _maxConcurrentRequests;
|
||||||
private readonly TimeSpan _cacheExpiration;
|
private readonly TimeSpan _cacheExpiration;
|
||||||
private readonly int _maxRetries;
|
private readonly int _maxRetries;
|
||||||
|
private readonly int _maxCacheEntries;
|
||||||
|
|
||||||
// Logging callback
|
// Logging callback
|
||||||
public Action<string>? OnLog { get; set; }
|
public Action<string>? OnLog { get; set; }
|
||||||
@@ -36,12 +37,14 @@ namespace AutoBidder.Services
|
|||||||
int maxConcurrentRequests = 3,
|
int maxConcurrentRequests = 3,
|
||||||
int requestsPerSecond = 5,
|
int requestsPerSecond = 5,
|
||||||
TimeSpan? cacheExpiration = null,
|
TimeSpan? cacheExpiration = null,
|
||||||
int maxRetries = 2)
|
int maxRetries = 2,
|
||||||
|
int maxCacheEntries = 50)
|
||||||
{
|
{
|
||||||
_maxConcurrentRequests = maxConcurrentRequests;
|
_maxConcurrentRequests = maxConcurrentRequests;
|
||||||
_minRequestDelay = TimeSpan.FromMilliseconds(1000.0 / requestsPerSecond);
|
_minRequestDelay = TimeSpan.FromMilliseconds(1000.0 / requestsPerSecond);
|
||||||
_cacheExpiration = cacheExpiration ?? TimeSpan.FromMinutes(5);
|
_cacheExpiration = cacheExpiration ?? TimeSpan.FromMinutes(3); // Ridotto da 5 a 3 minuti
|
||||||
_maxRetries = maxRetries;
|
_maxRetries = maxRetries;
|
||||||
|
_maxCacheEntries = maxCacheEntries;
|
||||||
_rateLimiter = new SemaphoreSlim(maxConcurrentRequests, maxConcurrentRequests);
|
_rateLimiter = new SemaphoreSlim(maxConcurrentRequests, maxConcurrentRequests);
|
||||||
|
|
||||||
_httpClient.Timeout = TimeSpan.FromSeconds(15);
|
_httpClient.Timeout = TimeSpan.FromSeconds(15);
|
||||||
@@ -191,10 +194,26 @@ namespace AutoBidder.Services
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Salva HTML in cache
|
/// Salva HTML in cache con limite dimensione
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void SaveToCache(string url, string html)
|
private void SaveToCache(string url, string html)
|
||||||
{
|
{
|
||||||
|
// Limita dimensione cache per evitare memory leak
|
||||||
|
if (_cache.Count >= _maxCacheEntries)
|
||||||
|
{
|
||||||
|
// Rimuovi le entry più vecchie
|
||||||
|
var oldestEntries = _cache
|
||||||
|
.OrderBy(kvp => kvp.Value.Timestamp)
|
||||||
|
.Take(_cache.Count - _maxCacheEntries + 10) // Rimuovi 10 extra per evitare chiamate frequenti
|
||||||
|
.Select(kvp => kvp.Key)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
foreach (var key in oldestEntries)
|
||||||
|
{
|
||||||
|
_cache.TryRemove(key, out _);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_cache[url] = new CachedHtml
|
_cache[url] = new CachedHtml
|
||||||
{
|
{
|
||||||
Html = html,
|
Html = html,
|
||||||
|
|||||||
@@ -0,0 +1,352 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using AutoBidder.Models;
|
||||||
|
|
||||||
|
namespace AutoBidder.Services
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Servizio per calcolare statistiche aggregate per prodotto e generare limiti consigliati.
|
||||||
|
/// L'algoritmo analizza le aste storiche per determinare i parametri ottimali.
|
||||||
|
/// </summary>
|
||||||
|
public class ProductStatisticsService
|
||||||
|
{
|
||||||
|
private readonly DatabaseService _db;
|
||||||
|
|
||||||
|
public ProductStatisticsService(DatabaseService db)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Genera una chiave univoca normalizzata per raggruppare prodotti simili.
|
||||||
|
/// Rimuove varianti, numeri di serie, colori ecc.
|
||||||
|
/// </summary>
|
||||||
|
public static string GenerateProductKey(string productName)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(productName))
|
||||||
|
return "unknown";
|
||||||
|
|
||||||
|
var normalized = productName.ToLowerInvariant().Trim();
|
||||||
|
|
||||||
|
// Rimuovi contenuto tra parentesi (varianti, colori, capacità)
|
||||||
|
normalized = Regex.Replace(normalized, @"\([^)]*\)", "");
|
||||||
|
normalized = Regex.Replace(normalized, @"\[[^\]]*\]", "");
|
||||||
|
|
||||||
|
// Rimuovi colori comuni
|
||||||
|
var colors = new[] { "nero", "bianco", "grigio", "rosso", "blu", "verde", "oro", "argento",
|
||||||
|
"black", "white", "gray", "red", "blue", "green", "gold", "silver",
|
||||||
|
"space gray", "midnight", "starlight" };
|
||||||
|
foreach (var color in colors)
|
||||||
|
{
|
||||||
|
normalized = Regex.Replace(normalized, $@"\b{color}\b", "", RegexOptions.IgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rimuovi capacità storage (64gb, 128gb, 256gb, ecc.)
|
||||||
|
normalized = Regex.Replace(normalized, @"\b\d+\s*(gb|tb|mb)\b", "", RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
|
// Rimuovi numeri di serie e codici prodotto
|
||||||
|
normalized = Regex.Replace(normalized, @"\b[A-Z]{2,}\d{3,}\b", "", RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
|
// Normalizza spazi e caratteri speciali
|
||||||
|
normalized = Regex.Replace(normalized, @"[^a-z0-9\s]", " ");
|
||||||
|
normalized = Regex.Replace(normalized, @"\s+", "_");
|
||||||
|
normalized = normalized.Trim('_');
|
||||||
|
|
||||||
|
// Limita lunghezza
|
||||||
|
if (normalized.Length > 50)
|
||||||
|
normalized = normalized.Substring(0, 50);
|
||||||
|
|
||||||
|
return string.IsNullOrEmpty(normalized) ? "unknown" : normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Aggiorna le statistiche aggregate per un prodotto dopo una nuova asta completata
|
||||||
|
/// </summary>
|
||||||
|
public async Task UpdateProductStatisticsAsync(string productKey, string productName)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Ottieni tutti i risultati per questo prodotto
|
||||||
|
var results = await _db.GetAuctionResultsByProductAsync(productKey, 500);
|
||||||
|
|
||||||
|
if (results.Count == 0)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[ProductStats] No results found for product: {productKey}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calcola statistiche aggregate
|
||||||
|
var wonResults = results.Where(r => r.Won).ToList();
|
||||||
|
var lostResults = results.Where(r => !r.Won).ToList();
|
||||||
|
|
||||||
|
var stats = new ProductStatisticsRecord
|
||||||
|
{
|
||||||
|
ProductKey = productKey,
|
||||||
|
ProductName = productName,
|
||||||
|
TotalAuctions = results.Count,
|
||||||
|
WonAuctions = wonResults.Count,
|
||||||
|
LostAuctions = lostResults.Count
|
||||||
|
};
|
||||||
|
|
||||||
|
// Statistiche prezzo (usa aste vinte per calcolare i target)
|
||||||
|
if (wonResults.Any())
|
||||||
|
{
|
||||||
|
stats.AvgFinalPrice = wonResults.Average(r => r.FinalPrice);
|
||||||
|
stats.MinFinalPrice = wonResults.Min(r => r.FinalPrice);
|
||||||
|
stats.MaxFinalPrice = wonResults.Max(r => r.FinalPrice);
|
||||||
|
stats.MedianFinalPrice = CalculateMedian(wonResults.Select(r => r.FinalPrice).ToList());
|
||||||
|
}
|
||||||
|
else if (results.Any())
|
||||||
|
{
|
||||||
|
stats.AvgFinalPrice = results.Average(r => r.FinalPrice);
|
||||||
|
stats.MinFinalPrice = results.Min(r => r.FinalPrice);
|
||||||
|
stats.MaxFinalPrice = results.Max(r => r.FinalPrice);
|
||||||
|
stats.MedianFinalPrice = CalculateMedian(results.Select(r => r.FinalPrice).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Statistiche puntate (usa WinnerBidsUsed se disponibile, altrimenti BidsUsed)
|
||||||
|
var bidsData = wonResults
|
||||||
|
.Where(r => r.WinnerBidsUsed.HasValue || r.BidsUsed > 0)
|
||||||
|
.Select(r => r.WinnerBidsUsed ?? r.BidsUsed)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (bidsData.Any())
|
||||||
|
{
|
||||||
|
stats.AvgBidsToWin = bidsData.Select(b => (double)b).Average();
|
||||||
|
stats.MinBidsToWin = bidsData.Min();
|
||||||
|
stats.MaxBidsToWin = bidsData.Max();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Statistiche reset
|
||||||
|
var resetData = wonResults.Where(r => r.TotalResets.HasValue).Select(r => r.TotalResets!.Value).ToList();
|
||||||
|
if (resetData.Any())
|
||||||
|
{
|
||||||
|
stats.AvgResets = resetData.Select(r => (double)r).Average();
|
||||||
|
stats.MinResets = resetData.Min();
|
||||||
|
stats.MaxResets = resetData.Max();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calcola limiti consigliati
|
||||||
|
var limits = CalculateRecommendedLimits(results);
|
||||||
|
stats.RecommendedMinPrice = limits.MinPrice;
|
||||||
|
stats.RecommendedMaxPrice = limits.MaxPrice;
|
||||||
|
stats.RecommendedMinResets = limits.MinResets;
|
||||||
|
stats.RecommendedMaxResets = limits.MaxResets;
|
||||||
|
stats.RecommendedMaxBids = limits.MaxBids;
|
||||||
|
|
||||||
|
// Calcola statistiche per fascia oraria
|
||||||
|
var hourlyStats = CalculateHourlyStats(results);
|
||||||
|
stats.HourlyStatsJson = JsonSerializer.Serialize(hourlyStats);
|
||||||
|
|
||||||
|
// Salva nel database
|
||||||
|
await _db.UpsertProductStatisticsAsync(stats);
|
||||||
|
|
||||||
|
Console.WriteLine($"[ProductStats] Updated stats for {productKey}: {stats.TotalAuctions} auctions, WinRate={stats.WinRate:F1}%");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[ProductStats ERROR] Failed to update stats for {productKey}: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Calcola i limiti consigliati basandosi sui dati storici
|
||||||
|
/// </summary>
|
||||||
|
public RecommendedLimits CalculateRecommendedLimits(List<AutoBidder.Models.AuctionResultExtended> results)
|
||||||
|
{
|
||||||
|
var limits = new RecommendedLimits
|
||||||
|
{
|
||||||
|
SampleSize = results.Count
|
||||||
|
};
|
||||||
|
|
||||||
|
if (results.Count < 3)
|
||||||
|
{
|
||||||
|
limits.ConfidenceScore = 0;
|
||||||
|
return limits;
|
||||||
|
}
|
||||||
|
|
||||||
|
var wonResults = results.Where(r => r.Won).ToList();
|
||||||
|
|
||||||
|
if (wonResults.Count == 0)
|
||||||
|
{
|
||||||
|
// Nessuna vittoria: usa tutti i risultati con margine conservativo
|
||||||
|
limits.ConfidenceScore = 10;
|
||||||
|
limits.MinPrice = results.Min(r => r.FinalPrice) * 0.8;
|
||||||
|
limits.MaxPrice = results.Max(r => r.FinalPrice) * 1.2;
|
||||||
|
return limits;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calcola percentili sui prezzi delle aste vinte
|
||||||
|
var prices = wonResults.Select(r => r.FinalPrice).OrderBy(p => p).ToList();
|
||||||
|
limits.MinPrice = CalculatePercentile(prices, 10); // 10° percentile - entrare presto
|
||||||
|
limits.MaxPrice = CalculatePercentile(prices, 90); // 90° percentile - limite sicuro
|
||||||
|
|
||||||
|
// Calcola limiti reset
|
||||||
|
var resets = wonResults.Where(r => r.TotalResets.HasValue).Select(r => r.TotalResets!.Value).ToList();
|
||||||
|
if (resets.Any())
|
||||||
|
{
|
||||||
|
var avgResets = resets.Average();
|
||||||
|
var stdDev = CalculateStandardDeviation(resets.Select(r => (double)r).ToList());
|
||||||
|
|
||||||
|
limits.MinResets = Math.Max(0, (int)(avgResets - stdDev)); // Media - 1 stddev
|
||||||
|
limits.MaxResets = (int)(avgResets + stdDev); // Media + 1 stddev
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calcola limiti puntate
|
||||||
|
var bids = wonResults
|
||||||
|
.Where(r => r.WinnerBidsUsed.HasValue || r.BidsUsed > 0)
|
||||||
|
.Select(r => r.WinnerBidsUsed ?? r.BidsUsed)
|
||||||
|
.OrderBy(b => b)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (bids.Any())
|
||||||
|
{
|
||||||
|
// 90° percentile + 10% buffer
|
||||||
|
limits.MaxBids = (int)(CalculatePercentile(bids.Select(b => (double)b).ToList(), 90) * 1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trova la fascia oraria migliore
|
||||||
|
var hourlyWins = wonResults
|
||||||
|
.Where(r => r.ClosedAtHour.HasValue)
|
||||||
|
.GroupBy(r => r.ClosedAtHour!.Value)
|
||||||
|
.Select(g => new { Hour = g.Key, Wins = g.Count() })
|
||||||
|
.OrderByDescending(x => x.Wins)
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
if (hourlyWins != null)
|
||||||
|
{
|
||||||
|
limits.BestHourToPlay = hourlyWins.Hour;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Win rate
|
||||||
|
limits.AverageWinRate = results.Count > 0 ? (double)wonResults.Count / results.Count * 100 : 0;
|
||||||
|
|
||||||
|
// Confidence score basato sul sample size
|
||||||
|
limits.ConfidenceScore = results.Count switch
|
||||||
|
{
|
||||||
|
>= 50 => 95,
|
||||||
|
>= 30 => 85,
|
||||||
|
>= 20 => 70,
|
||||||
|
>= 10 => 50,
|
||||||
|
>= 5 => 30,
|
||||||
|
_ => 15
|
||||||
|
};
|
||||||
|
|
||||||
|
return limits;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Calcola statistiche aggregate per ogni fascia oraria
|
||||||
|
/// </summary>
|
||||||
|
private List<HourlyStats> CalculateHourlyStats(List<AutoBidder.Models.AuctionResultExtended> results)
|
||||||
|
{
|
||||||
|
var stats = new List<HourlyStats>();
|
||||||
|
|
||||||
|
var grouped = results
|
||||||
|
.Where(r => r.ClosedAtHour.HasValue)
|
||||||
|
.GroupBy(r => r.ClosedAtHour!.Value);
|
||||||
|
|
||||||
|
foreach (var group in grouped)
|
||||||
|
{
|
||||||
|
var hourResults = group.ToList();
|
||||||
|
var wonInHour = hourResults.Where(r => r.Won).ToList();
|
||||||
|
|
||||||
|
stats.Add(new HourlyStats
|
||||||
|
{
|
||||||
|
Hour = group.Key,
|
||||||
|
TotalAuctions = hourResults.Count,
|
||||||
|
WonAuctions = wonInHour.Count,
|
||||||
|
AvgFinalPrice = hourResults.Any() ? hourResults.Average(r => r.FinalPrice) : 0,
|
||||||
|
AvgBidsUsed = hourResults.Any() ? hourResults.Average(r => r.BidsUsed) : 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats.OrderBy(s => s.Hour).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ottiene le statistiche per un prodotto
|
||||||
|
/// </summary>
|
||||||
|
public async Task<ProductStatisticsRecord?> GetProductStatisticsAsync(string productKey)
|
||||||
|
{
|
||||||
|
return await _db.GetProductStatisticsAsync(productKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ottiene tutti i prodotti con statistiche
|
||||||
|
/// </summary>
|
||||||
|
public async Task<List<ProductStatisticsRecord>> GetAllProductStatisticsAsync()
|
||||||
|
{
|
||||||
|
return await _db.GetAllProductStatisticsAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ottiene i limiti consigliati per un prodotto
|
||||||
|
/// </summary>
|
||||||
|
public async Task<RecommendedLimits?> GetRecommendedLimitsAsync(string productKey)
|
||||||
|
{
|
||||||
|
var stats = await _db.GetProductStatisticsAsync(productKey);
|
||||||
|
|
||||||
|
if (stats == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return new RecommendedLimits
|
||||||
|
{
|
||||||
|
MinPrice = stats.RecommendedMinPrice ?? 0,
|
||||||
|
MaxPrice = stats.RecommendedMaxPrice ?? 0,
|
||||||
|
MinResets = stats.RecommendedMinResets ?? 0,
|
||||||
|
MaxResets = stats.RecommendedMaxResets ?? 0,
|
||||||
|
MaxBids = stats.RecommendedMaxBids ?? 0,
|
||||||
|
ConfidenceScore = stats.TotalAuctions switch
|
||||||
|
{
|
||||||
|
>= 50 => 95,
|
||||||
|
>= 30 => 85,
|
||||||
|
>= 20 => 70,
|
||||||
|
>= 10 => 50,
|
||||||
|
>= 5 => 30,
|
||||||
|
_ => 15
|
||||||
|
},
|
||||||
|
SampleSize = stats.TotalAuctions,
|
||||||
|
AverageWinRate = stats.WinRate
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helpers per calcoli statistici
|
||||||
|
private static double CalculatePercentile(List<double> sortedData, int percentile)
|
||||||
|
{
|
||||||
|
if (sortedData.Count == 0) return 0;
|
||||||
|
if (sortedData.Count == 1) return sortedData[0];
|
||||||
|
|
||||||
|
double index = (percentile / 100.0) * (sortedData.Count - 1);
|
||||||
|
int lower = (int)Math.Floor(index);
|
||||||
|
int upper = (int)Math.Ceiling(index);
|
||||||
|
|
||||||
|
if (lower == upper) return sortedData[lower];
|
||||||
|
|
||||||
|
return sortedData[lower] + (sortedData[upper] - sortedData[lower]) * (index - lower);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double CalculateStandardDeviation(List<double> data)
|
||||||
|
{
|
||||||
|
if (data.Count < 2) return 0;
|
||||||
|
|
||||||
|
double avg = data.Average();
|
||||||
|
double sumSquares = data.Sum(d => Math.Pow(d - avg, 2));
|
||||||
|
return Math.Sqrt(sumSquares / (data.Count - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double CalculateMedian(List<double> data)
|
||||||
|
{
|
||||||
|
if (data.Count == 0) return 0;
|
||||||
|
var sorted = data.OrderBy(x => x).ToList();
|
||||||
|
int mid = sorted.Count / 2;
|
||||||
|
return sorted.Count % 2 == 0
|
||||||
|
? (sorted[mid - 1] + sorted[mid]) / 2.0
|
||||||
|
: sorted[mid];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+280
-217
@@ -2,64 +2,145 @@ using System;
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using AutoBidder.Models;
|
using AutoBidder.Models;
|
||||||
using AutoBidder.Data;
|
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
|
|
||||||
namespace AutoBidder.Services
|
namespace AutoBidder.Services
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Servizio per calcolo e gestione statistiche avanzate
|
/// Servizio per calcolo e gestione statistiche.
|
||||||
/// Usa PostgreSQL per statistiche persistenti e SQLite locale come fallback
|
/// Usa esclusivamente il database SQLite interno gestito da DatabaseService.
|
||||||
|
/// Le statistiche sono disabilitate se il database non è disponibile.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class StatsService
|
public class StatsService
|
||||||
{
|
{
|
||||||
private readonly DatabaseService _db;
|
private readonly DatabaseService _db;
|
||||||
private readonly PostgresStatsContext? _postgresDb;
|
|
||||||
private readonly bool _postgresAvailable;
|
|
||||||
|
|
||||||
public StatsService(DatabaseService db, PostgresStatsContext? postgresDb = null)
|
/// <summary>
|
||||||
|
/// Indica se le statistiche sono disponibili (database SQLite funzionante)
|
||||||
|
/// </summary>
|
||||||
|
public bool IsAvailable => _db.IsAvailable && _db.IsInitialized;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Messaggio di errore se le statistiche non sono disponibili
|
||||||
|
/// </summary>
|
||||||
|
public string? ErrorMessage => !IsAvailable ? _db.InitializationError ?? "Database non disponibile" : null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Path del database SQLite
|
||||||
|
/// </summary>
|
||||||
|
public string DatabasePath => _db.DatabasePath;
|
||||||
|
|
||||||
|
private ProductStatisticsService? _productStatsService;
|
||||||
|
|
||||||
|
public StatsService(DatabaseService db)
|
||||||
{
|
{
|
||||||
_db = db;
|
_db = db;
|
||||||
_postgresDb = postgresDb;
|
_productStatsService = new ProductStatisticsService(db);
|
||||||
_postgresAvailable = false;
|
|
||||||
|
|
||||||
// Verifica disponibilità PostgreSQL
|
// Log stato database SQLite
|
||||||
if (_postgresDb != null)
|
Console.WriteLine($"[StatsService] Database available: {_db.IsAvailable}");
|
||||||
|
Console.WriteLine($"[StatsService] Database initialized: {_db.IsInitialized}");
|
||||||
|
|
||||||
|
if (!_db.IsAvailable)
|
||||||
{
|
{
|
||||||
try
|
Console.WriteLine($"[StatsService] Database error: {_db.InitializationError}");
|
||||||
{
|
|
||||||
_postgresAvailable = _postgresDb.Database.CanConnect();
|
|
||||||
var status = _postgresAvailable ? "AVAILABLE" : "UNAVAILABLE";
|
|
||||||
Console.WriteLine($"[StatsService] PostgreSQL status: {status}");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"[StatsService] PostgreSQL connection failed: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Console.WriteLine("[StatsService] PostgreSQL not configured - using SQLite only");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Registra il completamento di un'asta (sia su PostgreSQL che SQLite)
|
/// Registra il completamento di un'asta con tutti i dati per analytics
|
||||||
|
/// Include scraping HTML per ottenere le puntate del vincitore
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task RecordAuctionCompletedAsync(AuctionInfo auction, bool won)
|
public async Task RecordAuctionCompletedAsync(AuctionInfo auction, AuctionState state, bool won)
|
||||||
{
|
{
|
||||||
|
// Skip se database non disponibile
|
||||||
|
if (!IsAvailable)
|
||||||
|
{
|
||||||
|
Console.WriteLine("[StatsService] Skipping record - database not available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
Console.WriteLine($"[StatsService] ====== INIZIO SALVATAGGIO ASTA TERMINATA ======");
|
||||||
|
Console.WriteLine($"[StatsService] Asta: {auction.Name} (ID: {auction.AuctionId})");
|
||||||
|
Console.WriteLine($"[StatsService] Stato: {(won ? "VINTA" : "PERSA")}");
|
||||||
|
|
||||||
var today = DateTime.UtcNow.ToString("yyyy-MM-dd");
|
var today = DateTime.UtcNow.ToString("yyyy-MM-dd");
|
||||||
var bidsUsed = auction.BidsUsedOnThisAuction ?? 0;
|
var bidsUsed = auction.BidsUsedOnThisAuction ?? 0;
|
||||||
var bidCost = auction.BidCost;
|
var bidCost = auction.BidCost;
|
||||||
var moneySpent = bidsUsed * bidCost;
|
var moneySpent = bidsUsed * bidCost;
|
||||||
|
|
||||||
var finalPrice = auction.LastState?.Price ?? 0;
|
var finalPrice = state.Price;
|
||||||
var buyNowPrice = auction.BuyNowPrice;
|
var buyNowPrice = auction.BuyNowPrice;
|
||||||
var shippingCost = auction.ShippingCost ?? 0;
|
var shippingCost = auction.ShippingCost ?? 0;
|
||||||
|
|
||||||
|
// Dati aggiuntivi per analytics
|
||||||
|
var winnerUsername = state.LastBidder;
|
||||||
|
var totalResets = auction.ResetCount;
|
||||||
|
var productKey = ProductStatisticsService.GenerateProductKey(auction.Name);
|
||||||
|
|
||||||
|
Console.WriteLine($"[StatsService] Prezzo finale: €{finalPrice:F2}");
|
||||||
|
Console.WriteLine($"[StatsService] Puntate usate (utente): {bidsUsed}");
|
||||||
|
Console.WriteLine($"[StatsService] Vincitore: {winnerUsername ?? "N/A"}");
|
||||||
|
Console.WriteLine($"[StatsService] Reset totali: {totalResets}");
|
||||||
|
|
||||||
|
// ?? SCRAPING HTML: Ottieni puntate del vincitore dalla pagina dell'asta
|
||||||
|
int? winnerBidsUsed = null;
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(winnerUsername))
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[StatsService] Avvio scraping HTML per ottenere puntate del vincitore...");
|
||||||
|
winnerBidsUsed = await ScrapeWinnerBidsFromAuctionPageAsync(auction.OriginalUrl);
|
||||||
|
|
||||||
|
// ? VALIDAZIONE: Verifica che i dati estratti siano ragionevoli
|
||||||
|
if (winnerBidsUsed.HasValue)
|
||||||
|
{
|
||||||
|
if (winnerBidsUsed.Value < 0)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[StatsService] ? VALIDAZIONE FALLITA: Puntate negative ({winnerBidsUsed.Value}) - Dato scartato");
|
||||||
|
winnerBidsUsed = null;
|
||||||
|
}
|
||||||
|
else if (winnerBidsUsed.Value > 50000)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[StatsService] ? VALIDAZIONE FALLITA: Puntate sospette ({winnerBidsUsed.Value} > 50000) - Dato scartato");
|
||||||
|
winnerBidsUsed = null;
|
||||||
|
}
|
||||||
|
else if (winnerBidsUsed.Value == 0 && totalResets > 10)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[StatsService] ? VALIDAZIONE FALLITA: 0 puntate con {totalResets} reset - Dato sospetto");
|
||||||
|
winnerBidsUsed = null;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[StatsService] ? Puntate vincitore estratte da HTML: {winnerBidsUsed.Value} (validazione OK)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback se validazione fallita o scraping non riuscito
|
||||||
|
if (!winnerBidsUsed.HasValue)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[StatsService] ? Impossibile estrarre puntate valide del vincitore da HTML");
|
||||||
|
|
||||||
|
// Fallback: conta da RecentBids (meno affidabile)
|
||||||
|
if (auction.RecentBids != null)
|
||||||
|
{
|
||||||
|
winnerBidsUsed = auction.RecentBids
|
||||||
|
.Count(b => b.Username?.Equals(winnerUsername, StringComparison.OrdinalIgnoreCase) == true);
|
||||||
|
|
||||||
|
if (winnerBidsUsed.Value > 0)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[StatsService] Fallback: puntate vincitore da RecentBids: {winnerBidsUsed.Value}");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[StatsService] ? Fallback fallito: nessuna puntata trovata in RecentBids");
|
||||||
|
winnerBidsUsed = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
double? totalCost = null;
|
double? totalCost = null;
|
||||||
double? savings = null;
|
double? savings = null;
|
||||||
|
|
||||||
@@ -67,9 +148,14 @@ namespace AutoBidder.Services
|
|||||||
{
|
{
|
||||||
totalCost = finalPrice + moneySpent + shippingCost;
|
totalCost = finalPrice + moneySpent + shippingCost;
|
||||||
savings = (buyNowPrice.Value + shippingCost) - totalCost.Value;
|
savings = (buyNowPrice.Value + shippingCost) - totalCost.Value;
|
||||||
|
|
||||||
|
Console.WriteLine($"[StatsService] Costo totale: €{totalCost:F2}");
|
||||||
|
Console.WriteLine($"[StatsService] Risparmio: €{savings:F2}");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Salva su SQLite (sempre)
|
Console.WriteLine($"[StatsService] Salvataggio nel database...");
|
||||||
|
|
||||||
|
// Salva risultato asta con tutti i campi
|
||||||
await _db.SaveAuctionResultAsync(
|
await _db.SaveAuctionResultAsync(
|
||||||
auction.AuctionId,
|
auction.AuctionId,
|
||||||
auction.Name,
|
auction.Name,
|
||||||
@@ -79,9 +165,16 @@ namespace AutoBidder.Services
|
|||||||
buyNowPrice,
|
buyNowPrice,
|
||||||
shippingCost,
|
shippingCost,
|
||||||
totalCost,
|
totalCost,
|
||||||
savings
|
savings,
|
||||||
|
winnerUsername,
|
||||||
|
totalResets,
|
||||||
|
winnerBidsUsed,
|
||||||
|
productKey
|
||||||
);
|
);
|
||||||
|
|
||||||
|
Console.WriteLine($"[StatsService] ? Risultato asta salvato");
|
||||||
|
|
||||||
|
// Aggiorna statistiche giornaliere
|
||||||
await _db.SaveDailyStatAsync(
|
await _db.SaveDailyStatAsync(
|
||||||
today,
|
today,
|
||||||
bidsUsed,
|
bidsUsed,
|
||||||
@@ -89,229 +182,178 @@ namespace AutoBidder.Services
|
|||||||
won ? 1 : 0,
|
won ? 1 : 0,
|
||||||
won ? 0 : 1,
|
won ? 0 : 1,
|
||||||
savings ?? 0,
|
savings ?? 0,
|
||||||
auction.LastState?.PollingLatencyMs
|
state.PollingLatencyMs
|
||||||
);
|
);
|
||||||
|
|
||||||
// Salva su PostgreSQL se disponibile
|
Console.WriteLine($"[StatsService] ? Statistiche giornaliere aggiornate");
|
||||||
if (_postgresAvailable && _postgresDb != null)
|
|
||||||
|
// Aggiorna statistiche aggregate per prodotto
|
||||||
|
if (_productStatsService != null)
|
||||||
{
|
{
|
||||||
await SaveToPostgresAsync(auction, won, finalPrice, bidsUsed, totalCost, savings);
|
Console.WriteLine($"[StatsService] Aggiornamento statistiche prodotto (key: {productKey})...");
|
||||||
|
await _productStatsService.UpdateProductStatisticsAsync(productKey, auction.Name);
|
||||||
|
Console.WriteLine($"[StatsService] ? Statistiche prodotto aggiornate");
|
||||||
}
|
}
|
||||||
|
|
||||||
Console.WriteLine($"[StatsService] Recorded auction {auction.Name} - Won: {won}, Bids: {bidsUsed}, Savings: {savings:F2}€");
|
Console.WriteLine($"[StatsService] ====== SALVATAGGIO COMPLETATO ======");
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Console.WriteLine($"[StatsService ERROR] Failed to record auction: {ex.Message}");
|
Console.WriteLine($"[StatsService ERROR] Failed to record auction: {ex.Message}");
|
||||||
|
Console.WriteLine($"[StatsService ERROR] Stack: {ex.StackTrace}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Salva asta conclusa su PostgreSQL
|
/// Scarica l'HTML della pagina dell'asta e estrae le puntate del vincitore
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task SaveToPostgresAsync(AuctionInfo auction, bool won, double finalPrice, int bidsUsed, double? totalCost, double? savings)
|
private async Task<int?> ScrapeWinnerBidsFromAuctionPageAsync(string auctionUrl)
|
||||||
{
|
{
|
||||||
if (_postgresDb == null) return;
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var completedAuction = new CompletedAuction
|
using var httpClient = new HttpClient();
|
||||||
|
// ? RIDOTTO: Timeout da 10s ? 5s per evitare rallentamenti
|
||||||
|
httpClient.Timeout = TimeSpan.FromSeconds(5);
|
||||||
|
|
||||||
|
// Headers browser-like per evitare rilevamento come bot
|
||||||
|
httpClient.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36");
|
||||||
|
httpClient.DefaultRequestHeaders.Add("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8");
|
||||||
|
httpClient.DefaultRequestHeaders.Add("Accept-Language", "it-IT,it;q=0.9,en-US;q=0.8,en;q=0.7");
|
||||||
|
|
||||||
|
Console.WriteLine($"[StatsService] Downloading HTML from: {auctionUrl} (timeout: 5s)");
|
||||||
|
|
||||||
|
var html = await httpClient.GetStringAsync(auctionUrl);
|
||||||
|
|
||||||
|
Console.WriteLine($"[StatsService] HTML scaricato ({html.Length} chars), parsing...");
|
||||||
|
|
||||||
|
// Usa il metodo esistente di ClosedAuctionsScraper per estrarre le puntate
|
||||||
|
var bidsUsed = ExtractBidsUsedFromHtml(html);
|
||||||
|
|
||||||
|
return bidsUsed;
|
||||||
|
}
|
||||||
|
catch (TaskCanceledException)
|
||||||
{
|
{
|
||||||
AuctionId = auction.AuctionId,
|
Console.WriteLine($"[StatsService] ? Timeout durante download HTML (>5s) - URL: {auctionUrl}");
|
||||||
ProductName = auction.Name,
|
return null;
|
||||||
FinalPrice = (decimal)finalPrice,
|
}
|
||||||
BuyNowPrice = auction.BuyNowPrice.HasValue ? (decimal)auction.BuyNowPrice.Value : null,
|
catch (HttpRequestException ex)
|
||||||
ShippingCost = auction.ShippingCost.HasValue ? (decimal)auction.ShippingCost.Value : null,
|
{
|
||||||
TotalBids = auction.LastState?.MyBidsCount ?? bidsUsed, // Usa MyBidsCount se disponibile
|
Console.WriteLine($"[StatsService] ? Errore HTTP durante scraping: {ex.Message}");
|
||||||
MyBidsCount = bidsUsed,
|
return null;
|
||||||
ResetCount = auction.ResetCount,
|
|
||||||
Won = won,
|
|
||||||
WinnerUsername = won ? "ME" : auction.LastState?.LastBidder,
|
|
||||||
CompletedAt = DateTime.UtcNow,
|
|
||||||
AverageLatency = auction.LastState != null ? (decimal)auction.LastState.PollingLatencyMs : null, // PollingLatencyMs è int, non nullable
|
|
||||||
Savings = savings.HasValue ? (decimal)savings.Value : null,
|
|
||||||
TotalCost = totalCost.HasValue ? (decimal)totalCost.Value : null,
|
|
||||||
CreatedAt = DateTime.UtcNow
|
|
||||||
};
|
|
||||||
|
|
||||||
_postgresDb.CompletedAuctions.Add(completedAuction);
|
|
||||||
await _postgresDb.SaveChangesAsync();
|
|
||||||
|
|
||||||
// Aggiorna statistiche prodotto
|
|
||||||
await UpdateProductStatisticsAsync(auction, won, bidsUsed, finalPrice);
|
|
||||||
|
|
||||||
// Aggiorna metriche giornaliere
|
|
||||||
await UpdateDailyMetricsAsync(DateTime.UtcNow.Date, bidsUsed, auction.BidCost, won, savings ?? 0);
|
|
||||||
|
|
||||||
Console.WriteLine($"[PostgreSQL] Saved auction {auction.Name} to PostgreSQL");
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Console.WriteLine($"[PostgreSQL ERROR] Failed to save auction: {ex.Message}");
|
Console.WriteLine($"[StatsService] ? Errore generico durante scraping: {ex.Message}");
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Aggiorna statistiche prodotto in PostgreSQL
|
/// Estrae le puntate usate dall'HTML (stesso algoritmo di ClosedAuctionsScraper)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task UpdateProductStatisticsAsync(AuctionInfo auction, bool won, int bidsUsed, double finalPrice)
|
private int? ExtractBidsUsedFromHtml(string html)
|
||||||
{
|
{
|
||||||
if (_postgresDb == null) return;
|
if (string.IsNullOrEmpty(html)) return null;
|
||||||
|
|
||||||
|
// 1) Look for the explicit bids-used span: <p ...><span>628</span> Puntate utilizzate</p>
|
||||||
|
var match = System.Text.RegularExpressions.Regex.Match(html,
|
||||||
|
"class=\\\"bids-used\\\"[^>]*>[^<]*<span[^>]*>(?<n>[0-9]{1,7})</span>",
|
||||||
|
System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.Singleline);
|
||||||
|
|
||||||
|
if (match.Success && int.TryParse(match.Groups["n"].Value, out var val1))
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[StatsService] Puntate estratte (metodo 1 - bids-used class): {val1}");
|
||||||
|
return val1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Look for numeric followed by 'Puntate utilizzate' or similar
|
||||||
|
match = System.Text.RegularExpressions.Regex.Match(html,
|
||||||
|
"(?<n>[0-9]{1,7})\\s*(?:Puntate utilizzate|Puntate usate|puntate utilizzate|puntate usate|puntate)\\b",
|
||||||
|
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
|
if (match.Success && int.TryParse(match.Groups["n"].Value, out var val2))
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[StatsService] Puntate estratte (metodo 2 - pattern testo): {val2}");
|
||||||
|
return val2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Fallbacks
|
||||||
|
match = System.Text.RegularExpressions.Regex.Match(html,
|
||||||
|
"(?<n>[0-9]+)\\s*(?:puntate|Puntate|puntate usate|puntate_usate|pt\\.?|pts)\\b",
|
||||||
|
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
|
if (match.Success && int.TryParse(match.Groups["n"].Value, out var val3))
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[StatsService] Puntate estratte (metodo 3 - fallback): {val3}");
|
||||||
|
return val3;
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine($"[StatsService] Nessun pattern trovato per le puntate nell'HTML");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registra il completamento di un'asta (overload semplificato per compatibilità)
|
||||||
|
/// </summary>
|
||||||
|
public async Task RecordAuctionCompletedAsync(AuctionInfo auction, bool won)
|
||||||
|
{
|
||||||
|
if (auction.LastState != null)
|
||||||
|
{
|
||||||
|
await RecordAuctionCompletedAsync(auction, auction.LastState, won);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Console.WriteLine("[StatsService] Cannot record auction - LastState is null");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ottiene i limiti consigliati per un prodotto
|
||||||
|
/// </summary>
|
||||||
|
public async Task<RecommendedLimits?> GetRecommendedLimitsAsync(string productName)
|
||||||
|
{
|
||||||
|
if (_productStatsService == null) return null;
|
||||||
|
|
||||||
|
var productKey = ProductStatisticsService.GenerateProductKey(productName);
|
||||||
|
return await _productStatsService.GetRecommendedLimitsAsync(productKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ottiene le statistiche di un singolo prodotto
|
||||||
|
/// </summary>
|
||||||
|
public ProductStatisticsRecord? GetProductStats(string productKey)
|
||||||
|
{
|
||||||
|
if (_productStatsService == null || !IsAvailable) return null;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var productKey = GenerateProductKey(auction.Name);
|
// Carica statistiche dal database in modo sincrono
|
||||||
var stat = await _postgresDb.ProductStatistics.FirstOrDefaultAsync(p => p.ProductKey == productKey);
|
var allStats = _productStatsService.GetAllProductStatisticsAsync().GetAwaiter().GetResult();
|
||||||
|
return allStats.FirstOrDefault(p => p.ProductKey == productKey);
|
||||||
if (stat == null)
|
|
||||||
{
|
|
||||||
stat = new ProductStatistic
|
|
||||||
{
|
|
||||||
ProductKey = productKey,
|
|
||||||
ProductName = auction.Name,
|
|
||||||
TotalAuctions = 0,
|
|
||||||
MinBidsSeen = int.MaxValue,
|
|
||||||
MaxBidsSeen = 0,
|
|
||||||
CompetitionLevel = "Medium"
|
|
||||||
};
|
|
||||||
_postgresDb.ProductStatistics.Add(stat);
|
|
||||||
}
|
}
|
||||||
|
catch
|
||||||
stat.TotalAuctions++;
|
|
||||||
stat.AverageFinalPrice = ((stat.AverageFinalPrice * (stat.TotalAuctions - 1)) + (decimal)finalPrice) / stat.TotalAuctions;
|
|
||||||
stat.AverageResets = ((stat.AverageResets * (stat.TotalAuctions - 1)) + auction.ResetCount) / stat.TotalAuctions;
|
|
||||||
|
|
||||||
if (won)
|
|
||||||
{
|
{
|
||||||
stat.AverageWinningBids = ((stat.AverageWinningBids * Math.Max(1, stat.TotalAuctions - 1)) + bidsUsed) / stat.TotalAuctions;
|
return null;
|
||||||
}
|
|
||||||
|
|
||||||
stat.MinBidsSeen = Math.Min(stat.MinBidsSeen, bidsUsed);
|
|
||||||
stat.MaxBidsSeen = Math.Max(stat.MaxBidsSeen, bidsUsed);
|
|
||||||
stat.RecommendedMaxBids = (int)(stat.AverageWinningBids * 1.5m); // 50% buffer
|
|
||||||
stat.RecommendedMaxPrice = stat.AverageFinalPrice * 1.2m; // 20% buffer
|
|
||||||
stat.LastUpdated = DateTime.UtcNow;
|
|
||||||
|
|
||||||
// Determina livello competizione
|
|
||||||
if (stat.AverageWinningBids > 50) stat.CompetitionLevel = "High";
|
|
||||||
else if (stat.AverageWinningBids < 20) stat.CompetitionLevel = "Low";
|
|
||||||
else stat.CompetitionLevel = "Medium";
|
|
||||||
|
|
||||||
await _postgresDb.SaveChangesAsync();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"[PostgreSQL ERROR] Failed to update product statistics: {ex.Message}");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Aggiorna metriche giornaliere in PostgreSQL
|
/// Ottiene tutte le statistiche prodotto
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task UpdateDailyMetricsAsync(DateTime date, int bidsUsed, double bidCost, bool won, double savings)
|
public async Task<List<ProductStatisticsRecord>> GetAllProductStatisticsAsync()
|
||||||
{
|
{
|
||||||
if (_postgresDb == null) return;
|
if (_productStatsService == null) return new List<ProductStatisticsRecord>();
|
||||||
|
return await _productStatsService.GetAllProductStatisticsAsync();
|
||||||
try
|
|
||||||
{
|
|
||||||
var metric = await _postgresDb.DailyMetrics.FirstOrDefaultAsync(m => m.Date.Date == date.Date);
|
|
||||||
|
|
||||||
if (metric == null)
|
|
||||||
{
|
|
||||||
metric = new DailyMetric { Date = date.Date };
|
|
||||||
_postgresDb.DailyMetrics.Add(metric);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
metric.TotalBidsUsed += bidsUsed;
|
// Metodi per query statistiche
|
||||||
metric.MoneySpent += (decimal)(bidsUsed * bidCost);
|
|
||||||
if (won) metric.AuctionsWon++; else metric.AuctionsLost++;
|
|
||||||
metric.TotalSavings += (decimal)savings;
|
|
||||||
|
|
||||||
var totalAuctions = metric.AuctionsWon + metric.AuctionsLost;
|
|
||||||
if (totalAuctions > 0)
|
|
||||||
{
|
|
||||||
metric.WinRate = ((decimal)metric.AuctionsWon / totalAuctions) * 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (metric.MoneySpent > 0)
|
|
||||||
{
|
|
||||||
metric.ROI = (metric.TotalSavings / metric.MoneySpent) * 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
await _postgresDb.SaveChangesAsync();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"[PostgreSQL ERROR] Failed to update daily metrics: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Genera chiave univoca per prodotto
|
|
||||||
/// </summary>
|
|
||||||
private string GenerateProductKey(string productName)
|
|
||||||
{
|
|
||||||
var normalized = productName.ToLowerInvariant()
|
|
||||||
.Replace(" ", "_")
|
|
||||||
.Replace("-", "_");
|
|
||||||
return System.Text.RegularExpressions.Regex.Replace(normalized, "[^a-z0-9_]", "");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Ottiene raccomandazioni strategiche da PostgreSQL
|
|
||||||
/// </summary>
|
|
||||||
public async Task<List<StrategicInsight>> GetStrategicInsightsAsync(string? productKey = null)
|
|
||||||
{
|
|
||||||
if (!_postgresAvailable || _postgresDb == null)
|
|
||||||
{
|
|
||||||
return new List<StrategicInsight>();
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var query = _postgresDb.StrategicInsights.Where(i => i.IsActive);
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(productKey))
|
|
||||||
{
|
|
||||||
query = query.Where(i => i.ProductKey == productKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
return await query.OrderByDescending(i => i.ConfidenceLevel).ToListAsync();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"[PostgreSQL ERROR] Failed to get insights: {ex.Message}");
|
|
||||||
return new List<StrategicInsight>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Ottiene performance puntatori da PostgreSQL
|
|
||||||
/// </summary>
|
|
||||||
public async Task<List<BidderPerformance>> GetTopCompetitorsAsync(int limit = 10)
|
|
||||||
{
|
|
||||||
if (!_postgresAvailable || _postgresDb == null)
|
|
||||||
{
|
|
||||||
return new List<BidderPerformance>();
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
return await _postgresDb.BidderPerformances
|
|
||||||
.OrderByDescending(b => b.WinRate)
|
|
||||||
.Take(limit)
|
|
||||||
.ToListAsync();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"[PostgreSQL ERROR] Failed to get competitors: {ex.Message}");
|
|
||||||
return new List<BidderPerformance>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Metodi esistenti per compatibilità SQLite
|
|
||||||
public async Task<List<DailyStat>> GetDailyStatsAsync(int days = 30)
|
public async Task<List<DailyStat>> GetDailyStatsAsync(int days = 30)
|
||||||
{
|
{
|
||||||
|
if (!IsAvailable)
|
||||||
|
{
|
||||||
|
return new List<DailyStat>();
|
||||||
|
}
|
||||||
|
|
||||||
var to = DateTime.UtcNow;
|
var to = DateTime.UtcNow;
|
||||||
var from = to.AddDays(-days);
|
var from = to.AddDays(-days);
|
||||||
return await _db.GetDailyStatsAsync(from, to);
|
return await _db.GetDailyStatsAsync(from, to);
|
||||||
@@ -319,6 +361,11 @@ namespace AutoBidder.Services
|
|||||||
|
|
||||||
public async Task<TotalStats> GetTotalStatsAsync()
|
public async Task<TotalStats> GetTotalStatsAsync()
|
||||||
{
|
{
|
||||||
|
if (!IsAvailable)
|
||||||
|
{
|
||||||
|
return new TotalStats();
|
||||||
|
}
|
||||||
|
|
||||||
var stats = await GetDailyStatsAsync(365);
|
var stats = await GetDailyStatsAsync(365);
|
||||||
|
|
||||||
return new TotalStats
|
return new TotalStats
|
||||||
@@ -338,13 +385,23 @@ namespace AutoBidder.Services
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<AuctionResult>> GetRecentAuctionResultsAsync(int limit = 50)
|
public async Task<List<AuctionResultExtended>> GetRecentAuctionResultsAsync(int limit = 50)
|
||||||
{
|
{
|
||||||
|
if (!IsAvailable)
|
||||||
|
{
|
||||||
|
return new List<AuctionResultExtended>();
|
||||||
|
}
|
||||||
|
|
||||||
return await _db.GetRecentAuctionResultsAsync(limit);
|
return await _db.GetRecentAuctionResultsAsync(limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<double> CalculateROIAsync()
|
public async Task<double> CalculateROIAsync()
|
||||||
{
|
{
|
||||||
|
if (!IsAvailable)
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
var stats = await GetTotalStatsAsync();
|
var stats = await GetTotalStatsAsync();
|
||||||
|
|
||||||
if (stats.TotalMoneySpent <= 0)
|
if (stats.TotalMoneySpent <= 0)
|
||||||
@@ -355,11 +412,22 @@ namespace AutoBidder.Services
|
|||||||
|
|
||||||
public async Task<ChartData> GetChartDataAsync(int days = 30)
|
public async Task<ChartData> GetChartDataAsync(int days = 30)
|
||||||
{
|
{
|
||||||
|
if (!IsAvailable)
|
||||||
|
{
|
||||||
|
return new ChartData
|
||||||
|
{
|
||||||
|
Labels = new List<string>(),
|
||||||
|
MoneySpent = new List<double>(),
|
||||||
|
Savings = new List<double>()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
var stats = await GetDailyStatsAsync(days);
|
var stats = await GetDailyStatsAsync(days);
|
||||||
|
|
||||||
var allDates = new List<DailyStat>();
|
var allDates = new List<DailyStat>();
|
||||||
var startDate = DateTime.UtcNow.AddDays(-days);
|
var startDate = DateTime.UtcNow.AddDays(-days);
|
||||||
|
|
||||||
|
|
||||||
for (int i = 0; i < days; i++)
|
for (int i = 0; i < days; i++)
|
||||||
{
|
{
|
||||||
var date = startDate.AddDays(i).ToString("yyyy-MM-dd");
|
var date = startDate.AddDays(i).ToString("yyyy-MM-dd");
|
||||||
@@ -387,11 +455,6 @@ namespace AutoBidder.Services
|
|||||||
Savings = allDates.Select(s => s.TotalSavings).ToList()
|
Savings = allDates.Select(s => s.TotalSavings).ToList()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Indica se il database PostgreSQL è disponibile
|
|
||||||
/// </summary>
|
|
||||||
public bool IsPostgresAvailable => _postgresAvailable;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Classi esistenti per compatibilità
|
// Classi esistenti per compatibilità
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
@inherits LayoutComponentBase
|
||||||
|
|
||||||
|
<div class="login-page">
|
||||||
|
@Body
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.login-page {
|
||||||
|
/* Layout fullscreen per pagina login */
|
||||||
|
min-height: 100vh;
|
||||||
|
width: 100vw;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nascondi sidebar se presente */
|
||||||
|
.login-page + .sidebar,
|
||||||
|
.login-page .sidebar {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,26 +1,103 @@
|
|||||||
@inherits LayoutComponentBase
|
@inherits LayoutComponentBase
|
||||||
|
|
||||||
<div class="page">
|
<div class="app-container">
|
||||||
<div class="sidebar">
|
<aside class="app-sidebar">
|
||||||
<NavMenu />
|
<NavMenu />
|
||||||
</div>
|
</aside>
|
||||||
|
|
||||||
<main>
|
<main class="app-main">
|
||||||
<!-- UserBanner rimosso - informazioni integrate nel toolbar dell'Index.razor -->
|
<article class="app-content">
|
||||||
|
|
||||||
<article class="content">
|
|
||||||
@Body
|
@Body
|
||||||
</article>
|
</article>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="blazor-error-ui">
|
<div id="blazor-error-ui">
|
||||||
<environment include="Staging,Production">
|
<div class="error-content">
|
||||||
Si è verificato un errore.
|
<i class="bi bi-exclamation-triangle-fill"></i>
|
||||||
</environment>
|
<span>Si e verificato un errore. <a href="" class="reload">Ricarica</a></span>
|
||||||
<environment include="Development">
|
<button class="dismiss-btn" onclick="this.parentElement.parentElement.style.display='none'">×</button>
|
||||||
Si è verificato un errore non gestito. Consultare la console del browser per ulteriori informazioni.
|
</div>
|
||||||
</environment>
|
|
||||||
<a href="" class="reload">Ricarica</a>
|
|
||||||
<a class="dismiss">??</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.app-container {
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #0f0f0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-sidebar {
|
||||||
|
width: 260px;
|
||||||
|
min-width: 260px;
|
||||||
|
height: 100vh;
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main {
|
||||||
|
flex: 1;
|
||||||
|
margin-left: 260px;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-content {
|
||||||
|
flex: 1;
|
||||||
|
padding: 1.5rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blazor-error-ui {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 9999;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blazor-error-ui .error-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
background: linear-gradient(135deg, #dc3545 0%, #c82333 100%);
|
||||||
|
color: white;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blazor-error-ui .reload {
|
||||||
|
color: white;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blazor-error-ui .dismiss-btn {
|
||||||
|
margin-left: auto;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blazor-error-ui .dismiss-btn:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@@media (max-width: 768px) {
|
||||||
|
.app-sidebar {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
+272
-76
@@ -1,105 +1,301 @@
|
|||||||
|
@using Microsoft.AspNetCore.Components.Authorization
|
||||||
@inject NavigationManager NavigationManager
|
@inject NavigationManager NavigationManager
|
||||||
|
@inject AuthenticationStateProvider AuthenticationStateProvider
|
||||||
|
@inject AuctionMonitor AuctionMonitor
|
||||||
|
@implements IDisposable
|
||||||
|
|
||||||
<div class="sidebar">
|
<div class="nav-sidebar">
|
||||||
<div class="top-row ps-3 navbar navbar-dark">
|
<div class="nav-header">
|
||||||
<div class="container-fluid">
|
<a class="nav-brand" href="">
|
||||||
<a class="navbar-brand d-flex align-items-center" href="">
|
<div class="brand-icon">
|
||||||
<i class="bi bi-lightning-charge-fill me-2" style="font-size: 1.5rem; color: #ffc107;"></i>
|
<i class="bi bi-lightning-charge-fill"></i>
|
||||||
<span class="fw-bold">AutoBidder</span>
|
</div>
|
||||||
|
<span class="brand-text">AutoBidder</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<nav class="nav-menu">
|
||||||
|
<div class="nav-section">
|
||||||
|
<NavLink class="nav-menu-item" href="" Match="NavLinkMatch.All">
|
||||||
|
<i class="bi bi-display"></i>
|
||||||
|
<span>Monitor Aste</span>
|
||||||
|
</NavLink>
|
||||||
|
|
||||||
|
<NavLink class="nav-menu-item" href="browser">
|
||||||
|
<i class="bi bi-search"></i>
|
||||||
|
<span>Esplora Aste</span>
|
||||||
|
</NavLink>
|
||||||
|
|
||||||
|
<NavLink class="nav-menu-item" href="statistics">
|
||||||
|
<i class="bi bi-bar-chart"></i>
|
||||||
|
<span>Statistiche</span>
|
||||||
|
</NavLink>
|
||||||
|
|
||||||
|
<NavLink class="nav-menu-item" href="settings">
|
||||||
|
<i class="bi bi-gear"></i>
|
||||||
|
<span>Impostazioni</span>
|
||||||
|
</NavLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="nav-scrollable">
|
<div class="nav-footer">
|
||||||
<nav class="flex-column px-3 mt-3">
|
<!-- Info Sessione Utente -->
|
||||||
<div class="nav-item px-2 mb-2 animate-fade-in-left stagger-item">
|
@if (!string.IsNullOrEmpty(sessionUsername))
|
||||||
<NavLink class="nav-link hover-lift transition-all" href="" Match="NavLinkMatch.All">
|
{
|
||||||
<i class="bi bi-display me-2"></i> Monitor Aste
|
<div class="session-stats">
|
||||||
</NavLink>
|
<div class="session-stat">
|
||||||
|
<i class="bi bi-hand-index-thumb-fill"></i>
|
||||||
|
<div class="stat-content">
|
||||||
|
<span class="stat-label">Puntate</span>
|
||||||
|
<span class="stat-value @GetBidsClass()">@sessionRemainingBids</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-item px-2 mb-2 animate-fade-in-left stagger-item">
|
|
||||||
<NavLink class="nav-link hover-lift transition-all" href="freebids">
|
|
||||||
<i class="bi bi-gift me-2"></i> Puntate Gratuite
|
|
||||||
</NavLink>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-item px-2 mb-2 animate-fade-in-left stagger-item">
|
<div class="session-stat">
|
||||||
<NavLink class="nav-link hover-lift transition-all" href="statistics">
|
<i class="bi bi-wallet2"></i>
|
||||||
<i class="bi bi-bar-chart me-2"></i> Statistiche
|
<div class="stat-content">
|
||||||
</NavLink>
|
<span class="stat-label">Credito</span>
|
||||||
|
<span class="stat-value text-success">€@sessionShopCredit.ToString("F2")</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-item px-2 mb-2 animate-fade-in-left stagger-item">
|
</div>
|
||||||
<NavLink class="nav-link hover-lift transition-all" href="settings">
|
</div>
|
||||||
<i class="bi bi-gear me-2"></i> Impostazioni
|
}
|
||||||
</NavLink>
|
|
||||||
|
<AuthorizeView>
|
||||||
|
<Authorized>
|
||||||
|
<div class="user-badge @(string.IsNullOrEmpty(sessionUsername) ? "disconnected" : "connected")">
|
||||||
|
<i class="bi bi-person-circle"></i>
|
||||||
|
<span>@(string.IsNullOrEmpty(sessionUsername) ? "Non connesso" : sessionUsername)</span>
|
||||||
|
</div>
|
||||||
|
<a href="/Account/Logout" class="nav-menu-item logout-item">
|
||||||
|
<i class="bi bi-box-arrow-right"></i>
|
||||||
|
<span>Logout</span>
|
||||||
|
</a>
|
||||||
|
</Authorized>
|
||||||
|
</AuthorizeView>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private string? sessionUsername;
|
||||||
|
private int sessionRemainingBids;
|
||||||
|
private double sessionShopCredit;
|
||||||
|
private System.Threading.Timer? refreshTimer;
|
||||||
|
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
LoadSessionInfo();
|
||||||
|
|
||||||
|
// Refresh ogni 5 secondi
|
||||||
|
refreshTimer = new System.Threading.Timer(async _ =>
|
||||||
|
{
|
||||||
|
LoadSessionInfo();
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
}, null, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void LoadSessionInfo()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var session = AuctionMonitor.GetSession();
|
||||||
|
if (session != null)
|
||||||
|
{
|
||||||
|
sessionUsername = session.Username;
|
||||||
|
sessionRemainingBids = session.RemainingBids;
|
||||||
|
sessionShopCredit = session.ShopCredit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetBidsClass()
|
||||||
|
{
|
||||||
|
if (sessionRemainingBids <= 10) return "text-danger";
|
||||||
|
if (sessionRemainingBids <= 50) return "text-warning";
|
||||||
|
return "text-success";
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
refreshTimer?.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.sidebar {
|
.nav-sidebar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
|
||||||
|
|
||||||
.navbar-brand {
|
|
||||||
font-size: 1.3rem;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
color: white !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar-brand:hover {
|
|
||||||
transform: scale(1.05);
|
|
||||||
text-shadow: 0 0 10px rgba(255, 193, 7, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-scrollable {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding-bottom: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link {
|
|
||||||
border-radius: 8px;
|
|
||||||
margin: 0.25rem 0;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
font-weight: 500;
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
color: rgba(255, 255, 255, 0.8) !important;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
top: 0;
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 4px;
|
background: linear-gradient(180deg, #1a1d23 0%, #13151a 100%);
|
||||||
background: linear-gradient(to bottom, #0dcaf0, #0d6efd);
|
border-right: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
transform: scaleY(0);
|
|
||||||
transition: transform 0.3s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-link:hover {
|
.nav-header {
|
||||||
background: rgba(255, 255, 255, 0.1);
|
padding: 1.25rem 1.5rem;
|
||||||
color: white !important;
|
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-link:hover::before,
|
.nav-brand {
|
||||||
.nav-link.active::before {
|
display: flex;
|
||||||
transform: scaleY(1);
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: opacity 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-link.active {
|
.nav-brand:hover {
|
||||||
background: linear-gradient(to right, rgba(13, 202, 240, 0.2), transparent);
|
opacity: 0.9;
|
||||||
font-weight: 600;
|
}
|
||||||
color: #0dcaf0 !important;
|
|
||||||
|
.brand-icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-text {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: white;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-menu {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
padding: 1rem 0.75rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-menu-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: rgba(255, 255, 255, 0.65);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-menu-item i {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
width: 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-menu-item:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-menu-item.active {
|
||||||
|
background: linear-gradient(135deg, rgba(99, 102, 241, 0.15) 0%, rgba(139, 92, 246, 0.15) 100%);
|
||||||
|
color: #a5b4fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-menu-item.active i {
|
||||||
|
color: #818cf8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-footer {
|
.nav-footer {
|
||||||
padding: 1rem;
|
|
||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-stats {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-stat {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.625rem;
|
||||||
|
padding: 0.375rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-stat i {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
width: 1.25rem;
|
||||||
|
text-align: center;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-stat .stat-content {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex: 1;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-stat .stat-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-stat .stat-value {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-stat .text-success { color: #22c55e; }
|
||||||
|
.session-stat .text-warning { color: #f59e0b; }
|
||||||
|
.session-stat .text-danger { color: #ef4444; }
|
||||||
|
|
||||||
|
.user-badge {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-badge.connected {
|
||||||
|
border-left: 3px solid #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-badge.disconnected {
|
||||||
|
border-left: 3px solid #ef4444;
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-badge i {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-item {
|
||||||
|
color: rgba(248, 113, 113, 0.8) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-item:hover {
|
||||||
|
background: rgba(248, 113, 113, 0.1) !important;
|
||||||
|
color: #f87171 !important;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
@using Microsoft.AspNetCore.Components
|
||||||
|
@inject NavigationManager Navigation
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-center align-items-center" style="min-height: 100vh; background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);">
|
||||||
|
<div class="spinner-border text-light" role="status">
|
||||||
|
<span class="visually-hidden">Caricamento...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private bool _hasRedirected = false;
|
||||||
|
|
||||||
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
|
{
|
||||||
|
if (firstRender && !_hasRedirected)
|
||||||
|
{
|
||||||
|
_hasRedirected = true;
|
||||||
|
|
||||||
|
// Redirect semplice senza returnUrl per evitare problemi
|
||||||
|
Navigation.NavigateTo("/Account/Login", forceLoad: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
await base.OnAfterRenderAsync(firstRender);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ namespace AutoBidder.Utilities
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Calcola risparmio rispetto al prezzo "Compra Subito"
|
// Calcola risparmio rispetto al prezzo "Compra Subito"
|
||||||
if (auctionInfo.BuyNowPrice.HasValue)
|
if (auctionInfo.BuyNowPrice.HasValue && auctionInfo.BuyNowPrice.Value > 0)
|
||||||
{
|
{
|
||||||
var buyNowTotal = auctionInfo.BuyNowPrice.Value;
|
var buyNowTotal = auctionInfo.BuyNowPrice.Value;
|
||||||
if (auctionInfo.ShippingCost.HasValue)
|
if (auctionInfo.ShippingCost.HasValue)
|
||||||
@@ -50,12 +50,24 @@ namespace AutoBidder.Utilities
|
|||||||
}
|
}
|
||||||
|
|
||||||
value.Savings = buyNowTotal - value.TotalCostIfWin;
|
value.Savings = buyNowTotal - value.TotalCostIfWin;
|
||||||
|
|
||||||
|
// ?? FIX: Evita divisione per zero
|
||||||
|
if (buyNowTotal > 0)
|
||||||
|
{
|
||||||
value.SavingsPercentage = (value.Savings.Value / buyNowTotal) * 100.0;
|
value.SavingsPercentage = (value.Savings.Value / buyNowTotal) * 100.0;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Se il buyNowTotal è 0, imposta un valore fittizio negativo per indicare perdita
|
||||||
|
value.SavingsPercentage = -100.0;
|
||||||
|
}
|
||||||
|
|
||||||
value.IsWorthIt = value.Savings.Value > 0;
|
value.IsWorthIt = value.Savings.Value > 0;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Senza prezzo "Compra Subito", consideriamo sempre conveniente
|
// Senza prezzo "Compra Subito" valido, consideriamo sempre conveniente
|
||||||
|
// Questo permette di puntare su aste senza dati di riferimento
|
||||||
value.IsWorthIt = true;
|
value.IsWorthIt = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
|
||||||
@@ -7,7 +7,7 @@ namespace AutoBidder.Utilities
|
|||||||
public class AppSettings
|
public class AppSettings
|
||||||
{
|
{
|
||||||
// NUOVE IMPOSTAZIONI PREDEFINITE PER LE ASTE
|
// NUOVE IMPOSTAZIONI PREDEFINITE PER LE ASTE
|
||||||
public int DefaultBidBeforeDeadlineMs { get; set; } = 200;
|
public int DefaultBidBeforeDeadlineMs { get; set; } = 800;
|
||||||
public bool DefaultCheckAuctionOpenBeforeBid { get; set; } = false;
|
public bool DefaultCheckAuctionOpenBeforeBid { get; set; } = false;
|
||||||
public double DefaultMinPrice { get; set; } = 0;
|
public double DefaultMinPrice { get; set; } = 0;
|
||||||
public double DefaultMaxPrice { get; set; } = 0;
|
public double DefaultMaxPrice { get; set; } = 0;
|
||||||
@@ -15,6 +15,33 @@ namespace AutoBidder.Utilities
|
|||||||
public int DefaultMinResets { get; set; } = 0;
|
public int DefaultMinResets { get; set; } = 0;
|
||||||
public int DefaultMaxResets { get; set; } = 0;
|
public int DefaultMaxResets { get; set; } = 0;
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
|
// TICKER LOOP - SISTEMA DI TIMING SEMPLIFICATO
|
||||||
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Intervallo del ticker in millisecondi.
|
||||||
|
/// Più basso = più preciso ma più CPU.
|
||||||
|
/// Valori consigliati: 50-100ms
|
||||||
|
/// Default: 50ms
|
||||||
|
/// </summary>
|
||||||
|
public int TickerIntervalMs { get; set; } = 50;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Soglia in millisecondi per iniziare i controlli delle strategie.
|
||||||
|
/// Se il timer è superiore a questo valore, non vengono eseguiti i controlli.
|
||||||
|
/// Questo ottimizza le risorse evitando controlli inutili quando siamo lontani dal momento di puntare.
|
||||||
|
/// Default: 5000ms (5 secondi)
|
||||||
|
/// </summary>
|
||||||
|
public int StrategyCheckThresholdMs { get; set; } = 5000;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mostra avviso quando una puntata arriva troppo tardi (timer scaduto).
|
||||||
|
/// Suggerisce all'utente di aumentare il tempo di puntata.
|
||||||
|
/// Default: true
|
||||||
|
/// </summary>
|
||||||
|
public bool ShowLateBidWarning { get; set; } = true;
|
||||||
|
|
||||||
// LIMITI LOG
|
// LIMITI LOG
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Numero massimo di righe di log da mantenere per ogni singola asta (default: 500)
|
/// Numero massimo di righe di log da mantenere per ogni singola asta (default: 500)
|
||||||
@@ -49,7 +76,7 @@ namespace AutoBidder.Utilities
|
|||||||
// ? NUOVO: LIMITE MINIMO PUNTATE
|
// ? NUOVO: LIMITE MINIMO PUNTATE
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Numero minimo di puntate residue da mantenere sull'account.
|
/// Numero minimo di puntate residue da mantenere sull'account.
|
||||||
/// Se impostato > 0, il sistema non punterà se le puntate residue scenderebbero sotto questa soglia.
|
/// Se impostato > 0, il sistema non punterà se le puntate residue scenderebbero sotto questa soglia.
|
||||||
/// Default: 0 (nessun limite)
|
/// Default: 0 (nessun limite)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int MinimumRemainingBids { get; set; } = 0;
|
public int MinimumRemainingBids { get; set; } = 0;
|
||||||
@@ -71,26 +98,337 @@ namespace AutoBidder.Utilities
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public string MinLogLevel { get; set; } = "Normal";
|
public string MinLogLevel { get; set; } = "Normal";
|
||||||
|
|
||||||
// CONFIGURAZIONE DATABASE POSTGRESQL
|
// ???????????????????????????????????????????????????????????????
|
||||||
/// <summary>
|
// IMPOSTAZIONI DATABASE
|
||||||
/// Abilita l'uso di PostgreSQL per statistiche avanzate
|
// ???????????????????????????????????????????????????????????????
|
||||||
/// </summary>
|
|
||||||
public bool UsePostgreSQL { get; set; } = true;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Connection string PostgreSQL
|
/// Abilita il salvataggio automatico delle aste completate nel database.
|
||||||
|
/// Default: true (consigliato per statistiche)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string PostgresConnectionString { get; set; } = "Host=localhost;Port=5432;Database=autobidder_stats;Username=autobidder;Password=autobidder_password";
|
public bool DatabaseAutoSaveEnabled { get; set; } = true;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Auto-crea schema database se mancante
|
/// Esegue pulizia automatica duplicati all'avvio dell'applicazione.
|
||||||
|
/// Default: true (consigliato per mantenere database pulito)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool AutoCreateDatabaseSchema { get; set; } = true;
|
public bool DatabaseAutoCleanupDuplicates { get; set; } = true;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Fallback automatico a SQLite se PostgreSQL non disponibile
|
/// Esegue pulizia automatica record incompleti all'avvio.
|
||||||
|
/// Default: false (può rimuovere dati utili in caso di errori temporanei)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool FallbackToSQLite { get; set; } = true;
|
public bool DatabaseAutoCleanupIncomplete { get; set; } = false;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Numero massimo di giorni da mantenere nei risultati aste.
|
||||||
|
/// Record più vecchi vengono eliminati automaticamente.
|
||||||
|
/// Default: 180 (6 mesi), 0 = disabilitato
|
||||||
|
/// </summary>
|
||||||
|
public int DatabaseMaxRetentionDays { get; set; } = 180;
|
||||||
|
|
||||||
|
// ???????????????????????????????????????????????????????????????
|
||||||
|
// STRATEGIE AVANZATE DI PUNTATA
|
||||||
|
// ???????????????????????????????????????????????????????????????
|
||||||
|
|
||||||
|
// ❌ RIMOSSO: Jitter, Offset Dinamico, Latenza Adattiva
|
||||||
|
// Il timing è gestito SOLO da DefaultBidBeforeDeadlineMs
|
||||||
|
// Le strategie decidono SE puntare, non QUANDO
|
||||||
|
|
||||||
|
// 🎯 LOGGING GRANULARE
|
||||||
|
// ???????????????????????????????????????????????????????????????
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Log quando viene piazzata una puntata [BID]
|
||||||
|
/// Default: true
|
||||||
|
/// </summary>
|
||||||
|
public bool LogBids { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Log quando una strategia blocca la puntata [STRATEGY]
|
||||||
|
/// Default: true
|
||||||
|
/// </summary>
|
||||||
|
public bool LogStrategyDecisions { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Log calcoli valore prodotto [VALUE]
|
||||||
|
/// Default: false (attiva per debug)
|
||||||
|
/// </summary>
|
||||||
|
public bool LogValueCalculations { get; set; } = false;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Log rilevamento competizione e heat [COMPETITION]
|
||||||
|
/// Default: false
|
||||||
|
/// </summary>
|
||||||
|
public bool LogCompetition { get; set; } = false;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Log timing e polling (molto verbose!) [TIMING]
|
||||||
|
/// Default: false (attiva solo per debug timing)
|
||||||
|
/// </summary>
|
||||||
|
public bool LogTiming { get; set; } = false;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Log errori e warning [ERROR/WARN]
|
||||||
|
/// Default: true
|
||||||
|
/// </summary>
|
||||||
|
public bool LogErrors { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Applica automaticamente i limiti salvati nel prodotto quando si aggiunge una nuova asta.
|
||||||
|
/// Se TRUE e il prodotto ha valori di default salvati, li applica automaticamente.
|
||||||
|
/// Default: true (consigliato per coerenza)
|
||||||
|
/// </summary>
|
||||||
|
public bool AutoApplyProductDefaults { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Scelta priorità limiti quando si aggiunge un'asta per un prodotto già salvato:
|
||||||
|
/// - "ProductStats": Usa i limiti personalizzati salvati nelle statistiche prodotto (UserDefaultMinPrice, ecc.)
|
||||||
|
/// - "GlobalDefaults": Usa sempre i limiti globali (DefaultMinPrice, DefaultMaxPrice, ecc.)
|
||||||
|
/// Default: "ProductStats" (consigliato per usare limiti specifici per prodotto)
|
||||||
|
/// </summary>
|
||||||
|
public string NewAuctionLimitsPriority { get; set; } = "ProductStats";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Log stato asta (terminata, reset, ecc.) [STATUS]
|
||||||
|
/// Default: true
|
||||||
|
/// </summary>
|
||||||
|
public bool LogAuctionStatus { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Log profiling avversari [OPPONENT]
|
||||||
|
/// Default: false
|
||||||
|
/// </summary>
|
||||||
|
public bool LogOpponentProfiling { get; set; } = false;
|
||||||
|
|
||||||
|
// 🎯 STRATEGIE SEMPLIFICATE
|
||||||
|
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Entry Point: Usato SOLO per calcolare i limiti consigliati (70% del MaxPrice storico).
|
||||||
|
/// NON blocca le puntate! I limiti MinPrice/MaxPrice impostati dall'utente sono RIGIDI.
|
||||||
|
/// Default: true (per calcolo limiti consigliati)
|
||||||
|
/// </summary>
|
||||||
|
public bool EntryPointEnabled { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Anti-Bot: Rileva pattern bot (timing identico con varianza minore di 50ms)
|
||||||
|
/// e evita di competere contro bot automatici.
|
||||||
|
/// Default: true
|
||||||
|
/// </summary>
|
||||||
|
public bool AntiBotDetectionEnabled { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// User Exhaustion: Sfrutta utenti stanchi (oltre 50 puntate)
|
||||||
|
/// quando ci sono pochi altri bidder attivi.
|
||||||
|
/// Default: true
|
||||||
|
/// </summary>
|
||||||
|
public bool UserExhaustionEnabled { get; set; } = true;
|
||||||
|
|
||||||
|
// 🎯 CONTROLLO CONVENIENZA PRODOTTO
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Abilita il controllo di convenienza basato sul valore del prodotto.
|
||||||
|
/// Se attivo, blocca le puntate quando il costo totale supera il prezzo "Compra Subito"
|
||||||
|
/// di una percentuale superiore a MinSavingsPercentage.
|
||||||
|
/// Default: true
|
||||||
|
/// </summary>
|
||||||
|
public bool ValueCheckEnabled { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Percentuale minima di risparmio richiesta per continuare a puntare.
|
||||||
|
/// Valori negativi = tolleranza alla perdita.
|
||||||
|
/// Es: -5 = permetti fino al 5% di perdita rispetto al "Compra Subito"
|
||||||
|
/// 0 = blocca se costa uguale o più del "Compra Subito"
|
||||||
|
/// 10 = richiedi almeno 10% di risparmio
|
||||||
|
/// Default: -5 (permetti fino al 5% di perdita)
|
||||||
|
/// </summary>
|
||||||
|
public double MinSavingsPercentage { get; set; } = -5.0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Abilita il controllo anti-collisione hardcoded.
|
||||||
|
/// Se attivo, blocca le puntate quando ci sono 3+ bidder attivi negli ultimi 10 secondi.
|
||||||
|
/// ATTENZIONE: Questo controllo può far perdere aste competitive!
|
||||||
|
/// Default: false (DISABILITATO - non blocca mai)
|
||||||
|
/// </summary>
|
||||||
|
public bool HardcodedAntiCollisionEnabled { get; set; } = false;
|
||||||
|
|
||||||
|
// 🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥
|
||||||
|
// RILEVAMENTO COMPETIZIONE E HEAT METRIC
|
||||||
|
// 🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Abilita rilevamento competizione e heat metric.
|
||||||
|
/// Conta bidder attivi e collisioni per determinare il "calore" dell'asta.
|
||||||
|
/// Default: true
|
||||||
|
/// </summary>
|
||||||
|
public bool CompetitionDetectionEnabled { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Finestra temporale in secondi per contare bidder attivi.
|
||||||
|
/// Default: 30 (ultimi 30 secondi)
|
||||||
|
/// </summary>
|
||||||
|
public int CompetitionWindowSeconds { get; set; } = 30;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Numero minimo di bidder attivi per considerare l'asta "affollata".
|
||||||
|
/// Se >= a questa soglia, applica logica di evitamento.
|
||||||
|
/// Default: 3
|
||||||
|
/// </summary>
|
||||||
|
public int CompetitionThreshold { get; set; } = 3;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Abilita auto-pausa per aste troppo competitive.
|
||||||
|
/// Default: false (solo warning, non pausa automatica)
|
||||||
|
/// </summary>
|
||||||
|
public bool AutoPauseHotAuctions { get; set; } = false;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Soglia heat metric per auto-pausa (0-100).
|
||||||
|
/// Default: 80 (pausa se heat >= 80%)
|
||||||
|
/// </summary>
|
||||||
|
public int HeatThresholdForPause { get; set; } = 80;
|
||||||
|
|
||||||
|
// ???????????????????????????????????????????????????????????????
|
||||||
|
// SOFT RETREAT E COLLISION MANAGEMENT
|
||||||
|
// ???????????????????????????????????????????????????????????????
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Abilita soft retreat automatico dopo N collisioni consecutive.
|
||||||
|
/// Default: true
|
||||||
|
/// </summary>
|
||||||
|
public bool SoftRetreatEnabled { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Numero di collisioni consecutive per attivare soft retreat.
|
||||||
|
/// Default: 3
|
||||||
|
/// </summary>
|
||||||
|
public int SoftRetreatAfterCollisions { get; set; } = 3;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Durata pausa soft retreat in secondi.
|
||||||
|
/// Default: 30
|
||||||
|
/// </summary>
|
||||||
|
public int SoftRetreatDurationSeconds { get; set; } = 30;
|
||||||
|
|
||||||
|
// ???????????????????????????????????????????????????????????????
|
||||||
|
// PROBABILISTIC BIDDING
|
||||||
|
// ???????????????????????????????????????????????????????????????
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Abilita policy di puntata probabilistica.
|
||||||
|
/// Decide se puntare con probabilità p basata su competizione e ROI.
|
||||||
|
/// Default: false (richiede tuning)
|
||||||
|
/// </summary>
|
||||||
|
public bool ProbabilisticBiddingEnabled { get; set; } = false;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Probabilità base di puntata (0.0 - 1.0).
|
||||||
|
/// Default: 0.8 (80%)
|
||||||
|
/// </summary>
|
||||||
|
public double BaseBidProbability { get; set; } = 0.8;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fattore di riduzione probabilità per ogni bidder attivo extra.
|
||||||
|
/// Default: 0.1 (riduce del 10% per ogni bidder oltre la soglia)
|
||||||
|
/// </summary>
|
||||||
|
public double ProbabilityReductionPerBidder { get; set; } = 0.1;
|
||||||
|
|
||||||
|
// ???????????????????????????????????????????????????????????????
|
||||||
|
// OPPONENT PROFILING
|
||||||
|
// ???????????????????????????????????????????????????????????????
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Abilita profiling degli avversari.
|
||||||
|
/// Identifica utenti aggressivi e applica regole specifiche.
|
||||||
|
/// Default: true
|
||||||
|
/// </summary>
|
||||||
|
public bool OpponentProfilingEnabled { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Soglia puntate per considerare un utente "aggressivo".
|
||||||
|
/// Default: 10 (se un utente ha fatto >= 10 puntate in un'asta)
|
||||||
|
/// </summary>
|
||||||
|
public int AggressiveBidderThreshold { get; set; } = 10;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Dimensione finestra scorrevole per analisi bidder aggressivi.
|
||||||
|
/// Analizza le ultime N puntate invece del conteggio totale.
|
||||||
|
/// Default: 30 (ultime 30 puntate)
|
||||||
|
/// </summary>
|
||||||
|
public int AggressiveBidderWindowSize { get; set; } = 30;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Soglia percentuale per considerare un utente "aggressivo".
|
||||||
|
/// Se un utente ha più di X% delle puntate nella finestra, è aggressivo.
|
||||||
|
/// Default: 40 (40% delle puntate)
|
||||||
|
/// </summary>
|
||||||
|
public double AggressiveBidderPercentageThreshold { get; set; } = 40.0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Dimensione finestra per rilevamento situazioni di duello.
|
||||||
|
/// Default: 20 (ultime 20 puntate)
|
||||||
|
/// </summary>
|
||||||
|
public int DuelDetectionWindowSize { get; set; } = 20;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Azione da intraprendere con bidder aggressivi.
|
||||||
|
/// "Avoid" = evita l'asta, "Compete" = continua normalmente, "Outbid" = punta più aggressivamente
|
||||||
|
/// Default: "Compete" (cambiato da Avoid per essere meno restrittivo)
|
||||||
|
/// </summary>
|
||||||
|
public string AggressiveBidderAction { get; set; } = "Compete";
|
||||||
|
|
||||||
|
// ???????????????????????????????????????????????????????????????
|
||||||
|
// BANKROLL & SAFETY MANAGER
|
||||||
|
// ???????????????????????????????????????????????????????????????
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Abilita gestione bankroll per limitare spese.
|
||||||
|
/// Default: true
|
||||||
|
/// </summary>
|
||||||
|
public bool BankrollManagerEnabled { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Limite massimo puntate per sessione (0 = illimitato).
|
||||||
|
/// Default: 0
|
||||||
|
/// </summary>
|
||||||
|
public int MaxBidsPerSession { get; set; } = 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Limite massimo puntate per singola asta (0 = illimitato).
|
||||||
|
/// Default: 0
|
||||||
|
/// </summary>
|
||||||
|
public int MaxBidsPerAuction { get; set; } = 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Budget massimo giornaliero in euro (0 = illimitato).
|
||||||
|
/// Calcolato come: puntate usate × costo medio puntata.
|
||||||
|
/// Default: 0
|
||||||
|
/// </summary>
|
||||||
|
public double DailyBudgetEuro { get; set; } = 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Costo medio per puntata in euro (per calcolo budget).
|
||||||
|
/// Default: 0.15
|
||||||
|
/// </summary>
|
||||||
|
public double AverageBidCostEuro { get; set; } = 0.15;
|
||||||
|
|
||||||
|
// ???????????????????????????????????????????????????????????????
|
||||||
|
// LOGGING AVANZATO
|
||||||
|
// ???????????????????????????????????????????????????????????????
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Abilita logging avanzato con metriche dettagliate.
|
||||||
|
/// Include: collisioni, timer scaduto, latenza, heat metric.
|
||||||
|
/// Default: true
|
||||||
|
/// </summary>
|
||||||
|
public bool AdvancedLoggingEnabled { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Salva metriche per ogni puntata nel database.
|
||||||
|
/// Default: true
|
||||||
|
/// </summary>
|
||||||
|
public bool SaveBidMetricsToDatabase { get; set; } = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class SettingsManager
|
public static class SettingsManager
|
||||||
@@ -98,17 +436,40 @@ namespace AutoBidder.Utilities
|
|||||||
private static readonly string _folder = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "AutoBidder");
|
private static readonly string _folder = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "AutoBidder");
|
||||||
private static readonly string _file = Path.Combine(_folder, "settings.json");
|
private static readonly string _file = Path.Combine(_folder, "settings.json");
|
||||||
|
|
||||||
|
// Cache per evitare letture disco ripetute nel hot path (ticker loop 50ms)
|
||||||
|
private static readonly object _cacheLock = new();
|
||||||
|
private static AppSettings? _cached;
|
||||||
|
private static DateTime _cacheExpiry = DateTime.MinValue;
|
||||||
|
private const int CACHE_TTL_MS = 2000; // Ricarica da disco al massimo ogni 2s
|
||||||
|
|
||||||
public static AppSettings Load()
|
public static AppSettings Load()
|
||||||
{
|
{
|
||||||
|
lock (_cacheLock)
|
||||||
|
{
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
if (_cached != null && now < _cacheExpiry)
|
||||||
|
return _cached;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (!File.Exists(_file)) return new AppSettings();
|
if (!File.Exists(_file))
|
||||||
var txt = File.ReadAllText(_file);
|
{
|
||||||
var s = JsonSerializer.Deserialize<AppSettings>(txt);
|
_cached = new AppSettings();
|
||||||
if (s == null) return new AppSettings();
|
}
|
||||||
return s;
|
else
|
||||||
|
{
|
||||||
|
var txt = File.ReadAllText(_file);
|
||||||
|
_cached = JsonSerializer.Deserialize<AppSettings>(txt) ?? new AppSettings();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
_cached ??= new AppSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
_cacheExpiry = now.AddMilliseconds(CACHE_TTL_MS);
|
||||||
|
return _cached;
|
||||||
}
|
}
|
||||||
catch { return new AppSettings(); }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void Save(AppSettings settings)
|
public static void Save(AppSettings settings)
|
||||||
@@ -118,6 +479,13 @@ namespace AutoBidder.Utilities
|
|||||||
if (!Directory.Exists(_folder)) Directory.CreateDirectory(_folder);
|
if (!Directory.Exists(_folder)) Directory.CreateDirectory(_folder);
|
||||||
var txt = JsonSerializer.Serialize(settings, new JsonSerializerOptions { WriteIndented = true });
|
var txt = JsonSerializer.Serialize(settings, new JsonSerializerOptions { WriteIndented = true });
|
||||||
File.WriteAllText(_file, txt);
|
File.WriteAllText(_file, txt);
|
||||||
|
|
||||||
|
// Invalida cache così il prossimo Load() legge i nuovi valori
|
||||||
|
lock (_cacheLock)
|
||||||
|
{
|
||||||
|
_cached = settings;
|
||||||
|
_cacheExpiry = DateTime.UtcNow.AddMilliseconds(CACHE_TTL_MS);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch { }
|
catch { }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
# bump-version.ps1
|
||||||
|
# Script per incrementare automaticamente la versione del progetto
|
||||||
|
# Uso: .\bump-version.ps1 -Type [major|minor|patch]
|
||||||
|
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory=$true)]
|
||||||
|
[ValidateSet('major','minor','patch')]
|
||||||
|
[string]$Type,
|
||||||
|
|
||||||
|
[Parameter(Mandatory=$false)]
|
||||||
|
[string]$Message = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
Write-Host "?????????????????????????????????????????????????????????????????????" -ForegroundColor Cyan
|
||||||
|
Write-Host "? AutoBidder Version Bump Tool ?" -ForegroundColor Cyan
|
||||||
|
Write-Host "?????????????????????????????????????????????????????????????????????" -ForegroundColor Cyan
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# File da aggiornare
|
||||||
|
$csprojFile = "AutoBidder.csproj"
|
||||||
|
$dockerFile = "Dockerfile"
|
||||||
|
$changelogFile = "CHANGELOG.md"
|
||||||
|
|
||||||
|
# Leggi versione corrente da .csproj
|
||||||
|
Write-Host "?? Lettura versione corrente..." -ForegroundColor Yellow
|
||||||
|
$csprojContent = Get-Content $csprojFile -Raw
|
||||||
|
$versionMatch = [regex]::Match($csprojContent, '<Version>(.*?)</Version>')
|
||||||
|
|
||||||
|
if (-not $versionMatch.Success) {
|
||||||
|
Write-Host "? Impossibile trovare tag <Version> in $csprojFile" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$currentVersion = $versionMatch.Groups[1].Value
|
||||||
|
Write-Host " Versione corrente: $currentVersion" -ForegroundColor Gray
|
||||||
|
|
||||||
|
# Parse semantic version
|
||||||
|
$parts = $currentVersion -split '\.'
|
||||||
|
if ($parts.Length -ne 3) {
|
||||||
|
Write-Host "? Formato versione non valido: $currentVersion (atteso: MAJOR.MINOR.PATCH)" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$major = [int]$parts[0]
|
||||||
|
$minor = [int]$parts[1]
|
||||||
|
$patch = [int]$parts[2]
|
||||||
|
|
||||||
|
# Incrementa in base al tipo
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "?? Incremento versione ($Type)..." -ForegroundColor Yellow
|
||||||
|
|
||||||
|
switch ($Type) {
|
||||||
|
'major' {
|
||||||
|
$major++
|
||||||
|
$minor = 0
|
||||||
|
$patch = 0
|
||||||
|
Write-Host " MAJOR version bump (breaking changes)" -ForegroundColor Magenta
|
||||||
|
}
|
||||||
|
'minor' {
|
||||||
|
$minor++
|
||||||
|
$patch = 0
|
||||||
|
Write-Host " MINOR version bump (nuove feature)" -ForegroundColor Blue
|
||||||
|
}
|
||||||
|
'patch' {
|
||||||
|
$patch++
|
||||||
|
Write-Host " PATCH version bump (bug fix)" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$newVersion = "$major.$minor.$patch"
|
||||||
|
$today = Get-Date -Format "yyyy-MM-dd"
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host " $currentVersion ? $newVersion" -ForegroundColor White -BackgroundColor DarkGreen
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Aggiorna AutoBidder.csproj
|
||||||
|
Write-Host "?? Aggiornamento AutoBidder.csproj..." -ForegroundColor Yellow
|
||||||
|
$csprojContent = $csprojContent -replace '<Version>.*?</Version>', "<Version>$newVersion</Version>"
|
||||||
|
$csprojContent = $csprojContent -replace '<AssemblyVersion>.*?</AssemblyVersion>', "<AssemblyVersion>$newVersion.0</AssemblyVersion>"
|
||||||
|
$csprojContent = $csprojContent -replace '<FileVersion>.*?</FileVersion>', "<FileVersion>$newVersion.0</FileVersion>"
|
||||||
|
$csprojContent = $csprojContent -replace '<InformationalVersion>.*?</InformationalVersion>', "<InformationalVersion>$newVersion</InformationalVersion>"
|
||||||
|
Set-Content $csprojFile $csprojContent -NoNewline
|
||||||
|
Write-Host " ? $csprojFile aggiornato" -ForegroundColor Green
|
||||||
|
|
||||||
|
# Aggiorna Dockerfile
|
||||||
|
Write-Host "?? Aggiornamento Dockerfile..." -ForegroundColor Yellow
|
||||||
|
$dockerContent = Get-Content $dockerFile -Raw
|
||||||
|
$dockerContent = $dockerContent -replace 'org\.opencontainers\.image\.version=".*?"', "org.opencontainers.image.version=""$newVersion"""
|
||||||
|
Set-Content $dockerFile $dockerContent -NoNewline
|
||||||
|
Write-Host " ? $dockerFile aggiornato" -ForegroundColor Green
|
||||||
|
|
||||||
|
# Prepara voce CHANGELOG
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "?? Preparazione CHANGELOG.md..." -ForegroundColor Yellow
|
||||||
|
|
||||||
|
$changelogEntry = @"
|
||||||
|
|
||||||
|
## [$newVersion] - $today
|
||||||
|
|
||||||
|
### ? Aggiunte (Added)
|
||||||
|
|
||||||
|
-
|
||||||
|
|
||||||
|
### ?? Modifiche (Changed)
|
||||||
|
|
||||||
|
-
|
||||||
|
|
||||||
|
### ?? Correzioni (Fixed)
|
||||||
|
|
||||||
|
-
|
||||||
|
|
||||||
|
### ??? Rimossi (Removed)
|
||||||
|
|
||||||
|
-
|
||||||
|
|
||||||
|
### ?? Breaking Changes
|
||||||
|
|
||||||
|
-
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
"@
|
||||||
|
|
||||||
|
# Leggi CHANGELOG esistente
|
||||||
|
$changelogContent = Get-Content $changelogFile -Raw
|
||||||
|
|
||||||
|
# Trova dove inserire (dopo l'intestazione, prima della prima release)
|
||||||
|
$insertPattern = "(---\s*\n\s*)"
|
||||||
|
if ($changelogContent -match $insertPattern) {
|
||||||
|
$changelogContent = $changelogContent -replace $insertPattern, "$changelogEntry`$1"
|
||||||
|
} else {
|
||||||
|
# Fallback: aggiungi alla fine
|
||||||
|
$changelogContent = $changelogContent + "`n" + $changelogEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
Set-Content $changelogFile $changelogContent -NoNewline
|
||||||
|
Write-Host " ? Template CHANGELOG aggiunto per v$newVersion" -ForegroundColor Green
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "?????????????????????????????????????????????????????????????????????" -ForegroundColor Green
|
||||||
|
Write-Host "? ? VERSIONE AGGIORNATA CON SUCCESSO! ?" -ForegroundColor Green
|
||||||
|
Write-Host "?????????????????????????????????????????????????????????????????????" -ForegroundColor Green
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "?? Nuova versione: v$newVersion" -ForegroundColor White -BackgroundColor DarkGreen
|
||||||
|
Write-Host "?? Data: $today" -ForegroundColor Gray
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
Write-Host "?? PROSSIMI PASSI:" -ForegroundColor Cyan
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host " 1. Compila CHANGELOG.md con le modifiche effettuate" -ForegroundColor White
|
||||||
|
Write-Host " 2. Verifica le modifiche:" -ForegroundColor White
|
||||||
|
Write-Host " git diff" -ForegroundColor Gray
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host " 3. Commit le modifiche:" -ForegroundColor White
|
||||||
|
Write-Host " git add AutoBidder.csproj Dockerfile CHANGELOG.md" -ForegroundColor Gray
|
||||||
|
Write-Host " git commit -m ""chore: bump version to v$newVersion""" -ForegroundColor Gray
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host " 4. Crea tag Git:" -ForegroundColor White
|
||||||
|
Write-Host " git tag v$newVersion" -ForegroundColor Gray
|
||||||
|
Write-Host " git push origin docker --tags" -ForegroundColor Gray
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host " 5. Pubblica su Gitea:" -ForegroundColor White
|
||||||
|
Write-Host " Visual Studio: Tasto destro ? Pubblica ? GiteaRegistry" -ForegroundColor Gray
|
||||||
|
Write-Host " Oppure: dotnet publish /p:PublishProfile=GiteaRegistry" -ForegroundColor Gray
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
Write-Host "???????????????????????????????????????????????????????????????????" -ForegroundColor Cyan
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Mostra summary files modificati
|
||||||
|
Write-Host "?? File modificati:" -ForegroundColor Yellow
|
||||||
|
Write-Host " • $csprojFile" -ForegroundColor Gray
|
||||||
|
Write-Host " • $dockerFile" -ForegroundColor Gray
|
||||||
|
Write-Host " • $changelogFile" -ForegroundColor Gray
|
||||||
|
Write-Host ""
|
||||||
+12
-45
@@ -1,31 +1,6 @@
|
|||||||
version: '3.8'
|
version: '3.8'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# ================================================
|
|
||||||
# PostgreSQL Database (statistiche avanzate)
|
|
||||||
# ================================================
|
|
||||||
postgres:
|
|
||||||
image: postgres:16-alpine
|
|
||||||
container_name: autobidder-postgres
|
|
||||||
environment:
|
|
||||||
POSTGRES_DB: autobidder_stats
|
|
||||||
POSTGRES_USER: ${POSTGRES_USER:-autobidder}
|
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-autobidder_password}
|
|
||||||
POSTGRES_INITDB_ARGS: --encoding=UTF8
|
|
||||||
volumes:
|
|
||||||
- postgres-data:/var/lib/postgresql/data
|
|
||||||
- ./postgres-backups:/backups
|
|
||||||
ports:
|
|
||||||
- "5432:5432"
|
|
||||||
restart: unless-stopped
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-autobidder} -d autobidder_stats"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
networks:
|
|
||||||
- autobidder-network
|
|
||||||
|
|
||||||
# ================================================
|
# ================================================
|
||||||
# AutoBidder Application
|
# AutoBidder Application
|
||||||
# ================================================
|
# ================================================
|
||||||
@@ -35,35 +10,31 @@ services:
|
|||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
args:
|
args:
|
||||||
BUILD_CONFIGURATION: Release
|
BUILD_CONFIGURATION: Release
|
||||||
image: gitea.encke-hake.ts.net/alby96/mimante/autobidder:latest
|
image: gitea.encke-hake.ts.net/alby96/autobidder:latest
|
||||||
container_name: autobidder
|
container_name: autobidder
|
||||||
depends_on:
|
|
||||||
postgres:
|
|
||||||
condition: service_healthy
|
|
||||||
ports:
|
ports:
|
||||||
- "${APP_PORT:-8080}:8080" # HTTP only (simpler for Docker)
|
- "${APP_PORT:-5000}:8080" # Host:Container (HTTP only)
|
||||||
volumes:
|
volumes:
|
||||||
# Persistent data (SQLite, backups, logs)
|
# Persistent data (SQLite databases, backups, logs, keys)
|
||||||
|
# Tutti i dati persistenti sono salvati in questo volume
|
||||||
- ./Data:/app/Data
|
- ./Data:/app/Data
|
||||||
|
|
||||||
# PostgreSQL backups
|
|
||||||
- ./postgres-backups:/app/Data/backups
|
|
||||||
environment:
|
environment:
|
||||||
# ASP.NET Core
|
# ASP.NET Core
|
||||||
- ASPNETCORE_ENVIRONMENT=Production
|
- ASPNETCORE_ENVIRONMENT=Production
|
||||||
- ASPNETCORE_URLS=http://+:8080
|
- ASPNETCORE_URLS=http://+:8080
|
||||||
|
|
||||||
# PostgreSQL connection
|
# ============================================
|
||||||
- ConnectionStrings__PostgreSQL=Host=postgres;Port=5432;Database=autobidder_stats;Username=${POSTGRES_USER:-autobidder};Password=${POSTGRES_PASSWORD:-autobidder_password}
|
# DATABASE PATH - Volume persistente Docker
|
||||||
|
# ============================================
|
||||||
|
# Tutti i database SQLite e dati persistenti usano questo path
|
||||||
|
- DATA_PATH=/app/Data
|
||||||
|
|
||||||
# Database settings
|
# Autenticazione applicazione (SICUREZZA)
|
||||||
- Database__UsePostgres=${USE_POSTGRES:-true}
|
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
|
||||||
- Database__AutoCreateSchema=true
|
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
|
||||||
- Database__FallbackToSQLite=true
|
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
- Logging__LogLevel__Default=${LOG_LEVEL:-Information}
|
- Logging__LogLevel__Default=${LOG_LEVEL:-Information}
|
||||||
- Logging__LogLevel__Microsoft.EntityFrameworkCore=Warning
|
|
||||||
|
|
||||||
# Timezone
|
# Timezone
|
||||||
- TZ=Europe/Rome
|
- TZ=Europe/Rome
|
||||||
@@ -77,10 +48,6 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- autobidder-network
|
- autobidder-network
|
||||||
|
|
||||||
volumes:
|
|
||||||
postgres-data:
|
|
||||||
driver: local
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
autobidder-network:
|
autobidder-network:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|||||||
@@ -299,17 +299,22 @@
|
|||||||
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ?? RIMOSSO: hover-lift causava movimento fastidioso */
|
||||||
.hover-lift:hover {
|
.hover-lift:hover {
|
||||||
transform: translateY(-4px);
|
/* transform: translateY(-4px); - RIMOSSO */
|
||||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15);
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
background-color: rgba(255, 255, 255, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ?? RIMOSSO: hover-scale causava zoom fastidioso */
|
||||||
.hover-scale {
|
.hover-scale {
|
||||||
transition: transform 0.3s ease;
|
transition: background-color 0.2s ease, border-color 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hover-scale:hover {
|
.hover-scale:hover {
|
||||||
transform: scale(1.05);
|
/* transform: scale(1.05); - RIMOSSO */
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
border-color: rgba(13, 110, 253, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hover-rotate {
|
.hover-rotate {
|
||||||
@@ -412,8 +417,9 @@
|
|||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Rimosso effetto scale sulle righe - era fastidioso */
|
||||||
.table tbody tr:hover {
|
.table tbody tr:hover {
|
||||||
transform: scale(1.01);
|
/* transform: scale(1.01); - RIMOSSO */
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -431,8 +437,7 @@
|
|||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge:hover {
|
/* Rimosso effetto scale su badge hover */
|
||||||
transform: scale(1.1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-pulse {
|
.badge-pulse {
|
||||||
|
|||||||
@@ -585,55 +585,67 @@ body {
|
|||||||
.btn-success {
|
.btn-success {
|
||||||
background: var(--success-color);
|
background: var(--success-color);
|
||||||
color: white;
|
color: white;
|
||||||
|
transition: filter 0.2s ease, box-shadow 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-success:hover:not(:disabled) {
|
.btn-success:hover:not(:disabled) {
|
||||||
background: #059669;
|
filter: brightness(1.1);
|
||||||
|
box-shadow: 0 2px 8px rgba(16, 185, 129, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-warning {
|
.btn-warning {
|
||||||
background: var(--warning-color);
|
background: var(--warning-color);
|
||||||
color: white;
|
color: white;
|
||||||
|
transition: filter 0.2s ease, box-shadow 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-warning:hover:not(:disabled) {
|
.btn-warning:hover:not(:disabled) {
|
||||||
background: #d97706;
|
filter: brightness(1.1);
|
||||||
|
box-shadow: 0 2px 8px rgba(245, 158, 11, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-danger {
|
.btn-danger {
|
||||||
background: var(--danger-color);
|
background: var(--danger-color);
|
||||||
color: white;
|
color: white;
|
||||||
|
transition: filter 0.2s ease, box-shadow 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-danger:hover:not(:disabled) {
|
.btn-danger:hover:not(:disabled) {
|
||||||
background: #dc2626;
|
filter: brightness(1.1);
|
||||||
|
box-shadow: 0 2px 8px rgba(239, 68, 68, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background: var(--primary-color);
|
background: var(--primary-color);
|
||||||
color: white;
|
color: white;
|
||||||
|
transition: filter 0.2s ease, box-shadow 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:hover:not(:disabled) {
|
.btn-primary:hover:not(:disabled) {
|
||||||
background: #0284c7;
|
filter: brightness(1.1);
|
||||||
|
box-shadow: 0 2px 8px rgba(14, 165, 233, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
|
transition: filter 0.2s ease, box-shadow 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary:hover:not(:disabled) {
|
.btn-secondary:hover:not(:disabled) {
|
||||||
background: var(--text-muted);
|
filter: brightness(1.15);
|
||||||
|
box-shadow: 0 2px 8px rgba(100, 116, 139, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-info {
|
.btn-info {
|
||||||
background: var(--info-color);
|
background: var(--info-color);
|
||||||
color: white;
|
color: white;
|
||||||
|
transition: filter 0.2s ease, box-shadow 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-info:hover:not(:disabled) {
|
.btn-info:hover:not(:disabled) {
|
||||||
background: #2563eb;
|
filter: brightness(1.1);
|
||||||
|
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn:disabled {
|
.btn:disabled {
|
||||||
|
|||||||
+365
-96
@@ -1,68 +1,271 @@
|
|||||||
/* app-wpf.css - WPF Dark Theme + Modern Sidebar */
|
/* app-wpf.css - Modern Dark Theme */
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
/* WPF Dark Theme Palette */
|
/* Modern Dark Palette */
|
||||||
--bg-primary: #1e1e1e;
|
--bg-primary: #0f0f0f;
|
||||||
--bg-secondary: #252526;
|
--bg-secondary: #171717;
|
||||||
--bg-tertiary: #2d2d30;
|
--bg-tertiary: #1f1f1f;
|
||||||
--bg-hover: #3e3e42;
|
--bg-card: #1a1a1a;
|
||||||
--bg-selected: #094771;
|
--bg-hover: #262626;
|
||||||
--border-color: #3e3e42;
|
--bg-selected: #2d2d2d;
|
||||||
--text-primary: #ffffff;
|
--border-color: rgba(255, 255, 255, 0.08);
|
||||||
--text-secondary: #cccccc;
|
--border-subtle: rgba(255, 255, 255, 0.04);
|
||||||
--text-muted: #808080;
|
|
||||||
|
|
||||||
/* WPF Accent Colors */
|
/* Text Colors */
|
||||||
--primary-color: #007acc;
|
--text-primary: #fafafa;
|
||||||
--success-color: #00d800;
|
--text-secondary: #a1a1aa;
|
||||||
--warning-color: #ffb700;
|
--text-muted: #71717a;
|
||||||
--danger-color: #e81123;
|
|
||||||
--info-color: #00b7c3;
|
|
||||||
|
|
||||||
/* Log Syntax Colors */
|
/* Accent Colors */
|
||||||
--log-success: #00d800;
|
--primary: #6366f1;
|
||||||
--log-warning: #ffb700;
|
--primary-hover: #4f46e5;
|
||||||
--log-error: #f48771;
|
--success: #22c55e;
|
||||||
--log-info: #4ec9b0;
|
--warning: #f59e0b;
|
||||||
--log-debug: #569cd6;
|
--danger: #ef4444;
|
||||||
--log-timestamp: #808080;
|
--info: #3b82f6;
|
||||||
|
|
||||||
|
/* Gradients */
|
||||||
|
--gradient-primary: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||||
|
--gradient-success: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
|
||||||
|
--gradient-danger: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
|
||||||
|
|
||||||
|
/* Shadows */
|
||||||
|
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||||
|
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.3);
|
||||||
|
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.4);
|
||||||
|
|
||||||
|
/* Border Radius */
|
||||||
|
--radius-sm: 6px;
|
||||||
|
--radius-md: 10px;
|
||||||
|
--radius-lg: 14px;
|
||||||
|
--radius-xl: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* === GLOBAL === */
|
/* === GLOBAL RESET === */
|
||||||
* {
|
*, *::before, *::after {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
font-family: 'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: 13px;
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* === LAYOUT === */
|
/* === SCROLLBAR === */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === LAYOUT (legacy support) === */
|
||||||
.page {
|
.page {
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Sidebar Moderna - 250px come prima */
|
/* === MODERN CARD COMPONENT === */
|
||||||
|
.card-modern {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 1.5rem;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-modern:hover {
|
||||||
|
border-color: rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header-modern {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title i {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === MODERN BUTTON === */
|
||||||
|
.btn-modern {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.625rem 1.25rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary-modern {
|
||||||
|
background: var(--gradient-primary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary-modern:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success-modern {
|
||||||
|
background: var(--gradient-success);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger-modern {
|
||||||
|
background: var(--gradient-danger);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-color: rgba(255, 255, 255, 0.15);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === MODERN INPUT === */
|
||||||
|
.input-modern {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-modern:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-modern::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === BADGE === */
|
||||||
|
.badge-modern {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.25rem 0.625rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: 9999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-success {
|
||||||
|
background: rgba(34, 197, 94, 0.15);
|
||||||
|
color: #4ade80;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-warning {
|
||||||
|
background: rgba(245, 158, 11, 0.15);
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-danger {
|
||||||
|
background: rgba(239, 68, 68, 0.15);
|
||||||
|
color: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-info {
|
||||||
|
background: rgba(59, 130, 246, 0.15);
|
||||||
|
color: #60a5fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === STAT CARD === */
|
||||||
|
.stat-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card-label {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card-value {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card-change {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card-change.positive {
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card-change.negative {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar Moderna - 260px */
|
||||||
.sidebar {
|
.sidebar {
|
||||||
width: 250px;
|
width: 260px;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
left: 0;
|
left: 0;
|
||||||
top: 0;
|
top: 0;
|
||||||
background: linear-gradient(180deg, #1c2128 0%, #161b22 50%, #0d1117 100%);
|
background: linear-gradient(180deg, #1a1d23 0%, #13151a 100%);
|
||||||
border-right: 1px solid var(--border-color);
|
border-right: 1px solid var(--border-color);
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.5);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
main {
|
main {
|
||||||
margin-left: 250px;
|
margin-left: 260px;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -355,6 +558,7 @@ main {
|
|||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/* Splitter verticale tra griglia e log */
|
/* Splitter verticale tra griglia e log */
|
||||||
.splitter-vertical {
|
.splitter-vertical {
|
||||||
grid-column: 2;
|
grid-column: 2;
|
||||||
@@ -363,22 +567,28 @@ main {
|
|||||||
cursor: col-resize;
|
cursor: col-resize;
|
||||||
position: relative;
|
position: relative;
|
||||||
transition: background 0.2s ease;
|
transition: background 0.2s ease;
|
||||||
|
min-width: 6px;
|
||||||
|
width: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.splitter-vertical:hover {
|
.splitter-vertical:hover {
|
||||||
background: var(--primary-color);
|
background: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.splitter-vertical::after {
|
.splitter-vertical::before {
|
||||||
content: '';
|
content: '⋮';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
width: 2px;
|
color: var(--text-muted);
|
||||||
height: 40px;
|
font-size: 16px;
|
||||||
background: var(--text-muted);
|
opacity: 0.5;
|
||||||
border-radius: 1px;
|
}
|
||||||
|
|
||||||
|
.splitter-vertical:hover::before {
|
||||||
|
color: white;
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Log globale - colonna destra */
|
/* Log globale - colonna destra */
|
||||||
@@ -395,7 +605,7 @@ main {
|
|||||||
|
|
||||||
/* Splitter orizzontale tra top e dettagli */
|
/* Splitter orizzontale tra top e dettagli */
|
||||||
.splitter-horizontal {
|
.splitter-horizontal {
|
||||||
height: 4px;
|
height: 6px;
|
||||||
background: var(--border-color);
|
background: var(--border-color);
|
||||||
cursor: row-resize;
|
cursor: row-resize;
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -404,19 +614,23 @@ main {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.splitter-horizontal:hover {
|
.splitter-horizontal:hover {
|
||||||
background: var(--primary-color);
|
background: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.splitter-horizontal::after {
|
.splitter-horizontal::before {
|
||||||
content: '';
|
content: '⋯';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
width: 40px;
|
color: var(--text-muted);
|
||||||
height: 2px;
|
font-size: 16px;
|
||||||
background: var(--text-muted);
|
opacity: 0.5;
|
||||||
border-radius: 1px;
|
}
|
||||||
|
|
||||||
|
.splitter-horizontal:hover::before {
|
||||||
|
color: white;
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dettagli asta - sotto splitter orizzontale */
|
/* Dettagli asta - sotto splitter orizzontale */
|
||||||
@@ -500,8 +714,9 @@ main {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 🔥 COMPATTATO: Ridotto padding per massimizzare spazio */
|
||||||
.tab-panel-content {
|
.tab-panel-content {
|
||||||
padding: 1rem;
|
padding: 0.5rem 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* === GRADIENTS FOR CARDS === */
|
/* === GRADIENTS FOR CARDS === */
|
||||||
@@ -669,24 +884,78 @@ main {
|
|||||||
background: var(--bg-tertiary);
|
background: var(--bg-tertiary);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
padding: 0.75rem;
|
padding: 0.5rem;
|
||||||
margin: 0.5rem;
|
margin: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 🔥 COMPATTATO: Ridotto margin e padding per info-group */
|
||||||
.info-group {
|
.info-group {
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-group label {
|
.info-group label {
|
||||||
display: block;
|
display: block;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin-bottom: 0.25rem;
|
margin-bottom: 0.15rem;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: 0.813rem;
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 🔥 COMPATTATO: Input più piccoli */
|
||||||
|
.info-group input.form-control,
|
||||||
|
.info-group select.form-control {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 🔥 GRIGLIA IMPOSTAZIONI COMPATTA */
|
||||||
|
.settings-grid-compact {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-grid-compact .setting-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-grid-compact .setting-item label {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-grid-compact .setting-item label i {
|
||||||
|
margin-right: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 🔥 Input stretti per valori numerici */
|
||||||
|
.input-narrow {
|
||||||
|
max-width: 90px !important;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0.2rem 0.4rem !important;
|
||||||
|
font-size: 0.8rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive: su schermi piccoli, 2 colonne */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.settings-grid-compact {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
.input-narrow {
|
||||||
|
max-width: 100% !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.auction-log, .bidders-stats {
|
.auction-log, .bidders-stats {
|
||||||
margin: 0.5rem;
|
margin: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auction-log h4, .bidders-stats h4 {
|
.auction-log h4, .bidders-stats h4 {
|
||||||
@@ -1024,56 +1293,56 @@ main {
|
|||||||
margin-right: 0.5rem;
|
margin-right: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/* === PRODUCT INFO COMPATTO === */
|
/* === PRODUCT INFO COMPATTO === */
|
||||||
.product-info-compact {
|
.product-info-compact {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Card info principali - orizzontali */
|
/* Card info principali - orizzontali compatte */
|
||||||
.info-cards {
|
.info-cards {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(2, 1fr);
|
||||||
gap: 0.75rem;
|
gap: 0.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-card {
|
.info-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0.5rem;
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.4rem 0.6rem;
|
||||||
border-radius: 6px;
|
border-radius: 4px;
|
||||||
border: 1px solid;
|
border: 1px solid;
|
||||||
transition: all 0.2s ease;
|
transition: background-color 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-card:hover {
|
.info-card:hover {
|
||||||
transform: translateY(-1px);
|
background: var(--bg-hover);
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-card i {
|
.info-card i {
|
||||||
font-size: 1.75rem;
|
font-size: 1.1rem;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-card div {
|
.info-card div {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.125rem;
|
gap: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-card small {
|
.info-card small {
|
||||||
font-size: 0.688rem;
|
font-size: 0.6rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.3px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-card strong {
|
.info-card strong {
|
||||||
font-size: 1.125rem;
|
font-size: 0.9rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
@@ -1096,26 +1365,26 @@ main {
|
|||||||
color: var(--info-color);
|
color: var(--info-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Calcoli inline - 4 colonne */
|
/* Calcoli inline - 4 colonne compatte */
|
||||||
.calc-inline {
|
.calc-inline {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, 1fr);
|
grid-template-columns: repeat(4, 1fr);
|
||||||
gap: 0.5rem;
|
gap: 0.3rem;
|
||||||
padding: 0.75rem;
|
padding: 0.4rem;
|
||||||
background: var(--bg-tertiary);
|
background: var(--bg-tertiary);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 6px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.calc-item {
|
.calc-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.25rem;
|
gap: 0.1rem;
|
||||||
padding: 0.5rem;
|
padding: 0.25rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
border-radius: 4px;
|
border-radius: 3px;
|
||||||
transition: all 0.2s ease;
|
transition: background-color 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.calc-item:hover {
|
.calc-item:hover {
|
||||||
@@ -1128,7 +1397,7 @@ main {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.calc-item i {
|
.calc-item i {
|
||||||
font-size: 1.25rem;
|
font-size: 0.9rem;
|
||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1137,13 +1406,13 @@ main {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.calc-item .label {
|
.calc-item .label {
|
||||||
font-size: 0.688rem;
|
font-size: 0.6rem;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.calc-item .value {
|
.calc-item .value {
|
||||||
font-size: 1rem;
|
font-size: 0.85rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
@@ -1152,30 +1421,30 @@ main {
|
|||||||
.totals-compact {
|
.totals-compact {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr auto;
|
grid-template-columns: 1fr 1fr auto;
|
||||||
gap: 0.75rem;
|
gap: 0.4rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.total-item {
|
.total-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.25rem;
|
gap: 0.1rem;
|
||||||
padding: 0.75rem;
|
padding: 0.4rem 0.6rem;
|
||||||
background: var(--bg-tertiary);
|
background: var(--bg-tertiary);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 6px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.total-item span {
|
.total-item span {
|
||||||
font-size: 0.75rem;
|
font-size: 0.65rem;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.375rem;
|
gap: 0.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.total-item strong {
|
.total-item strong {
|
||||||
font-size: 1.125rem;
|
font-size: 0.9rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1195,10 +1464,10 @@ main {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 0.5rem;
|
gap: 0.3rem;
|
||||||
padding: 0.75rem 1.5rem;
|
padding: 0.4rem 0.8rem;
|
||||||
border-radius: 6px;
|
border-radius: 4px;
|
||||||
font-size: 1rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
@@ -1216,7 +1485,7 @@ main {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.verdict-badge i {
|
.verdict-badge i {
|
||||||
font-size: 1.125rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* === RESPONSIVE === */
|
/* === RESPONSIVE === */
|
||||||
@@ -1310,8 +1579,8 @@ main {
|
|||||||
.table-fixed .col-prezzo { width: 90px; }
|
.table-fixed .col-prezzo { width: 90px; }
|
||||||
.table-fixed .col-timer { width: 90px; }
|
.table-fixed .col-timer { width: 90px; }
|
||||||
.table-fixed .col-ultimo { width: 120px; }
|
.table-fixed .col-ultimo { width: 120px; }
|
||||||
.table-fixed .col-click { width: 70px; text-align: center; }
|
.table-fixed .col-click { width: 90px; text-align: center; padding-right: 10px; }
|
||||||
.table-fixed .col-ping { width: 80px; }
|
.table-fixed .col-ping { width: 90px; padding-left: 10px; }
|
||||||
.table-fixed .col-azioni { width: 150px; }
|
.table-fixed .col-azioni { width: 150px; }
|
||||||
|
|
||||||
.table-fixed td {
|
.table-fixed td {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -76,6 +76,7 @@
|
|||||||
window.Blazor.addEventListener('enhancedload', initLogScroll);
|
window.Blazor.addEventListener('enhancedload', initLogScroll);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Esporta funzione per forzare scroll
|
// Esporta funzione per forzare scroll
|
||||||
window.forceLogScrollToBottom = function () {
|
window.forceLogScrollToBottom = function () {
|
||||||
logBoxes.forEach(logBox => {
|
logBoxes.forEach(logBox => {
|
||||||
@@ -83,4 +84,18 @@
|
|||||||
scrollToBottom(logBox);
|
scrollToBottom(logBox);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Funzione chiamabile da Blazor per scroll specifico elemento
|
||||||
|
window.scrollToBottom = function (elementId) {
|
||||||
|
const element = document.getElementById(elementId);
|
||||||
|
if (element) {
|
||||||
|
// Controlla se siamo già in fondo o quasi (entro 100px)
|
||||||
|
const isNearBottom = element.scrollHeight - element.scrollTop - element.clientHeight < 100;
|
||||||
|
|
||||||
|
// Auto-scroll solo se siamo già in fondo (non interrompe lettura manuale)
|
||||||
|
if (isNearBottom || !userScrolling.get(element)) {
|
||||||
|
element.scrollTop = element.scrollHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
Reference in New Issue
Block a user