feat: comprehensive app improvements with Pinia state management

Backend:
- Add API keys management (create, list, delete endpoints)
- Add email verification flow (verify, resend verification)
- Add account management (profile, change password, delete account)
- Add billing/Stripe integration (checkout, portal, webhooks)
- Add GeoIP service for analytics
- Add bulk link creation and link restore endpoints
- Add QR code analytics endpoint
- Add project description field with migration
- Add QR code name and logo support with migration
- Improve QR code generator with logo overlay support
- Add rate limiting middleware
- Update tests for new functionality

Frontend:
- Refactor entire app to use Pinia for state management
- Add auth store with initialization, login, register, logout
- Add workspace store with CRUD for workspaces, projects, links,
  QR codes, domains, assets, and analytics
- Add localStorage persistence for workspace selection
- Update App.vue with proper store initialization
- Update AppLayout.vue to use store methods instead of direct API
- Refactor Projects.vue and Domains.vue to use store state/actions
- Add VerifyEmail.vue for email verification flow
- Add ForgotPassword.vue and ResetPassword.vue
- Add Settings.vue with profile, password, API keys, danger zone
- Add QRCodeDetail.vue for QR code analytics
- Add Billing.vue for subscription management
- Expand api/client.js with all new API methods
- Add workspace change watchers for automatic data refresh

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-30 18:53:03 -05:00
parent abf7968911
commit e7d96f5508
100 changed files with 11424 additions and 254 deletions

View File

@@ -175,13 +175,13 @@
---
## Phase 6: Frontend Dashboard (In Progress)
## Phase 6: Frontend Dashboard (Complete)
### Authentication UI
- [x] Login page
- [x] Registration page
- [ ] Forgot password page
- [ ] Password reset page
- [x] Forgot password page
- [x] Password reset page
- [x] Auth state management (Pinia store)
### Dashboard
@@ -198,17 +198,17 @@
### QR Designer UI
- [x] QR designer page
- [x] Color pickers
- [~] Shape selectors (basic support)
- [ ] Logo upload integration
- [x] Shape selectors (Square, Rounded, Dots for modules; Square, Rounded, Circle for eyes)
- [x] Logo upload integration (upload new or select from existing assets)
- [x] Live preview (for saved QR codes)
- [x] Export buttons (PNG/SVG)
- [x] Style presets (6 presets)
- [x] Style presets (6 presets with shape variations)
### Analytics UI
- [x] Charts (time series with clicks/scans)
- [x] Stat cards (clicks, scans, visitors, total)
- [x] Breakdown tables (referrer, device)
- [~] Geo breakdown (API ready, UI pending)
- [x] Geo breakdown (country flags and names, requires MaxMind GeoIP2 database)
---
@@ -230,8 +230,7 @@
### Plan & Quotas
- [ ] Usage tracking
- [ ] Plan limits enforcement
- Free: 50 links, 1 workspace
- Pro: 5,000 links, 5 workspaces
- Free: 50 links, 1 workspacf - Pro: 5,000 links, 5 workspaces
- Business: Unlimited
- [ ] Upgrade prompts
@@ -297,6 +296,131 @@ Completed:
---
## Gap Analysis (Spec vs Implementation)
> This section identifies gaps between the MVP spec (`docs/spec.md`) and the current implementation.
### Authentication & Account
| Spec Requirement | Status | Notes |
|-----------------|--------|-------|
| Email verification | ❌ Missing | Endpoint structure exists, but no email sending or verification flow |
| Basic account settings page | ❌ Missing | No settings UI or endpoints for profile updates |
| SSO (optional, post-MVP) | ⏳ Deferred | As expected |
### Short Link Features
| Spec Requirement | Status | Notes |
|-----------------|--------|-------|
| UTM builder (preset templates) | ❌ Missing | Spec mentions UTM builder for Pro plan |
| Destination URL allowlist/denylist | ❌ Missing | Abuse prevention not implemented |
| Soft delete for links | ❌ Missing | Currently using hard delete |
### QR Code Designer
| Spec Requirement | Status | Notes |
|-----------------|--------|-------|
| Shape presets (module shapes) | ✅ Complete | Square, Rounded, Dots module shapes supported |
| Eye shape customization | ✅ Complete | Square, Rounded, Circle eye shapes supported |
| Logo upload integration | ✅ Complete | Upload new or select from existing assets |
| Logo size + margin controls | ⚠️ Partial | Fixed 20% size, no user controls |
| Print-ready options ("high contrast" toggle) | ❌ Missing | No print optimization features |
### Analytics & Tracking
| Spec Requirement | Status | Notes |
|-----------------|--------|-------|
| Geo (country) breakdown | ✅ Complete | MaxMind GeoIP2 integrated, UI with country flags |
| Per-QR analytics endpoint | ❌ Missing | Spec: `GET /analytics/qrcode/{id}` - only link analytics exist |
| Scan vs Click distinction via `?qr=` param | ⚠️ Partial | Event type exists but QR export doesn't append `?qr=<id>` to URLs |
| Custom date range filter | ❌ Missing | Only 24h/7d/30d implemented, spec mentions custom range |
| Monthly IP salt rotation | ❌ Missing | Spec requires rotating salt for privacy compliance |
| Event retention configuration per plan | ❌ Missing | No retention policy or cleanup jobs |
### Admin & Quotas
| Spec Requirement | Status | Notes |
|-----------------|--------|-------|
| Subscription status display | ❌ Missing | Plan field exists on Workspace but no UI |
| Usage quotas enforcement | ❌ Missing | No limits enforced for links/QRs/events/domains |
| Upgrade prompts | ❌ Missing | No paywall or upgrade flows |
### Security & Non-Functional
| Spec Requirement | Status | Notes |
|-----------------|--------|-------|
| Rate limiting on public endpoints | ❌ Missing | Critical for redirect endpoint |
| CORS configuration | ❌ Missing | Needs proper configuration |
| Strict CSP headers | ❌ Missing | App pages have no CSP |
| Request logging | ❌ Missing | No structured logging |
| Error handling middleware | ❌ Missing | No global error handler |
### Frontend UI Pages
| Spec Requirement | Status | Notes |
|-----------------|--------|-------|
| Forgot password page | ✅ Complete | Full UI with success state |
| Password reset page | ✅ Complete | Full UI with token validation and success state |
| Projects list UI | ❌ Missing | Backend CRUD complete, no frontend |
| Domains page (add/verify) | ❌ Missing | Backend complete, no frontend |
| Workspace switcher (full UI) | ⚠️ Partial | Basic switcher exists, no create/manage UI |
| Per-QR analytics view | ❌ Missing | Only per-link analytics in UI |
### Email System
| Spec Requirement | Status | Notes |
|-----------------|--------|-------|
| Email service integration | ❌ Missing | No email provider configured |
| Email verification emails | ❌ Missing | No templates or sending logic |
| Password reset emails | ❌ Missing | Token generated but not emailed |
| Email templates | ❌ Missing | No templating system |
### Background Jobs
| Spec Requirement | Status | Notes |
|-----------------|--------|-------|
| Domain verification checks | ❌ Missing | Only manual verification, no periodic checks |
| Event enrichment (geo/device) | ✅ Complete | Device parsing and GeoIP country lookup done |
| Cleanup & retention tasks | ❌ Missing | No scheduled cleanup for old events |
### API Surface Gaps
| Endpoint (from spec) | Status |
|---------------------|--------|
| `GET /analytics/qrcode/{id}` | ❌ Missing |
| Account settings endpoints | ❌ Missing |
| Usage/quota endpoints | ❌ Missing |
---
## Priority Gap Resolution
### High Priority (MVP Blockers)
1. **Email system** - Verification and password reset cannot work without email
2. **Rate limiting** - Security risk without it on public redirect
3. **QR scan tracking** - QR exports need `?qr=<id>` param for scan attribution
4. ~~**Geo breakdown** - GeoIP integration for country-level analytics~~ ✅ Complete
5. **Projects UI** - Backend exists, needs frontend
### Medium Priority (MVP Polish)
6. **Account settings page** - Users need to update profile
7. **Domains UI** - Backend exists, needs frontend
8. **Usage quotas** - Enforce plan limits
9. ~~**QR shape presets** - More customization options~~ ✅ Complete (Square, Rounded, Dots)
10. **Custom date range** - Analytics flexibility
### Lower Priority (Post-MVP)
11. UTM builder
12. Soft delete for links
13. Print-ready QR options
14. SSO integration
15. Stripe payments
---
## Notes
- Backend uses FastEndpoints (not traditional MVC controllers)

View File

@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Testcontainers.PostgreSql;
@@ -13,8 +14,36 @@ public sealed class ApiWebApplicationFactory : WebApplicationFactory<Program>, I
private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder("postgres:latest")
.Build();
private bool _containerStarted = false;
private void EnsureContainerStarted()
{
if (!_containerStarted)
{
_postgres.StartAsync().GetAwaiter().GetResult();
_containerStarted = true;
}
}
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
// Ensure container is started before we need the connection string
EnsureContainerStarted();
builder.UseEnvironment("Testing");
// Set environment variables for configuration (these take precedence)
Environment.SetEnvironmentVariable("Jwt__Secret", "test-secret-key-min-32-characters-long-for-hmac256!");
Environment.SetEnvironmentVariable("Jwt__Issuer", "TrakQR");
Environment.SetEnvironmentVariable("Jwt__Audience", "TrakQR");
Environment.SetEnvironmentVariable("Jwt__ExpirationMinutes", "60");
Environment.SetEnvironmentVariable("Email__Provider", "console");
Environment.SetEnvironmentVariable("Stripe__SecretKey", "sk_test_fake_key");
Environment.SetEnvironmentVariable("Stripe__WebhookSecret", "whsec_fake_secret");
Environment.SetEnvironmentVariable("Stripe__ProPriceId", "price_test_pro");
Environment.SetEnvironmentVariable("Stripe__BusinessPriceId", "price_test_business");
Environment.SetEnvironmentVariable("ConnectionStrings__PostgresConnection", _postgres.GetConnectionString());
builder.ConfigureTestServices(services =>
{
// Remove existing DbContext registration
@@ -29,12 +58,14 @@ public sealed class ApiWebApplicationFactory : WebApplicationFactory<Program>, I
// Add DbContext with Testcontainers connection string
services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(_postgres.GetConnectionString()));
});
}
public async Task InitializeAsync()
{
await _postgres.StartAsync();
// Ensure container is started (might already be started from ConfigureWebHost)
EnsureContainerStarted();
// Run migrations
using var scope = Services.CreateScope();
@@ -47,4 +78,19 @@ public sealed class ApiWebApplicationFactory : WebApplicationFactory<Program>, I
await _postgres.DisposeAsync();
await base.DisposeAsync();
}
/// <summary>
/// Upgrades a workspace to Pro plan for testing features that require paid plans.
/// </summary>
public async Task UpgradeWorkspaceToPro(Guid workspaceId)
{
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var workspace = await db.Workspaces.FindAsync(workspaceId);
if (workspace != null)
{
workspace.Plan = api.Models.WorkspacePlan.Pro;
await db.SaveChangesAsync();
}
}
}

View File

@@ -168,7 +168,7 @@ public class AuthControllerTests(ApiWebApplicationFactory factory)
}
[Fact]
public async Task ResetPassword_ReturnsNotImplemented()
public async Task ResetPassword_WithInvalidToken_ReturnsBadRequest()
{
// Arrange
var request = new { Token = "some-token", NewPassword = "newpassword123" };
@@ -181,7 +181,7 @@ public class AuthControllerTests(ApiWebApplicationFactory factory)
var result = await response.Content.ReadFromJsonAsync<MessageResponse>();
result.Should().NotBeNull();
result!.Message.Should().Be("Password reset is not yet available");
result!.Message.Should().Be("Invalid or expired reset token");
}
[Fact]

View File

@@ -13,7 +13,7 @@ public class DomainEndpointTests(ApiWebApplicationFactory factory)
{
private readonly HttpClient _client = factory.CreateClient();
private async Task<(string Token, Guid WorkspaceId)> GetAuthAndWorkspaceAsync(string email)
private async Task<(string Token, Guid WorkspaceId)> GetAuthAndWorkspaceAsync(string email, bool upgradeToPro = true)
{
var response = await _client.PostAsJsonAsync("/auth/register", new { Email = email, Password = "password123" });
if (response.StatusCode == HttpStatusCode.Conflict)
@@ -28,6 +28,12 @@ public class DomainEndpointTests(ApiWebApplicationFactory factory)
var workspaces = await workspacesResponse.Content.ReadFromJsonAsync<WorkspaceListResponse>();
var workspaceId = workspaces!.Workspaces.First().Id;
// Upgrade to Pro plan for domain tests (Free plan doesn't allow custom domains)
if (upgradeToPro)
{
await factory.UpgradeWorkspaceToPro(workspaceId);
}
return (token, workspaceId);
}

View File

@@ -12,7 +12,7 @@ public class WorkspaceEndpointTests(ApiWebApplicationFactory factory)
{
private readonly HttpClient _client = factory.CreateClient();
private async Task<string> GetAuthTokenAsync(string email = "workspace-test@example.com")
private async Task<(string Token, Guid WorkspaceId)> GetAuthAndWorkspaceAsync(string email, bool upgradeToPro = false)
{
var response = await _client.PostAsJsonAsync("/auth/register", new { Email = email, Password = "password123" });
if (response.StatusCode == HttpStatusCode.Conflict)
@@ -20,7 +20,25 @@ public class WorkspaceEndpointTests(ApiWebApplicationFactory factory)
response = await _client.PostAsJsonAsync("/auth/login", new { Email = email, Password = "password123" });
}
var result = await response.Content.ReadFromJsonAsync<AuthResponse>();
return result!.Token;
var token = result!.Token;
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var workspacesResponse = await _client.GetAsync("/workspaces");
var workspaces = await workspacesResponse.Content.ReadFromJsonAsync<WorkspaceListResponse>();
var workspaceId = workspaces!.Workspaces.First().Id;
if (upgradeToPro)
{
await factory.UpgradeWorkspaceToPro(workspaceId);
}
return (token, workspaceId);
}
private async Task<string> GetAuthTokenAsync(string email = "workspace-test@example.com")
{
var (token, _) = await GetAuthAndWorkspaceAsync(email);
return token;
}
[Fact]
@@ -53,8 +71,8 @@ public class WorkspaceEndpointTests(ApiWebApplicationFactory factory)
[Fact]
public async Task CreateWorkspace_WithValidData_ReturnsCreated()
{
// Arrange
var token = await GetAuthTokenAsync("create-ws@example.com");
// Arrange - upgrade to Pro to allow creating additional workspaces
var (token, _) = await GetAuthAndWorkspaceAsync("create-ws@example.com", upgradeToPro: true);
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
// Act
@@ -86,21 +104,18 @@ public class WorkspaceEndpointTests(ApiWebApplicationFactory factory)
[Fact]
public async Task GetWorkspace_WithValidId_ReturnsWorkspace()
{
// Arrange
var token = await GetAuthTokenAsync("get-ws@example.com");
// Arrange - use the default workspace (created on registration)
var (token, workspaceId) = await GetAuthAndWorkspaceAsync("get-ws@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var createResponse = await _client.PostAsJsonAsync("/workspaces", new { Name = "Get Test" });
var created = await createResponse.Content.ReadFromJsonAsync<WorkspaceResponse>();
// Act
var response = await _client.GetAsync($"/workspaces/{created!.Id}");
var response = await _client.GetAsync($"/workspaces/{workspaceId}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<WorkspaceResponse>();
result!.Id.Should().Be(created.Id);
result.Name.Should().Be("Get Test");
result!.Id.Should().Be(workspaceId);
result.Name.Should().NotBeNullOrEmpty();
}
[Fact]
@@ -120,15 +135,12 @@ public class WorkspaceEndpointTests(ApiWebApplicationFactory factory)
[Fact]
public async Task UpdateWorkspace_WithValidData_ReturnsUpdated()
{
// Arrange
var token = await GetAuthTokenAsync("update-ws@example.com");
// Arrange - use the default workspace (created on registration)
var (token, workspaceId) = await GetAuthAndWorkspaceAsync("update-ws@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var createResponse = await _client.PostAsJsonAsync("/workspaces", new { Name = "Original Name" });
var created = await createResponse.Content.ReadFromJsonAsync<WorkspaceResponse>();
// Act
var response = await _client.PutAsJsonAsync($"/workspaces/{created!.Id}", new { Name = "Updated Name" });
var response = await _client.PutAsJsonAsync($"/workspaces/{workspaceId}", new { Name = "Updated Name" });
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
@@ -139,8 +151,8 @@ public class WorkspaceEndpointTests(ApiWebApplicationFactory factory)
[Fact]
public async Task DeleteWorkspace_WithValidId_ReturnsSuccess()
{
// Arrange
var token = await GetAuthTokenAsync("delete-ws@example.com");
// Arrange - upgrade to Pro to allow creating additional workspaces
var (token, _) = await GetAuthAndWorkspaceAsync("delete-ws@example.com", upgradeToPro: true);
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var createResponse = await _client.PostAsJsonAsync("/workspaces", new { Name = "To Delete" });

View File

@@ -14,6 +14,9 @@ public class AppDbContext(DbContextOptions<AppDbContext> options)
public DbSet<QRCodeDesign> QrCodeDesigns => Set<QRCodeDesign>();
public DbSet<Event> Events => Set<Event>();
public DbSet<Asset> Assets => Set<Asset>();
public DbSet<PasswordResetToken> PasswordResetTokens => Set<PasswordResetToken>();
public DbSet<EmailVerificationToken> EmailVerificationTokens => Set<EmailVerificationToken>();
public DbSet<ApiKey> ApiKeys => Set<ApiKey>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -177,5 +180,49 @@ public class AppDbContext(DbContextOptions<AppDbContext> options)
.HasForeignKey(e => e.WorkspaceId)
.OnDelete(DeleteBehavior.Cascade);
});
// PasswordResetToken configuration
modelBuilder.Entity<PasswordResetToken>(entity =>
{
entity.HasKey(e => e.Id);
entity.HasIndex(e => e.Token).IsUnique();
entity.Property(e => e.Token).HasMaxLength(64);
entity.Property(e => e.CreatedAt).HasDefaultValueSql("CURRENT_TIMESTAMP");
entity.HasOne(e => e.User)
.WithMany()
.HasForeignKey(e => e.UserId)
.OnDelete(DeleteBehavior.Cascade);
});
// EmailVerificationToken configuration
modelBuilder.Entity<EmailVerificationToken>(entity =>
{
entity.HasKey(e => e.Id);
entity.HasIndex(e => e.Token).IsUnique();
entity.Property(e => e.Token).HasMaxLength(64);
entity.Property(e => e.CreatedAt).HasDefaultValueSql("CURRENT_TIMESTAMP");
entity.HasOne(e => e.User)
.WithMany()
.HasForeignKey(e => e.UserId)
.OnDelete(DeleteBehavior.Cascade);
});
// ApiKey configuration
modelBuilder.Entity<ApiKey>(entity =>
{
entity.HasKey(e => e.Id);
entity.HasIndex(e => e.KeyHash).IsUnique();
entity.Property(e => e.Name).HasMaxLength(100);
entity.Property(e => e.KeyHash).HasMaxLength(64);
entity.Property(e => e.KeyPrefix).HasMaxLength(16);
entity.Property(e => e.CreatedAt).HasDefaultValueSql("CURRENT_TIMESTAMP");
entity.HasOne(e => e.Workspace)
.WithMany()
.HasForeignKey(e => e.WorkspaceId)
.OnDelete(DeleteBehavior.Cascade);
});
}
}

View File

@@ -25,7 +25,8 @@ public record WorkspaceAnalyticsResponse(
IEnumerable<TimeSeriesPoint> TimeSeries,
IEnumerable<BreakdownItem> TopLinks,
IEnumerable<BreakdownItem> DeviceBreakdown,
IEnumerable<BreakdownItem> ReferrerBreakdown
IEnumerable<BreakdownItem> ReferrerBreakdown,
IEnumerable<BreakdownItem> CountryBreakdown
);
public record LinkAnalyticsResponse(
@@ -34,5 +35,6 @@ public record LinkAnalyticsResponse(
AnalyticsSummary Summary,
IEnumerable<TimeSeriesPoint> TimeSeries,
IEnumerable<BreakdownItem> DeviceBreakdown,
IEnumerable<BreakdownItem> ReferrerBreakdown
IEnumerable<BreakdownItem> ReferrerBreakdown,
IEnumerable<BreakdownItem> CountryBreakdown
);

View File

@@ -13,6 +13,8 @@ public class LinkAnalyticsRequest
public Guid WorkspaceId { get; set; }
public Guid Id { get; set; }
public string? Period { get; set; } // 24h, 7d, 30d, or null for all time
public DateTime? StartDate { get; set; } // Custom date range start
public DateTime? EndDate { get; set; } // Custom date range end
}
public class LinkAnalyticsEndpoint(AppDbContext db)
@@ -39,8 +41,19 @@ public class LinkAnalyticsEndpoint(AppDbContext db)
return;
}
// Determine time filter
var startDate = GetStartDate(req.Period);
// Determine time filter (custom range takes precedence over period)
DateTime? startDate = null;
DateTime? endDate = null;
if (req.StartDate.HasValue && req.EndDate.HasValue)
{
startDate = req.StartDate.Value;
endDate = req.EndDate.Value.AddDays(1); // Include the entire end day
}
else
{
startDate = GetStartDate(req.Period);
}
// Query events for this link
var eventsQuery = db.Events
@@ -51,6 +64,11 @@ public class LinkAnalyticsEndpoint(AppDbContext db)
eventsQuery = eventsQuery.Where(e => e.Timestamp >= startDate.Value);
}
if (endDate.HasValue)
{
eventsQuery = eventsQuery.Where(e => e.Timestamp < endDate.Value);
}
var events = await eventsQuery.ToListAsync(ct);
var totalEvents = events.Count;
@@ -99,13 +117,27 @@ public class LinkAnalyticsEndpoint(AppDbContext db)
))
.ToList();
// Country breakdown
var countryBreakdown = events
.Where(e => !string.IsNullOrEmpty(e.CountryCode))
.GroupBy(e => e.CountryCode!)
.OrderByDescending(g => g.Count())
.Take(10)
.Select(g => new BreakdownItem(
g.Key,
g.Count(),
totalEvents > 0 ? Math.Round((double)g.Count() / totalEvents * 100, 1) : 0
))
.ToList();
var response = new LinkAnalyticsResponse(
LinkId: link.Id,
Slug: link.Slug,
Summary: summary,
TimeSeries: timeSeries,
DeviceBreakdown: deviceBreakdown,
ReferrerBreakdown: referrerBreakdown
ReferrerBreakdown: referrerBreakdown,
CountryBreakdown: countryBreakdown
);
await HttpContext.Response.SendAsync(response, 200, cancellation: ct);

View File

@@ -12,6 +12,8 @@ public class WorkspaceAnalyticsRequest
{
public Guid WorkspaceId { get; set; }
public string? Period { get; set; } // 24h, 7d, 30d, or null for all time
public DateTime? StartDate { get; set; } // Custom date range start
public DateTime? EndDate { get; set; } // Custom date range end
}
public class WorkspaceAnalyticsEndpoint(AppDbContext db)
@@ -36,8 +38,19 @@ public class WorkspaceAnalyticsEndpoint(AppDbContext db)
return;
}
// Determine time filter
var startDate = GetStartDate(req.Period);
// Determine time filter (custom range takes precedence over period)
DateTime? startDate = null;
DateTime? endDate = null;
if (req.StartDate.HasValue && req.EndDate.HasValue)
{
startDate = req.StartDate.Value;
endDate = req.EndDate.Value.AddDays(1); // Include the entire end day
}
else
{
startDate = GetStartDate(req.Period);
}
// Query events
var eventsQuery = db.Events
@@ -48,6 +61,11 @@ public class WorkspaceAnalyticsEndpoint(AppDbContext db)
eventsQuery = eventsQuery.Where(e => e.Timestamp >= startDate.Value);
}
if (endDate.HasValue)
{
eventsQuery = eventsQuery.Where(e => e.Timestamp < endDate.Value);
}
var events = await eventsQuery.ToListAsync(ct);
var totalEvents = events.Count;
@@ -114,12 +132,26 @@ public class WorkspaceAnalyticsEndpoint(AppDbContext db)
))
.ToList();
// Get country breakdown
var countryBreakdown = events
.Where(e => !string.IsNullOrEmpty(e.CountryCode))
.GroupBy(e => e.CountryCode!)
.OrderByDescending(g => g.Count())
.Take(10)
.Select(g => new BreakdownItem(
g.Key,
g.Count(),
totalEvents > 0 ? Math.Round((double)g.Count() / totalEvents * 100, 1) : 0
))
.ToList();
var response = new WorkspaceAnalyticsResponse(
Summary: summary,
TimeSeries: timeSeries,
TopLinks: topLinks,
DeviceBreakdown: deviceBreakdown,
ReferrerBreakdown: referrerBreakdown
ReferrerBreakdown: referrerBreakdown,
CountryBreakdown: countryBreakdown
);
await HttpContext.Response.SendAsync(response, 200, cancellation: ct);

View File

@@ -0,0 +1,102 @@
using System.Security.Claims;
using System.Security.Cryptography;
using api.Data;
using api.Features.Auth.Common;
using api.Models;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
namespace api.Features.ApiKeys.Endpoints;
public class CreateApiKeyRequest
{
public Guid WorkspaceId { get; set; }
public required string Name { get; set; }
public DateTime? ExpiresAt { get; set; }
public List<string>? Scopes { get; set; }
}
public class CreateApiKeyResponse
{
public required Guid Id { get; set; }
public required string Name { get; set; }
public required string Key { get; set; } // Only returned once on creation!
public required string KeyPrefix { get; set; }
public List<string>? Scopes { get; set; }
public DateTime? ExpiresAt { get; set; }
public DateTime CreatedAt { get; set; }
}
public class CreateApiKeyEndpoint(AppDbContext db)
: Endpoint<CreateApiKeyRequest, CreateApiKeyResponse>
{
public override void Configure()
{
Post("/workspaces/{WorkspaceId}/api-keys");
}
public override async Task HandleAsync(CreateApiKeyRequest req, CancellationToken ct)
{
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
// Verify workspace ownership
var workspace = await db.Workspaces
.FirstOrDefaultAsync(w => w.Id == req.WorkspaceId && w.OwnerUserId == userId, ct);
if (workspace is null)
{
await HttpContext.Response.SendAsync(new MessageResponse("Workspace not found"), 404, cancellation: ct);
return;
}
// Check API key limit (max 10 per workspace)
var existingCount = await db.ApiKeys.CountAsync(k => k.WorkspaceId == req.WorkspaceId && k.IsActive, ct);
if (existingCount >= 10)
{
await HttpContext.Response.SendAsync(new MessageResponse("Maximum 10 API keys per workspace"), 400, cancellation: ct);
return;
}
// Generate secure key: trk_<32 random bytes as base64url>
var randomBytes = RandomNumberGenerator.GetBytes(32);
var keyValue = "trk_" + Convert.ToBase64String(randomBytes).Replace("+", "-").Replace("/", "_").TrimEnd('=');
var keyHash = ComputeSha256Hash(keyValue);
var keyPrefix = keyValue[..12] + "...";
var apiKey = new ApiKey
{
Id = Guid.NewGuid(),
WorkspaceId = req.WorkspaceId,
Name = req.Name,
KeyHash = keyHash,
KeyPrefix = keyPrefix,
Scopes = req.Scopes,
ExpiresAt = req.ExpiresAt,
CreatedAt = DateTime.UtcNow,
IsActive = true,
};
db.ApiKeys.Add(apiKey);
await db.SaveChangesAsync(ct);
var response = new CreateApiKeyResponse
{
Id = apiKey.Id,
Name = apiKey.Name,
Key = keyValue, // Only returned once!
KeyPrefix = keyPrefix,
Scopes = apiKey.Scopes,
ExpiresAt = apiKey.ExpiresAt,
CreatedAt = apiKey.CreatedAt,
};
await HttpContext.Response.SendAsync(response, 201, cancellation: ct);
}
private static string ComputeSha256Hash(string input)
{
var bytes = System.Text.Encoding.UTF8.GetBytes(input);
var hash = SHA256.HashData(bytes);
return Convert.ToHexString(hash).ToLower();
}
}

View File

@@ -0,0 +1,51 @@
using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
namespace api.Features.ApiKeys.Endpoints;
public class DeleteApiKeyRequest
{
public Guid WorkspaceId { get; set; }
public Guid Id { get; set; }
}
public class DeleteApiKeyEndpoint(AppDbContext db)
: Endpoint<DeleteApiKeyRequest>
{
public override void Configure()
{
Delete("/workspaces/{WorkspaceId}/api-keys/{Id}");
}
public override async Task HandleAsync(DeleteApiKeyRequest req, CancellationToken ct)
{
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
// Verify workspace ownership
var workspaceExists = await db.Workspaces
.AnyAsync(w => w.Id == req.WorkspaceId && w.OwnerUserId == userId, ct);
if (!workspaceExists)
{
await HttpContext.Response.SendAsync(new MessageResponse("Workspace not found"), 404, cancellation: ct);
return;
}
var apiKey = await db.ApiKeys
.FirstOrDefaultAsync(k => k.Id == req.Id && k.WorkspaceId == req.WorkspaceId, ct);
if (apiKey is null)
{
await HttpContext.Response.SendAsync(new MessageResponse("API key not found"), 404, cancellation: ct);
return;
}
db.ApiKeys.Remove(apiKey);
await db.SaveChangesAsync(ct);
await HttpContext.Response.SendAsync(new MessageResponse("API key deleted"), 200, cancellation: ct);
}
}

View File

@@ -0,0 +1,72 @@
using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
namespace api.Features.ApiKeys.Endpoints;
public class ListApiKeysRequest
{
public Guid WorkspaceId { get; set; }
}
public class ApiKeyDto
{
public required Guid Id { get; set; }
public required string Name { get; set; }
public required string KeyPrefix { get; set; }
public List<string>? Scopes { get; set; }
public DateTime? ExpiresAt { get; set; }
public DateTime? LastUsedAt { get; set; }
public DateTime CreatedAt { get; set; }
public bool IsActive { get; set; }
}
public class ListApiKeysResponse
{
public required List<ApiKeyDto> ApiKeys { get; set; }
}
public class ListApiKeysEndpoint(AppDbContext db)
: Endpoint<ListApiKeysRequest, ListApiKeysResponse>
{
public override void Configure()
{
Get("/workspaces/{WorkspaceId}/api-keys");
}
public override async Task HandleAsync(ListApiKeysRequest req, CancellationToken ct)
{
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
// Verify workspace ownership
var workspaceExists = await db.Workspaces
.AnyAsync(w => w.Id == req.WorkspaceId && w.OwnerUserId == userId, ct);
if (!workspaceExists)
{
await HttpContext.Response.SendAsync(new MessageResponse("Workspace not found"), 404, cancellation: ct);
return;
}
var apiKeys = await db.ApiKeys
.Where(k => k.WorkspaceId == req.WorkspaceId)
.OrderByDescending(k => k.CreatedAt)
.Select(k => new ApiKeyDto
{
Id = k.Id,
Name = k.Name,
KeyPrefix = k.KeyPrefix,
Scopes = k.Scopes,
ExpiresAt = k.ExpiresAt,
LastUsedAt = k.LastUsedAt,
CreatedAt = k.CreatedAt,
IsActive = k.IsActive,
})
.ToListAsync(ct);
var response = new ListApiKeysResponse { ApiKeys = apiKeys };
await HttpContext.Response.SendAsync(response, 200, cancellation: ct);
}
}

View File

@@ -0,0 +1,59 @@
using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using FastEndpoints;
using FluentValidation;
namespace api.Features.Auth.Endpoints;
public class ChangePasswordRequest
{
public string CurrentPassword { get; set; } = string.Empty;
public string NewPassword { get; set; } = string.Empty;
}
public class ChangePasswordValidator : Validator<ChangePasswordRequest>
{
public ChangePasswordValidator()
{
RuleFor(x => x.CurrentPassword)
.NotEmpty().WithMessage("Current password is required");
RuleFor(x => x.NewPassword)
.NotEmpty().WithMessage("New password is required")
.MinimumLength(8).WithMessage("New password must be at least 8 characters");
}
}
public class ChangePasswordEndpoint(AppDbContext db) : Endpoint<ChangePasswordRequest>
{
public override void Configure()
{
Post("/auth/change-password");
}
public override async Task HandleAsync(ChangePasswordRequest req, CancellationToken ct)
{
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
var user = await db.Users.FindAsync([userId], ct);
if (user == null)
{
await HttpContext.Response.SendAsync(new MessageResponse("User not found"), 404, cancellation: ct);
return;
}
// Verify current password
if (!BCrypt.Net.BCrypt.Verify(req.CurrentPassword, user.PasswordHash))
{
await HttpContext.Response.SendAsync(new MessageResponse("Current password is incorrect"), 400, cancellation: ct);
return;
}
// Update password
user.PasswordHash = BCrypt.Net.BCrypt.HashPassword(req.NewPassword);
await db.SaveChangesAsync(ct);
await HttpContext.Response.SendAsync(new MessageResponse("Password changed successfully"), cancellation: ct);
}
}

View File

@@ -0,0 +1,59 @@
using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using FastEndpoints;
using FluentValidation;
using Microsoft.EntityFrameworkCore;
namespace api.Features.Auth.Endpoints;
public class DeleteAccountRequest
{
public string Password { get; set; } = string.Empty;
}
public class DeleteAccountValidator : Validator<DeleteAccountRequest>
{
public DeleteAccountValidator()
{
RuleFor(x => x.Password)
.NotEmpty().WithMessage("Password is required to delete account");
}
}
public class DeleteAccountEndpoint(AppDbContext db) : Endpoint<DeleteAccountRequest>
{
public override void Configure()
{
Delete("/auth/account");
}
public override async Task HandleAsync(DeleteAccountRequest req, CancellationToken ct)
{
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
var user = await db.Users.FindAsync([userId], ct);
if (user == null)
{
await HttpContext.Response.SendAsync(new MessageResponse("User not found"), 404, cancellation: ct);
return;
}
// Verify password
if (!BCrypt.Net.BCrypt.Verify(req.Password, user.PasswordHash))
{
await HttpContext.Response.SendAsync(new MessageResponse("Password is incorrect"), 400, cancellation: ct);
return;
}
// Delete all user's workspaces (cascade will handle related data)
var workspaces = await db.Workspaces.Where(w => w.OwnerUserId == userId).ToListAsync(ct);
db.Workspaces.RemoveRange(workspaces);
// Delete user
db.Users.Remove(user);
await db.SaveChangesAsync(ct);
await HttpContext.Response.SendAsync(new MessageResponse("Account deleted successfully"), cancellation: ct);
}
}

View File

@@ -1,6 +1,8 @@
using System.Security.Cryptography;
using api.Data;
using api.Features.Auth.Common;
using api.Features.Email.Services;
using api.Models;
using FastEndpoints;
using FluentValidation;
using Microsoft.EntityFrameworkCore;
@@ -22,13 +24,14 @@ public class ForgotPasswordValidator : Validator<ForgotPasswordRequest>
}
}
public class ForgotPasswordEndpoint(AppDbContext db)
public class ForgotPasswordEndpoint(AppDbContext db, IEmailService emailService)
: Endpoint<ForgotPasswordRequest, MessageResponse>
{
public override void Configure()
{
Post("/auth/forgot");
AllowAnonymous();
Options(x => x.RequireRateLimiting("auth"));
}
public override async Task HandleAsync(ForgotPasswordRequest req, CancellationToken ct)
@@ -36,20 +39,55 @@ public class ForgotPasswordEndpoint(AppDbContext db)
var normalizedEmail = req.Email.ToLowerInvariant();
var user = await db.Users.FirstOrDefaultAsync(u => u.Email == normalizedEmail, ct);
if (user == null)
if (user != null)
{
Logger.LogInformation("Password reset requested for non-existent email: {Email}", normalizedEmail);
// Invalidate any existing tokens for this user
var existingTokens = await db.PasswordResetTokens
.Where(t => t.UserId == user.Id && !t.Used)
.ToListAsync(ct);
foreach (var token in existingTokens)
{
token.Used = true;
}
// Generate new token
var resetToken = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32))
.Replace("+", "-").Replace("/", "_").TrimEnd('=');
var passwordResetToken = new PasswordResetToken
{
Id = Guid.NewGuid(),
UserId = user.Id,
Token = resetToken,
ExpiresAt = DateTime.UtcNow.AddHours(1),
Used = false,
CreatedAt = DateTime.UtcNow
};
db.PasswordResetTokens.Add(passwordResetToken);
await db.SaveChangesAsync(ct);
// Send password reset email
try
{
await emailService.SendPasswordResetEmailAsync(normalizedEmail, resetToken, ct);
}
catch (Exception ex)
{
Logger.LogError(ex, "Failed to send password reset email to {Email}", normalizedEmail);
// Don't fail the request - still return success to prevent email enumeration
}
}
else
{
var resetToken = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32))
.Replace("+", "-").Replace("/", "_").TrimEnd('=');
// TODO: Store reset token in database with expiration
// TODO: Send email with reset link
Logger.LogInformation("Password reset token generated for: {Email}, Token: {Token}", normalizedEmail, resetToken);
Logger.LogInformation("Password reset requested for non-existent email: {Email}", normalizedEmail);
}
// Always return success to prevent email enumeration
await HttpContext.Response.SendAsync(new MessageResponse("If the email exists, a reset link will be sent"), 200, cancellation: ct);
await HttpContext.Response.SendAsync(
new MessageResponse("If the email exists, a reset link will be sent"),
200,
cancellation: ct);
}
}

View File

@@ -0,0 +1,44 @@
using System.Security.Claims;
using api.Data;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
namespace api.Features.Auth.Endpoints;
public record ProfileResponse(
Guid Id,
string Email,
bool IsVerified,
DateTime CreatedAt
);
public class GetProfileEndpoint(AppDbContext db) : EndpointWithoutRequest<ProfileResponse>
{
public override void Configure()
{
Get("/auth/profile");
}
public override async Task HandleAsync(CancellationToken ct)
{
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
var user = await db.Users
.Where(u => u.Id == userId)
.Select(u => new ProfileResponse(
u.Id,
u.Email,
u.VerifiedAt != null,
u.CreatedAt
))
.FirstOrDefaultAsync(ct);
if (user == null)
{
await HttpContext.Response.SendAsync(new { message = "User not found" }, 404, cancellation: ct);
return;
}
await HttpContext.Response.SendAsync(user, cancellation: ct);
}
}

View File

@@ -40,6 +40,7 @@ public class LoginEndpoint(AppDbContext db, IOptions<JwtSettings> jwtSettings)
{
Post("/auth/login");
AllowAnonymous();
Options(x => x.RequireRateLimiting("auth"));
}
public override async Task HandleAsync(LoginRequest req, CancellationToken ct)

View File

@@ -1,9 +1,11 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using api.Data;
using api.Features.Auth.Common;
using api.Features.Auth.Settings;
using api.Features.Email.Services;
using api.Models;
using FastEndpoints;
using FluentValidation;
@@ -35,7 +37,7 @@ public class RegisterValidator : Validator<RegisterRequest>
}
}
public class RegisterEndpoint(AppDbContext db, IOptions<JwtSettings> jwtSettings)
public class RegisterEndpoint(AppDbContext db, IOptions<JwtSettings> jwtSettings, IEmailService emailService)
: Endpoint<RegisterRequest, AuthResponse>
{
private readonly JwtSettings _jwtSettings = jwtSettings.Value;
@@ -44,6 +46,7 @@ public class RegisterEndpoint(AppDbContext db, IOptions<JwtSettings> jwtSettings
{
Post("/auth/register");
AllowAnonymous();
Options(x => x.RequireRateLimiting("auth"));
}
public override async Task HandleAsync(RegisterRequest req, CancellationToken ct)
@@ -76,8 +79,29 @@ public class RegisterEndpoint(AppDbContext db, IOptions<JwtSettings> jwtSettings
};
db.Workspaces.Add(workspace);
// Create email verification token
var tokenBytes = RandomNumberGenerator.GetBytes(32);
var tokenString = Convert.ToHexString(tokenBytes).ToLowerInvariant();
var verificationToken = new EmailVerificationToken
{
Id = Guid.NewGuid(),
UserId = user.Id,
Token = tokenString,
ExpiresAt = DateTime.UtcNow.AddHours(24),
CreatedAt = DateTime.UtcNow
};
db.EmailVerificationTokens.Add(verificationToken);
await db.SaveChangesAsync(ct);
// Send verification email (fire and forget)
_ = emailService.SendEmailVerificationAsync(normalizedEmail, tokenString, ct);
// Send welcome email
_ = emailService.SendWelcomeEmailAsync(normalizedEmail, normalizedEmail.Split('@')[0], ct);
Logger.LogInformation("User registered: {Email}", normalizedEmail);
var response = GenerateAuthResponse(user);

View File

@@ -0,0 +1,63 @@
using System.Security.Claims;
using System.Security.Cryptography;
using api.Data;
using api.Features.Auth.Common;
using api.Features.Email.Services;
using api.Models;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
namespace api.Features.Auth.Endpoints;
public class ResendVerificationEndpoint(AppDbContext db, IEmailService emailService) : EndpointWithoutRequest
{
public override void Configure()
{
Post("/auth/resend-verification");
}
public override async Task HandleAsync(CancellationToken ct)
{
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
var user = await db.Users.FindAsync([userId], ct);
if (user == null)
{
await HttpContext.Response.SendAsync(new MessageResponse("User not found"), 404, cancellation: ct);
return;
}
if (user.VerifiedAt != null)
{
await HttpContext.Response.SendAsync(new MessageResponse("Email is already verified"), 400, cancellation: ct);
return;
}
// Remove existing tokens
var existingTokens = await db.EmailVerificationTokens
.Where(t => t.UserId == userId)
.ToListAsync(ct);
db.EmailVerificationTokens.RemoveRange(existingTokens);
// Create new token
var tokenBytes = RandomNumberGenerator.GetBytes(32);
var tokenString = Convert.ToHexString(tokenBytes).ToLowerInvariant();
var verificationToken = new EmailVerificationToken
{
Id = Guid.NewGuid(),
UserId = userId,
Token = tokenString,
ExpiresAt = DateTime.UtcNow.AddHours(24),
CreatedAt = DateTime.UtcNow
};
db.EmailVerificationTokens.Add(verificationToken);
await db.SaveChangesAsync(ct);
// Send verification email
await emailService.SendEmailVerificationAsync(user.Email, tokenString, ct);
await HttpContext.Response.SendAsync(new MessageResponse("Verification email sent"), cancellation: ct);
}
}

View File

@@ -1,6 +1,8 @@
using api.Data;
using api.Features.Auth.Common;
using FastEndpoints;
using FluentValidation;
using Microsoft.EntityFrameworkCore;
namespace api.Features.Auth.Endpoints;
@@ -24,23 +26,63 @@ public class ValidatorResetPassword : Validator<ResetPasswordRequest>
}
}
public class ResetPasswordEndpoint : Endpoint<ResetPasswordRequest, MessageResponse>
public class ResetPasswordEndpoint(AppDbContext db)
: Endpoint<ResetPasswordRequest, MessageResponse>
{
public override void Configure()
{
Post("/auth/reset");
AllowAnonymous();
Options(x => x.RequireRateLimiting("auth"));
}
public override async Task HandleAsync(ResetPasswordRequest req, CancellationToken ct)
{
// TODO: Implement password reset
// 1. Look up token in database
// 2. Verify token hasn't expired
// 3. Get associated user
// 4. Update password
// 5. Invalidate token
// Find the token
var resetToken = await db.PasswordResetTokens
.Include(t => t.User)
.FirstOrDefaultAsync(t => t.Token == req.Token, ct);
await HttpContext.Response.SendAsync(new MessageResponse("Password reset is not yet available"), 400, cancellation: ct);
if (resetToken == null)
{
await HttpContext.Response.SendAsync(
new MessageResponse("Invalid or expired reset token"),
400,
cancellation: ct);
return;
}
// Check if token is expired
if (resetToken.ExpiresAt < DateTime.UtcNow)
{
await HttpContext.Response.SendAsync(
new MessageResponse("Reset token has expired"),
400,
cancellation: ct);
return;
}
// Check if token is already used
if (resetToken.Used)
{
await HttpContext.Response.SendAsync(
new MessageResponse("Reset token has already been used"),
400,
cancellation: ct);
return;
}
// Update the user's password
resetToken.User.PasswordHash = BCrypt.Net.BCrypt.HashPassword(req.NewPassword);
resetToken.Used = true;
await db.SaveChangesAsync(ct);
Logger.LogInformation("Password reset successful for user: {Email}", resetToken.User.Email);
await HttpContext.Response.SendAsync(
new MessageResponse("Password has been reset successfully"),
200,
cancellation: ct);
}
}

View File

@@ -0,0 +1,68 @@
using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using FastEndpoints;
using FluentValidation;
using Microsoft.EntityFrameworkCore;
namespace api.Features.Auth.Endpoints;
public class UpdateProfileRequest
{
public string? Email { get; set; }
}
public class UpdateProfileValidator : Validator<UpdateProfileRequest>
{
public UpdateProfileValidator()
{
RuleFor(x => x.Email)
.EmailAddress().WithMessage("Invalid email address")
.When(x => !string.IsNullOrEmpty(x.Email));
}
}
public class UpdateProfileEndpoint(AppDbContext db) : Endpoint<UpdateProfileRequest, ProfileResponse>
{
public override void Configure()
{
Put("/auth/profile");
}
public override async Task HandleAsync(UpdateProfileRequest req, CancellationToken ct)
{
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
var user = await db.Users.FindAsync([userId], ct);
if (user == null)
{
await HttpContext.Response.SendAsync(new MessageResponse("User not found"), 404, cancellation: ct);
return;
}
if (!string.IsNullOrEmpty(req.Email) && req.Email != user.Email)
{
// Check if email is already taken
var emailExists = await db.Users.AnyAsync(u => u.Email == req.Email && u.Id != userId, ct);
if (emailExists)
{
await HttpContext.Response.SendAsync(new MessageResponse("Email is already in use"), 409, cancellation: ct);
return;
}
user.Email = req.Email;
user.VerifiedAt = null; // Reset verification when email changes
}
await db.SaveChangesAsync(ct);
var response = new ProfileResponse(
user.Id,
user.Email,
user.VerifiedAt != null,
user.CreatedAt
);
await HttpContext.Response.SendAsync(response, cancellation: ct);
}
}

View File

@@ -0,0 +1,59 @@
using api.Data;
using api.Features.Auth.Common;
using FastEndpoints;
using FluentValidation;
using Microsoft.EntityFrameworkCore;
namespace api.Features.Auth.Endpoints;
public class VerifyEmailRequest
{
public string Token { get; set; } = string.Empty;
}
public class VerifyEmailValidator : Validator<VerifyEmailRequest>
{
public VerifyEmailValidator()
{
RuleFor(x => x.Token).NotEmpty().WithMessage("Token is required");
}
}
public class VerifyEmailEndpoint(AppDbContext db) : Endpoint<VerifyEmailRequest>
{
public override void Configure()
{
Post("/auth/verify-email");
AllowAnonymous();
}
public override async Task HandleAsync(VerifyEmailRequest req, CancellationToken ct)
{
var token = await db.EmailVerificationTokens
.Include(t => t.User)
.FirstOrDefaultAsync(t => t.Token == req.Token, ct);
if (token == null)
{
await HttpContext.Response.SendAsync(new MessageResponse("Invalid verification token"), 400, cancellation: ct);
return;
}
if (token.ExpiresAt < DateTime.UtcNow)
{
db.EmailVerificationTokens.Remove(token);
await db.SaveChangesAsync(ct);
await HttpContext.Response.SendAsync(new MessageResponse("Verification token has expired"), 400, cancellation: ct);
return;
}
// Mark user as verified
token.User.VerifiedAt = DateTime.UtcNow;
// Remove the token
db.EmailVerificationTokens.Remove(token);
await db.SaveChangesAsync(ct);
await HttpContext.Response.SendAsync(new MessageResponse("Email verified successfully"), cancellation: ct);
}
}

View File

@@ -0,0 +1,23 @@
namespace api.Features.Billing.Common;
public record CheckoutSessionRequest(
Guid WorkspaceId,
string Plan,
string SuccessUrl,
string CancelUrl
);
public record CheckoutSessionResponse(string Url);
public record PortalSessionRequest(string ReturnUrl);
public record PortalSessionResponse(string Url);
public record SubscriptionResponse(
Guid WorkspaceId,
string Plan,
string? SubscriptionId,
DateTime? CurrentPeriodEnd,
bool IsActive,
bool CancelAtPeriodEnd
);

View File

@@ -0,0 +1,87 @@
using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using api.Features.Billing.Common;
using api.Features.Billing.Services;
using api.Models;
using FastEndpoints;
using FluentValidation;
using Microsoft.EntityFrameworkCore;
namespace api.Features.Billing.Endpoints;
public class CreateCheckoutSessionValidator : Validator<CheckoutSessionRequest>
{
public CreateCheckoutSessionValidator()
{
RuleFor(x => x.WorkspaceId).NotEmpty();
RuleFor(x => x.Plan)
.NotEmpty()
.Must(p => p == "Pro" || p == "Business")
.WithMessage("Plan must be 'Pro' or 'Business'");
RuleFor(x => x.SuccessUrl).NotEmpty().Must(BeValidUrl);
RuleFor(x => x.CancelUrl).NotEmpty().Must(BeValidUrl);
}
private static bool BeValidUrl(string url)
{
return Uri.TryCreate(url, UriKind.Absolute, out var uri)
&& (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps);
}
}
public class CreateCheckoutSessionEndpoint(AppDbContext db, IStripeService stripeService)
: Endpoint<CheckoutSessionRequest, CheckoutSessionResponse>
{
public override void Configure()
{
Post("/billing/checkout");
}
public override async Task HandleAsync(CheckoutSessionRequest req, CancellationToken ct)
{
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
// Verify workspace ownership
var workspace = await db.Workspaces
.FirstOrDefaultAsync(w => w.Id == req.WorkspaceId && w.OwnerUserId == userId, ct);
if (workspace == null)
{
await HttpContext.Response.SendAsync(new MessageResponse("Workspace not found"), 404, cancellation: ct);
return;
}
// Check if already subscribed
if (!string.IsNullOrEmpty(workspace.StripeSubscriptionId))
{
await HttpContext.Response.SendAsync(
new MessageResponse("Workspace already has an active subscription. Use the billing portal to manage it."),
400,
cancellation: ct);
return;
}
var plan = Enum.Parse<WorkspacePlan>(req.Plan);
try
{
var checkoutUrl = await stripeService.CreateCheckoutSessionAsync(
userId,
req.WorkspaceId,
plan,
req.SuccessUrl,
req.CancelUrl,
ct);
await HttpContext.Response.SendAsync(new CheckoutSessionResponse(checkoutUrl), cancellation: ct);
}
catch (Exception ex)
{
await HttpContext.Response.SendAsync(
new MessageResponse($"Failed to create checkout session: {ex.Message}"),
500,
cancellation: ct);
}
}
}

View File

@@ -0,0 +1,60 @@
using System.Security.Claims;
using api.Features.Auth.Common;
using api.Features.Billing.Common;
using api.Features.Billing.Services;
using FastEndpoints;
using FluentValidation;
namespace api.Features.Billing.Endpoints;
public class CreatePortalSessionValidator : Validator<PortalSessionRequest>
{
public CreatePortalSessionValidator()
{
RuleFor(x => x.ReturnUrl).NotEmpty().Must(BeValidUrl);
}
private static bool BeValidUrl(string url)
{
return Uri.TryCreate(url, UriKind.Absolute, out var uri)
&& (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps);
}
}
public class CreatePortalSessionEndpoint(IStripeService stripeService)
: Endpoint<PortalSessionRequest, PortalSessionResponse>
{
public override void Configure()
{
Post("/billing/portal");
}
public override async Task HandleAsync(PortalSessionRequest req, CancellationToken ct)
{
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
try
{
var portalUrl = await stripeService.CreateCustomerPortalSessionAsync(
userId,
req.ReturnUrl,
ct);
await HttpContext.Response.SendAsync(new PortalSessionResponse(portalUrl), cancellation: ct);
}
catch (InvalidOperationException ex)
{
await HttpContext.Response.SendAsync(
new MessageResponse(ex.Message),
400,
cancellation: ct);
}
catch (Exception ex)
{
await HttpContext.Response.SendAsync(
new MessageResponse($"Failed to create portal session: {ex.Message}"),
500,
cancellation: ct);
}
}
}

View File

@@ -0,0 +1,62 @@
using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using api.Features.Billing.Common;
using api.Features.Billing.Services;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
namespace api.Features.Billing.Endpoints;
public class GetSubscriptionRequest
{
public Guid WorkspaceId { get; set; }
}
public class GetSubscriptionEndpoint(AppDbContext db, IStripeService stripeService)
: Endpoint<GetSubscriptionRequest, SubscriptionResponse>
{
public override void Configure()
{
Get("/workspaces/{WorkspaceId}/subscription");
}
public override async Task HandleAsync(GetSubscriptionRequest req, CancellationToken ct)
{
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
var workspace = await db.Workspaces
.FirstOrDefaultAsync(w => w.Id == req.WorkspaceId && w.OwnerUserId == userId, ct);
if (workspace == null)
{
await HttpContext.Response.SendAsync(new MessageResponse("Workspace not found"), 404, cancellation: ct);
return;
}
var isActive = workspace.Plan != Models.WorkspacePlan.Free;
var cancelAtPeriodEnd = false;
// Get live subscription status from Stripe if exists
if (!string.IsNullOrEmpty(workspace.StripeSubscriptionId))
{
var subscription = await stripeService.GetSubscriptionAsync(workspace.StripeSubscriptionId, ct);
if (subscription != null)
{
isActive = subscription.Status == "active" || subscription.Status == "trialing";
cancelAtPeriodEnd = subscription.CancelAtPeriodEnd;
}
}
var response = new SubscriptionResponse(
workspace.Id,
workspace.Plan.ToString(),
workspace.StripeSubscriptionId,
workspace.SubscriptionEndsAt,
isActive,
cancelAtPeriodEnd
);
await HttpContext.Response.SendAsync(response, cancellation: ct);
}
}

View File

@@ -0,0 +1,87 @@
using api.Features.Billing.Services;
using api.Features.Billing.Settings;
using FastEndpoints;
using Microsoft.Extensions.Options;
using Stripe;
namespace api.Features.Billing.Endpoints;
public class StripeWebhookEndpoint(
IStripeService stripeService,
IOptions<StripeSettings> settings,
ILogger<StripeWebhookEndpoint> logger)
: EndpointWithoutRequest
{
private readonly StripeSettings _settings = settings.Value;
public override void Configure()
{
Post("/billing/webhook");
AllowAnonymous();
}
public override async Task HandleAsync(CancellationToken ct)
{
var json = await new StreamReader(HttpContext.Request.Body).ReadToEndAsync(ct);
try
{
var stripeSignature = HttpContext.Request.Headers["Stripe-Signature"].ToString();
var stripeEvent = EventUtility.ConstructEvent(
json,
stripeSignature,
_settings.WebhookSecret
);
logger.LogInformation("Received Stripe event: {EventType} ({EventId})", stripeEvent.Type, stripeEvent.Id);
switch (stripeEvent.Type)
{
case "checkout.session.completed":
var session = stripeEvent.Data.Object as Stripe.Checkout.Session;
if (session != null)
{
await stripeService.HandleCheckoutCompletedAsync(session, ct);
}
break;
case "customer.subscription.updated":
var updatedSubscription = stripeEvent.Data.Object as Subscription;
if (updatedSubscription != null)
{
await stripeService.HandleSubscriptionUpdatedAsync(updatedSubscription, ct);
}
break;
case "customer.subscription.deleted":
var deletedSubscription = stripeEvent.Data.Object as Subscription;
if (deletedSubscription != null)
{
await stripeService.HandleSubscriptionDeletedAsync(deletedSubscription, ct);
}
break;
case "invoice.payment_failed":
logger.LogWarning("Payment failed for invoice: {InvoiceId}",
(stripeEvent.Data.Object as Invoice)?.Id);
break;
default:
logger.LogDebug("Unhandled Stripe event type: {EventType}", stripeEvent.Type);
break;
}
await HttpContext.Response.SendAsync(new { received = true }, cancellation: ct);
}
catch (StripeException ex)
{
logger.LogError(ex, "Stripe webhook signature verification failed");
await HttpContext.Response.SendAsync(new { error = "Webhook signature verification failed" }, 400, cancellation: ct);
}
catch (Exception ex)
{
logger.LogError(ex, "Error processing Stripe webhook");
await HttpContext.Response.SendAsync(new { error = "Webhook processing failed" }, 500, cancellation: ct);
}
}
}

View File

@@ -0,0 +1,302 @@
using api.Data;
using api.Features.Billing.Settings;
using api.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Stripe;
using Stripe.Checkout;
namespace api.Features.Billing.Services;
public interface IStripeService
{
Task<string> CreateCheckoutSessionAsync(Guid userId, Guid workspaceId, WorkspacePlan plan, string successUrl, string cancelUrl, CancellationToken ct = default);
Task<string> CreateCustomerPortalSessionAsync(Guid userId, string returnUrl, CancellationToken ct = default);
Task<Subscription?> GetSubscriptionAsync(string subscriptionId, CancellationToken ct = default);
Task CancelSubscriptionAsync(string subscriptionId, CancellationToken ct = default);
Task HandleCheckoutCompletedAsync(Session session, CancellationToken ct = default);
Task HandleSubscriptionUpdatedAsync(Subscription subscription, CancellationToken ct = default);
Task HandleSubscriptionDeletedAsync(Subscription subscription, CancellationToken ct = default);
string GetPriceIdForPlan(WorkspacePlan plan);
WorkspacePlan GetPlanForPriceId(string priceId);
}
public class StripeService : IStripeService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly StripeSettings _settings;
private readonly ILogger<StripeService> _logger;
public StripeService(
IServiceScopeFactory scopeFactory,
IOptions<StripeSettings> settings,
ILogger<StripeService> logger)
{
_scopeFactory = scopeFactory;
_settings = settings.Value;
_logger = logger;
StripeConfiguration.ApiKey = _settings.SecretKey;
}
public async Task<string> CreateCheckoutSessionAsync(
Guid userId,
Guid workspaceId,
WorkspacePlan plan,
string successUrl,
string cancelUrl,
CancellationToken ct = default)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var user = await db.Users.FindAsync([userId], ct)
?? throw new InvalidOperationException("User not found");
// Get or create Stripe customer
var customerId = user.StripeCustomerId;
if (string.IsNullOrEmpty(customerId))
{
var customerService = new CustomerService();
var customer = await customerService.CreateAsync(new CustomerCreateOptions
{
Email = user.Email,
Metadata = new Dictionary<string, string>
{
["user_id"] = userId.ToString()
}
}, cancellationToken: ct);
customerId = customer.Id;
user.StripeCustomerId = customerId;
await db.SaveChangesAsync(ct);
}
var priceId = GetPriceIdForPlan(plan);
if (string.IsNullOrEmpty(priceId))
{
throw new InvalidOperationException($"No price configured for plan: {plan}");
}
var sessionService = new SessionService();
var session = await sessionService.CreateAsync(new SessionCreateOptions
{
Customer = customerId,
Mode = "subscription",
PaymentMethodTypes = ["card"],
LineItems =
[
new SessionLineItemOptions
{
Price = priceId,
Quantity = 1
}
],
SuccessUrl = successUrl + "?session_id={CHECKOUT_SESSION_ID}",
CancelUrl = cancelUrl,
Metadata = new Dictionary<string, string>
{
["user_id"] = userId.ToString(),
["workspace_id"] = workspaceId.ToString(),
["plan"] = plan.ToString()
},
SubscriptionData = new SessionSubscriptionDataOptions
{
Metadata = new Dictionary<string, string>
{
["user_id"] = userId.ToString(),
["workspace_id"] = workspaceId.ToString()
}
}
}, cancellationToken: ct);
return session.Url;
}
public async Task<string> CreateCustomerPortalSessionAsync(
Guid userId,
string returnUrl,
CancellationToken ct = default)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var user = await db.Users.FindAsync([userId], ct)
?? throw new InvalidOperationException("User not found");
if (string.IsNullOrEmpty(user.StripeCustomerId))
{
throw new InvalidOperationException("User has no Stripe customer");
}
var sessionService = new Stripe.BillingPortal.SessionService();
var session = await sessionService.CreateAsync(new Stripe.BillingPortal.SessionCreateOptions
{
Customer = user.StripeCustomerId,
ReturnUrl = returnUrl
}, cancellationToken: ct);
return session.Url;
}
public async Task<Subscription?> GetSubscriptionAsync(string subscriptionId, CancellationToken ct = default)
{
var service = new SubscriptionService();
try
{
return await service.GetAsync(subscriptionId, cancellationToken: ct);
}
catch (StripeException ex) when (ex.StripeError?.Code == "resource_missing")
{
return null;
}
}
public async Task CancelSubscriptionAsync(string subscriptionId, CancellationToken ct = default)
{
var service = new SubscriptionService();
await service.CancelAsync(subscriptionId, new SubscriptionCancelOptions
{
InvoiceNow = false,
Prorate = false
}, cancellationToken: ct);
}
public async Task HandleCheckoutCompletedAsync(Session session, CancellationToken ct = default)
{
var workspaceIdStr = session.Metadata.GetValueOrDefault("workspace_id");
var planStr = session.Metadata.GetValueOrDefault("plan");
if (string.IsNullOrEmpty(workspaceIdStr) || string.IsNullOrEmpty(planStr))
{
_logger.LogWarning("Checkout session missing metadata: {SessionId}", session.Id);
return;
}
if (!Guid.TryParse(workspaceIdStr, out var workspaceId))
{
_logger.LogWarning("Invalid workspace_id in checkout session: {WorkspaceId}", workspaceIdStr);
return;
}
if (!Enum.TryParse<WorkspacePlan>(planStr, out var plan))
{
_logger.LogWarning("Invalid plan in checkout session: {Plan}", planStr);
return;
}
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var workspace = await db.Workspaces.FindAsync([workspaceId], ct);
if (workspace == null)
{
_logger.LogWarning("Workspace not found for checkout: {WorkspaceId}", workspaceId);
return;
}
workspace.Plan = plan;
workspace.StripeSubscriptionId = session.SubscriptionId;
// Get subscription to set end date
if (!string.IsNullOrEmpty(session.SubscriptionId))
{
var subscription = await GetSubscriptionAsync(session.SubscriptionId, ct);
if (subscription != null)
{
workspace.SubscriptionEndsAt = subscription.CurrentPeriodEnd;
}
}
await db.SaveChangesAsync(ct);
_logger.LogInformation(
"Workspace {WorkspaceId} upgraded to {Plan} via checkout {SessionId}",
workspaceId, plan, session.Id);
}
public async Task HandleSubscriptionUpdatedAsync(Subscription subscription, CancellationToken ct = default)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var workspace = await db.Workspaces
.FirstOrDefaultAsync(w => w.StripeSubscriptionId == subscription.Id, ct);
if (workspace == null)
{
_logger.LogWarning("No workspace found for subscription: {SubscriptionId}", subscription.Id);
return;
}
// Update plan based on price
var priceId = subscription.Items.Data.FirstOrDefault()?.Price?.Id;
if (!string.IsNullOrEmpty(priceId))
{
var newPlan = GetPlanForPriceId(priceId);
if (workspace.Plan != newPlan)
{
_logger.LogInformation(
"Workspace {WorkspaceId} plan changed from {OldPlan} to {NewPlan}",
workspace.Id, workspace.Plan, newPlan);
workspace.Plan = newPlan;
}
}
workspace.SubscriptionEndsAt = subscription.CurrentPeriodEnd;
// Handle cancellation at period end
if (subscription.CancelAtPeriodEnd)
{
_logger.LogInformation(
"Workspace {WorkspaceId} subscription will cancel at {EndDate}",
workspace.Id, subscription.CurrentPeriodEnd);
}
await db.SaveChangesAsync(ct);
}
public async Task HandleSubscriptionDeletedAsync(Subscription subscription, CancellationToken ct = default)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var workspace = await db.Workspaces
.FirstOrDefaultAsync(w => w.StripeSubscriptionId == subscription.Id, ct);
if (workspace == null)
{
_logger.LogWarning("No workspace found for deleted subscription: {SubscriptionId}", subscription.Id);
return;
}
_logger.LogInformation(
"Workspace {WorkspaceId} subscription deleted, downgrading to Free",
workspace.Id);
workspace.Plan = WorkspacePlan.Free;
workspace.StripeSubscriptionId = null;
workspace.SubscriptionEndsAt = null;
await db.SaveChangesAsync(ct);
}
public string GetPriceIdForPlan(WorkspacePlan plan)
{
return plan switch
{
WorkspacePlan.Pro => _settings.ProPriceId,
WorkspacePlan.Business => _settings.BusinessPriceId,
_ => string.Empty
};
}
public WorkspacePlan GetPlanForPriceId(string priceId)
{
if (priceId == _settings.ProPriceId)
return WorkspacePlan.Pro;
if (priceId == _settings.BusinessPriceId)
return WorkspacePlan.Business;
return WorkspacePlan.Free;
}
}

View File

@@ -0,0 +1,9 @@
namespace api.Features.Billing.Settings;
public class StripeSettings
{
public string SecretKey { get; set; } = string.Empty;
public string WebhookSecret { get; set; } = string.Empty;
public string ProPriceId { get; set; } = string.Empty;
public string BusinessPriceId { get; set; } = string.Empty;
}

View File

@@ -3,6 +3,7 @@ using System.Security.Cryptography;
using api.Data;
using api.Features.Auth.Common;
using api.Features.Domains.Common;
using api.Features.Plans.Services;
using api.Models;
using FastEndpoints;
using FluentValidation;
@@ -28,7 +29,7 @@ public class AddDomainValidator : Validator<AddDomainRequest>
}
}
public class AddDomainEndpoint(AppDbContext db)
public class AddDomainEndpoint(AppDbContext db, IPlanLimitsService planLimits)
: Endpoint<AddDomainRequest, DomainResponse>
{
public override void Configure()
@@ -50,6 +51,16 @@ public class AddDomainEndpoint(AppDbContext db)
return;
}
// Check plan limits
if (!await planLimits.CanCreateDomainAsync(req.WorkspaceId, ct))
{
await HttpContext.Response.SendAsync(
new MessageResponse("Domain limit reached. Please upgrade your plan to add more custom domains."),
402,
cancellation: ct);
return;
}
// Normalize hostname (lowercase, no trailing dots)
var hostname = req.Hostname.ToLowerInvariant().TrimEnd('.');

View File

@@ -0,0 +1,60 @@
using api.Features.Email.Templates;
using Microsoft.Extensions.Options;
namespace api.Features.Email.Services;
/// <summary>
/// Development email service that logs emails to console instead of sending them.
/// Useful for testing without a real SMTP server.
/// </summary>
public class ConsoleEmailService : IEmailService
{
private readonly EmailSettings _settings;
private readonly ILogger<ConsoleEmailService> _logger;
public ConsoleEmailService(IOptions<EmailSettings> settings, ILogger<ConsoleEmailService> logger)
{
_settings = settings.Value;
_logger = logger;
}
public Task SendPasswordResetEmailAsync(string toEmail, string resetToken, CancellationToken ct = default)
{
var resetUrl = $"{_settings.BaseUrl}/reset-password?token={Uri.EscapeDataString(resetToken)}";
var (subject, _, textBody) = EmailTemplates.PasswordReset(resetUrl);
LogEmail(toEmail, subject, textBody, resetUrl);
return Task.CompletedTask;
}
public Task SendEmailVerificationAsync(string toEmail, string verificationToken, CancellationToken ct = default)
{
var verifyUrl = $"{_settings.BaseUrl}/verify-email?token={Uri.EscapeDataString(verificationToken)}";
var (subject, _, textBody) = EmailTemplates.EmailVerification(verifyUrl);
LogEmail(toEmail, subject, textBody, verifyUrl);
return Task.CompletedTask;
}
public Task SendWelcomeEmailAsync(string toEmail, string userName, CancellationToken ct = default)
{
var dashboardUrl = $"{_settings.BaseUrl}/dashboard";
var (subject, _, textBody) = EmailTemplates.Welcome(userName, dashboardUrl);
LogEmail(toEmail, subject, textBody, dashboardUrl);
return Task.CompletedTask;
}
private void LogEmail(string toEmail, string subject, string body, string actionUrl)
{
_logger.LogInformation($"""
╔══════════════════════════════════════════════════════════════╗
║ EMAIL (Console Mode) ║
╚══════════════════════════════════════════════════════════════╝
To: {toEmail}
Subject: {subject}
Action URL: {actionUrl}
""");
}
}

View File

@@ -0,0 +1,36 @@
namespace api.Features.Email.Services;
public interface IEmailService
{
Task SendPasswordResetEmailAsync(string toEmail, string resetToken, CancellationToken ct = default);
Task SendEmailVerificationAsync(string toEmail, string verificationToken, CancellationToken ct = default);
Task SendWelcomeEmailAsync(string toEmail, string userName, CancellationToken ct = default);
}
public class EmailSettings
{
public string Provider { get; set; } = "smtp"; // smtp, sendgrid, ses
public string FromEmail { get; set; } = "noreply@trakqr.com";
public string FromName { get; set; } = "TrakQR";
public string BaseUrl { get; set; } = "https://trakqr.com";
// SMTP settings
public SmtpSettings? Smtp { get; set; }
// SendGrid settings
public SendGridSettings? SendGrid { get; set; }
}
public class SmtpSettings
{
public string Host { get; set; } = "localhost";
public int Port { get; set; } = 587;
public bool UseSsl { get; set; } = true;
public string? Username { get; set; }
public string? Password { get; set; }
}
public class SendGridSettings
{
public string ApiKey { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,92 @@
using System.Net;
using System.Net.Mail;
using api.Features.Email.Templates;
using Microsoft.Extensions.Options;
namespace api.Features.Email.Services;
public class SmtpEmailService : IEmailService
{
private readonly EmailSettings _settings;
private readonly ILogger<SmtpEmailService> _logger;
public SmtpEmailService(IOptions<EmailSettings> settings, ILogger<SmtpEmailService> logger)
{
_settings = settings.Value;
_logger = logger;
}
public async Task SendPasswordResetEmailAsync(string toEmail, string resetToken, CancellationToken ct = default)
{
var resetUrl = $"{_settings.BaseUrl}/reset-password?token={Uri.EscapeDataString(resetToken)}";
var (subject, htmlBody, textBody) = EmailTemplates.PasswordReset(resetUrl);
await SendEmailAsync(toEmail, subject, htmlBody, textBody, ct);
_logger.LogInformation("Password reset email sent to {Email}", toEmail);
}
public async Task SendEmailVerificationAsync(string toEmail, string verificationToken, CancellationToken ct = default)
{
var verifyUrl = $"{_settings.BaseUrl}/verify-email?token={Uri.EscapeDataString(verificationToken)}";
var (subject, htmlBody, textBody) = EmailTemplates.EmailVerification(verifyUrl);
await SendEmailAsync(toEmail, subject, htmlBody, textBody, ct);
_logger.LogInformation("Verification email sent to {Email}", toEmail);
}
public async Task SendWelcomeEmailAsync(string toEmail, string userName, CancellationToken ct = default)
{
var dashboardUrl = $"{_settings.BaseUrl}/dashboard";
var (subject, htmlBody, textBody) = EmailTemplates.Welcome(userName, dashboardUrl);
await SendEmailAsync(toEmail, subject, htmlBody, textBody, ct);
_logger.LogInformation("Welcome email sent to {Email}", toEmail);
}
private async Task SendEmailAsync(string toEmail, string subject, string htmlBody, string textBody, CancellationToken ct)
{
if (_settings.Smtp == null)
{
_logger.LogWarning("SMTP settings not configured. Email not sent to {Email}", toEmail);
return;
}
try
{
using var message = new MailMessage
{
From = new MailAddress(_settings.FromEmail, _settings.FromName),
Subject = subject,
IsBodyHtml = true,
Body = htmlBody
};
message.To.Add(new MailAddress(toEmail));
// Add plain text alternative
var plainTextView = AlternateView.CreateAlternateViewFromString(textBody, null, "text/plain");
var htmlView = AlternateView.CreateAlternateViewFromString(htmlBody, null, "text/html");
message.AlternateViews.Add(plainTextView);
message.AlternateViews.Add(htmlView);
using var client = new SmtpClient(_settings.Smtp.Host, _settings.Smtp.Port)
{
EnableSsl = _settings.Smtp.UseSsl,
DeliveryMethod = SmtpDeliveryMethod.Network
};
if (!string.IsNullOrEmpty(_settings.Smtp.Username))
{
client.Credentials = new NetworkCredential(_settings.Smtp.Username, _settings.Smtp.Password);
}
await client.SendMailAsync(message, ct);
_logger.LogDebug("Email sent successfully to {Email}", toEmail);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send email to {Email}", toEmail);
throw;
}
}
}

View File

@@ -0,0 +1,221 @@
namespace api.Features.Email.Templates;
public static class EmailTemplates
{
public static (string Subject, string HtmlBody, string TextBody) PasswordReset(string resetUrl)
{
var subject = "Reset your TrakQR password";
var htmlBody = $@"
<!DOCTYPE html>
<html>
<head>
<meta charset=""utf-8"">
<meta name=""viewport"" content=""width=device-width, initial-scale=1.0"">
<title>Reset Your Password</title>
</head>
<body style=""margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5;"">
<table role=""presentation"" width=""100%"" cellspacing=""0"" cellpadding=""0"" style=""max-width: 600px; margin: 0 auto; padding: 40px 20px;"">
<tr>
<td style=""background-color: #ffffff; border-radius: 16px; padding: 40px; box-shadow: 0 4px 12px rgba(0,0,0,0.05);"">
<table width=""100%"" cellspacing=""0"" cellpadding=""0"">
<tr>
<td style=""text-align: center; padding-bottom: 30px;"">
<span style=""display: inline-block; background: #1a1a1a; color: #fff4ec; padding: 12px 16px; border-radius: 12px; font-weight: bold; font-size: 18px;"">TQ</span>
</td>
</tr>
<tr>
<td style=""text-align: center; padding-bottom: 20px;"">
<h1 style=""margin: 0; font-size: 24px; color: #1a1a1a;"">Reset your password</h1>
</td>
</tr>
<tr>
<td style=""text-align: center; padding-bottom: 30px; color: #666666; line-height: 1.6;"">
<p style=""margin: 0;"">We received a request to reset your password. Click the button below to choose a new password.</p>
</td>
</tr>
<tr>
<td style=""text-align: center; padding-bottom: 30px;"">
<a href=""{resetUrl}"" style=""display: inline-block; background: #ff6a3d; color: #ffffff; text-decoration: none; padding: 14px 32px; border-radius: 12px; font-weight: 600; font-size: 16px;"">Reset Password</a>
</td>
</tr>
<tr>
<td style=""text-align: center; color: #999999; font-size: 14px; line-height: 1.6;"">
<p style=""margin: 0;"">This link will expire in 1 hour.</p>
<p style=""margin: 10px 0 0 0;"">If you didn't request this, you can safely ignore this email.</p>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style=""text-align: center; padding-top: 30px; color: #999999; font-size: 12px;"">
<p style=""margin: 0;"">&copy; TrakQR. All rights reserved.</p>
</td>
</tr>
</table>
</body>
</html>";
var textBody = $@"Reset Your Password
We received a request to reset your TrakQR password.
Click the link below to reset your password:
{resetUrl}
This link will expire in 1 hour.
If you didn't request this, you can safely ignore this email.
- The TrakQR Team";
return (subject, htmlBody, textBody);
}
public static (string Subject, string HtmlBody, string TextBody) EmailVerification(string verifyUrl)
{
var subject = "Verify your TrakQR email";
var htmlBody = $@"
<!DOCTYPE html>
<html>
<head>
<meta charset=""utf-8"">
<meta name=""viewport"" content=""width=device-width, initial-scale=1.0"">
<title>Verify Your Email</title>
</head>
<body style=""margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5;"">
<table role=""presentation"" width=""100%"" cellspacing=""0"" cellpadding=""0"" style=""max-width: 600px; margin: 0 auto; padding: 40px 20px;"">
<tr>
<td style=""background-color: #ffffff; border-radius: 16px; padding: 40px; box-shadow: 0 4px 12px rgba(0,0,0,0.05);"">
<table width=""100%"" cellspacing=""0"" cellpadding=""0"">
<tr>
<td style=""text-align: center; padding-bottom: 30px;"">
<span style=""display: inline-block; background: #1a1a1a; color: #fff4ec; padding: 12px 16px; border-radius: 12px; font-weight: bold; font-size: 18px;"">TQ</span>
</td>
</tr>
<tr>
<td style=""text-align: center; padding-bottom: 20px;"">
<h1 style=""margin: 0; font-size: 24px; color: #1a1a1a;"">Verify your email</h1>
</td>
</tr>
<tr>
<td style=""text-align: center; padding-bottom: 30px; color: #666666; line-height: 1.6;"">
<p style=""margin: 0;"">Thanks for signing up! Please verify your email address to get started with TrakQR.</p>
</td>
</tr>
<tr>
<td style=""text-align: center; padding-bottom: 30px;"">
<a href=""{verifyUrl}"" style=""display: inline-block; background: #ff6a3d; color: #ffffff; text-decoration: none; padding: 14px 32px; border-radius: 12px; font-weight: 600; font-size: 16px;"">Verify Email</a>
</td>
</tr>
<tr>
<td style=""text-align: center; color: #999999; font-size: 14px; line-height: 1.6;"">
<p style=""margin: 0;"">This link will expire in 24 hours.</p>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style=""text-align: center; padding-top: 30px; color: #999999; font-size: 12px;"">
<p style=""margin: 0;"">&copy; TrakQR. All rights reserved.</p>
</td>
</tr>
</table>
</body>
</html>";
var textBody = $@"Verify Your Email
Thanks for signing up for TrakQR!
Please verify your email address by clicking the link below:
{verifyUrl}
This link will expire in 24 hours.
- The TrakQR Team";
return (subject, htmlBody, textBody);
}
public static (string Subject, string HtmlBody, string TextBody) Welcome(string userName, string dashboardUrl)
{
var subject = "Welcome to TrakQR!";
var htmlBody = $@"
<!DOCTYPE html>
<html>
<head>
<meta charset=""utf-8"">
<meta name=""viewport"" content=""width=device-width, initial-scale=1.0"">
<title>Welcome to TrakQR</title>
</head>
<body style=""margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5;"">
<table role=""presentation"" width=""100%"" cellspacing=""0"" cellpadding=""0"" style=""max-width: 600px; margin: 0 auto; padding: 40px 20px;"">
<tr>
<td style=""background-color: #ffffff; border-radius: 16px; padding: 40px; box-shadow: 0 4px 12px rgba(0,0,0,0.05);"">
<table width=""100%"" cellspacing=""0"" cellpadding=""0"">
<tr>
<td style=""text-align: center; padding-bottom: 30px;"">
<span style=""display: inline-block; background: #1a1a1a; color: #fff4ec; padding: 12px 16px; border-radius: 12px; font-weight: bold; font-size: 18px;"">TQ</span>
</td>
</tr>
<tr>
<td style=""text-align: center; padding-bottom: 20px;"">
<h1 style=""margin: 0; font-size: 24px; color: #1a1a1a;"">Welcome to TrakQR!</h1>
</td>
</tr>
<tr>
<td style=""text-align: center; padding-bottom: 30px; color: #666666; line-height: 1.6;"">
<p style=""margin: 0;"">Hi {userName},</p>
<p style=""margin: 10px 0 0 0;"">You're all set! Start creating short links and beautiful QR codes with powerful analytics.</p>
</td>
</tr>
<tr>
<td style=""text-align: center; padding-bottom: 30px;"">
<a href=""{dashboardUrl}"" style=""display: inline-block; background: #ff6a3d; color: #ffffff; text-decoration: none; padding: 14px 32px; border-radius: 12px; font-weight: 600; font-size: 16px;"">Go to Dashboard</a>
</td>
</tr>
<tr>
<td style=""padding: 20px; background: #f9f9f9; border-radius: 12px;"">
<h3 style=""margin: 0 0 15px 0; font-size: 16px; color: #1a1a1a;"">Get started:</h3>
<ul style=""margin: 0; padding-left: 20px; color: #666666; line-height: 1.8;"">
<li>Create your first short link</li>
<li>Design a custom QR code</li>
<li>Track clicks and scans in real-time</li>
</ul>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style=""text-align: center; padding-top: 30px; color: #999999; font-size: 12px;"">
<p style=""margin: 0;"">&copy; TrakQR. All rights reserved.</p>
</td>
</tr>
</table>
</body>
</html>";
var textBody = $@"Welcome to TrakQR!
Hi {userName},
You're all set! Start creating short links and beautiful QR codes with powerful analytics.
Get started:
- Create your first short link
- Design a custom QR code
- Track clicks and scans in real-time
Go to your dashboard: {dashboardUrl}
- The TrakQR Team";
return (subject, htmlBody, textBody);
}
}

View File

@@ -13,7 +13,7 @@ public interface IEventTrackingService
Task TrackScanAsync(Guid workspaceId, Guid shortLinkId, Guid qrCodeId, HttpContext context);
}
public class EventTrackingService(IServiceScopeFactory scopeFactory, ILogger<EventTrackingService> logger)
public class EventTrackingService(IServiceScopeFactory scopeFactory, IGeoIpService geoIpService, ILogger<EventTrackingService> logger)
: IEventTrackingService
{
// Dedupe window - same visitor clicking same link within this window counts as one
@@ -72,6 +72,7 @@ public class EventTrackingService(IServiceScopeFactory scopeFactory, ILogger<Eve
var ipHash = HashIpAddress(ipAddress);
var deviceType = ParseDeviceType(userAgent);
var countryCode = geoIpService.GetCountryCode(ipAddress);
var dedupeKey = GenerateDedupeKey(ipHash, shortLinkId, qrCodeId);
// Check for duplicate within the dedupe window
@@ -95,7 +96,7 @@ public class EventTrackingService(IServiceScopeFactory scopeFactory, ILogger<Eve
IpHash = ipHash,
UserAgent = TruncateString(userAgent, 512),
Referrer = TruncateString(referrer, 2048),
CountryCode = null, // TODO: GeoIP lookup
CountryCode = countryCode,
DeviceType = deviceType,
DedupeKey = dedupeKey
};

View File

@@ -0,0 +1,95 @@
using System.Net;
using MaxMind.GeoIP2;
namespace api.Features.Events.Services;
public interface IGeoIpService
{
string? GetCountryCode(string ipAddress);
}
public class GeoIpService : IGeoIpService, IDisposable
{
private readonly DatabaseReader? _reader;
private readonly ILogger<GeoIpService> _logger;
public GeoIpService(IConfiguration configuration, ILogger<GeoIpService> logger)
{
_logger = logger;
var dbPath = configuration["GeoIP:DatabasePath"];
if (!string.IsNullOrEmpty(dbPath) && File.Exists(dbPath))
{
try
{
_reader = new DatabaseReader(dbPath);
_logger.LogInformation("GeoIP database loaded from {Path}", dbPath);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load GeoIP database from {Path}", dbPath);
}
}
else
{
_logger.LogInformation("GeoIP database not configured or not found. Country detection disabled.");
}
}
public string? GetCountryCode(string ipAddress)
{
if (_reader == null)
return null;
try
{
// Handle localhost and private IPs
if (ipAddress == "127.0.0.1" || ipAddress == "::1" || IsPrivateIp(ipAddress))
{
return null;
}
if (!IPAddress.TryParse(ipAddress, out var ip))
{
return null;
}
if (_reader.TryCountry(ip, out var response))
{
return response?.Country?.IsoCode;
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to lookup country for IP {IP}", ipAddress);
}
return null;
}
private static bool IsPrivateIp(string ipAddress)
{
if (!IPAddress.TryParse(ipAddress, out var ip))
return false;
var bytes = ip.GetAddressBytes();
// Check for IPv4 private ranges
if (bytes.Length == 4)
{
// 10.0.0.0/8
if (bytes[0] == 10) return true;
// 172.16.0.0/12
if (bytes[0] == 172 && bytes[1] >= 16 && bytes[1] <= 31) return true;
// 192.168.0.0/16
if (bytes[0] == 192 && bytes[1] == 168) return true;
}
return false;
}
public void Dispose()
{
_reader?.Dispose();
}
}

View File

@@ -0,0 +1,12 @@
namespace api.Features.Links.Endpoints;
public class LinkDto
{
public required Guid Id { get; set; }
public required string Slug { get; set; }
public required string DestinationUrl { get; set; }
public required string? Title { get; set; }
public required string Status { get; set; }
public int ClickCount { get; set; }
public DateTimeOffset CreatedAt { get; set; }
};

View File

@@ -12,7 +12,8 @@ public record LinkResponse(
DateTime? ExpiresAt,
bool HasPassword,
DateTime CreatedAt,
DateTime UpdatedAt
DateTime UpdatedAt,
DateTime? DeletedAt = null
);
public record LinkListResponse(

View File

@@ -0,0 +1,177 @@
using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using api.Features.Links.Common;
using api.Models;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
namespace api.Features.Links.Endpoints;
public class BulkCreateLinksRequest
{
public Guid WorkspaceId { get; set; }
public required List<BulkLinkItem> Links { get; set; }
}
public class BulkLinkItem
{
public required string DestinationUrl { get; set; }
public string? Title { get; set; }
public string? Slug { get; set; }
}
public class BulkCreateLinksResponse
{
public required List<LinkDto> Created { get; set; }
public required List<BulkLinkError> Errors { get; set; }
}
public class BulkLinkError
{
public int Index { get; set; }
public required string Url { get; set; }
public required string Error { get; set; }
}
public class BulkCreateLinksEndpoint(AppDbContext db)
: Endpoint<BulkCreateLinksRequest, BulkCreateLinksResponse>
{
public override void Configure()
{
Post("/workspaces/{WorkspaceId}/links/bulk");
}
public override async Task HandleAsync(BulkCreateLinksRequest req, CancellationToken ct)
{
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
// Verify workspace ownership
var workspace = await db.Workspaces
.FirstOrDefaultAsync(w => w.Id == req.WorkspaceId && w.OwnerUserId == userId, ct);
if (workspace is null)
{
await HttpContext.Response.SendAsync(new MessageResponse("Workspace not found"), 404, cancellation: ct);
return;
}
// Limit bulk creation to 100 links at a time
if (req.Links.Count > 100)
{
await HttpContext.Response.SendAsync(new MessageResponse("Maximum 100 links per request"), 400, cancellation: ct);
return;
}
var created = new List<LinkDto>();
var errors = new List<BulkLinkError>();
// Check for plan limits
var currentLinkCount = await db.ShortLinks.CountAsync(l => l.WorkspaceId == req.WorkspaceId, ct);
var linkLimit = GetPlanLinkLimit(workspace.Plan);
for (int i = 0; i < req.Links.Count; i++)
{
var item = req.Links[i];
// Validate URL
if (!Uri.TryCreate(item.DestinationUrl, UriKind.Absolute, out var uri) ||
(uri.Scheme != "http" && uri.Scheme != "https"))
{
errors.Add(new BulkLinkError
{
Index = i,
Url = item.DestinationUrl,
Error = "Invalid URL"
});
continue;
}
// Check plan limits
if (linkLimit.HasValue && currentLinkCount + created.Count >= linkLimit.Value)
{
errors.Add(new BulkLinkError
{
Index = i,
Url = item.DestinationUrl,
Error = "Plan link limit reached"
});
continue;
}
// Generate or validate slug
var slug = item.Slug;
if (string.IsNullOrWhiteSpace(slug))
{
slug = GenerateSlug();
}
else
{
// Check if slug is taken
var slugTaken = await db.ShortLinks.AnyAsync(l => l.Slug == slug, ct);
if (slugTaken)
{
errors.Add(new BulkLinkError
{
Index = i,
Url = item.DestinationUrl,
Error = $"Slug '{slug}' is already taken"
});
continue;
}
}
var link = new ShortLink
{
Id = Guid.NewGuid(),
WorkspaceId = req.WorkspaceId,
Slug = slug,
DestinationUrl = item.DestinationUrl,
Title = item.Title,
Status = ShortLinkStatus.Active,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
};
db.ShortLinks.Add(link);
created.Add(new LinkDto
{
Id = link.Id,
Slug = link.Slug,
DestinationUrl = link.DestinationUrl,
Title = link?.Title,
Status = link.Status.ToString(),
ClickCount = 0,
CreatedAt = link.CreatedAt,
});
}
await db.SaveChangesAsync(ct);
var response = new BulkCreateLinksResponse
{
Created = created,
Errors = errors
};
await HttpContext.Response.SendAsync(response, 201, cancellation: ct);
}
private static string GenerateSlug()
{
const string chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
var random = new Random();
return new string(Enumerable.Repeat(chars, 7).Select(s => s[random.Next(s.Length)]).ToArray());
}
private static int? GetPlanLinkLimit(WorkspacePlan? plan)
{
return plan switch
{
WorkspacePlan.Business => null, // Unlimited
WorkspacePlan.Pro => 10000,
_ => 100 // Free plan
};
}
}

View File

@@ -2,6 +2,7 @@ using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using api.Features.Links.Common;
using api.Features.Plans.Services;
using api.Models;
using FastEndpoints;
using FluentValidation;
@@ -43,7 +44,7 @@ public class CreateLinkValidator : Validator<CreateLinkRequest>
}
}
public class CreateLinkEndpoint(AppDbContext db)
public class CreateLinkEndpoint(AppDbContext db, IPlanLimitsService planLimits)
: Endpoint<CreateLinkRequest, LinkResponse>
{
public override void Configure()
@@ -65,6 +66,16 @@ public class CreateLinkEndpoint(AppDbContext db)
return;
}
// Check plan limits
if (!await planLimits.CanCreateLinkAsync(req.WorkspaceId, ct))
{
await HttpContext.Response.SendAsync(
new MessageResponse("Link limit reached. Please upgrade your plan to create more links."),
402,
cancellation: ct);
return;
}
// Verify project belongs to workspace if specified
if (req.ProjectId.HasValue)
{

View File

@@ -26,7 +26,11 @@ public class DeleteLinkEndpoint(AppDbContext db)
var link = await db.ShortLinks
.Include(l => l.Workspace)
.FirstOrDefaultAsync(l => l.Id == req.Id && l.WorkspaceId == req.WorkspaceId && l.Workspace.OwnerUserId == userId, ct);
.FirstOrDefaultAsync(l =>
l.Id == req.Id &&
l.WorkspaceId == req.WorkspaceId &&
l.Workspace.OwnerUserId == userId &&
l.DeletedAt == null, ct);
if (link is null)
{
@@ -34,7 +38,8 @@ public class DeleteLinkEndpoint(AppDbContext db)
return;
}
db.ShortLinks.Remove(link);
// Soft delete
link.DeletedAt = DateTime.UtcNow;
await db.SaveChangesAsync(ct);
await HttpContext.Response.SendAsync(new MessageResponse("Link deleted"), 200, cancellation: ct);

View File

@@ -26,7 +26,7 @@ public class GetLinkEndpoint(AppDbContext db)
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
var link = await db.ShortLinks
.Where(l => l.Id == req.Id && l.WorkspaceId == req.WorkspaceId && l.Workspace.OwnerUserId == userId)
.Where(l => l.Id == req.Id && l.WorkspaceId == req.WorkspaceId && l.Workspace.OwnerUserId == userId && l.DeletedAt == null)
.Select(l => new LinkResponse(
l.Id,
l.WorkspaceId,
@@ -39,7 +39,8 @@ public class GetLinkEndpoint(AppDbContext db)
l.ExpiresAt,
l.PasswordHash != null,
l.CreatedAt,
l.UpdatedAt
l.UpdatedAt,
l.DeletedAt
))
.FirstOrDefaultAsync(ct);

View File

@@ -12,6 +12,7 @@ public class ListLinksRequest
public Guid WorkspaceId { get; set; }
public Guid? ProjectId { get; set; }
public string? Status { get; set; }
public bool IncludeDeleted { get; set; } = false;
}
public class ListLinksEndpoint(AppDbContext db)
@@ -39,6 +40,12 @@ public class ListLinksEndpoint(AppDbContext db)
var query = db.ShortLinks
.Where(l => l.WorkspaceId == req.WorkspaceId);
// Filter by deleted status (exclude soft-deleted by default)
if (!req.IncludeDeleted)
{
query = query.Where(l => l.DeletedAt == null);
}
// Filter by project if specified
if (req.ProjectId.HasValue)
{
@@ -65,7 +72,8 @@ public class ListLinksEndpoint(AppDbContext db)
l.ExpiresAt,
l.PasswordHash != null,
l.CreatedAt,
l.UpdatedAt
l.UpdatedAt,
l.DeletedAt
))
.ToListAsync(ct);

View File

@@ -0,0 +1,65 @@
using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using api.Features.Links.Common;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
namespace api.Features.Links.Endpoints;
public class RestoreLinkRequest
{
public Guid WorkspaceId { get; set; }
public Guid Id { get; set; }
}
public class RestoreLinkEndpoint(AppDbContext db)
: Endpoint<RestoreLinkRequest, LinkResponse>
{
public override void Configure()
{
Post("/workspaces/{WorkspaceId}/links/{Id}/restore");
}
public override async Task HandleAsync(RestoreLinkRequest req, CancellationToken ct)
{
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
var link = await db.ShortLinks
.Include(l => l.Workspace)
.FirstOrDefaultAsync(l =>
l.Id == req.Id &&
l.WorkspaceId == req.WorkspaceId &&
l.Workspace.OwnerUserId == userId &&
l.DeletedAt != null, ct);
if (link is null)
{
await HttpContext.Response.SendAsync(new MessageResponse("Deleted link not found"), 404, cancellation: ct);
return;
}
// Restore the link
link.DeletedAt = null;
link.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync(ct);
var response = new LinkResponse(
link.Id,
link.WorkspaceId,
link.ProjectId,
link.DomainId,
link.Slug,
link.DestinationUrl,
link.Title,
link.Status.ToString(),
link.ExpiresAt,
link.PasswordHash != null,
link.CreatedAt,
link.UpdatedAt,
link.DeletedAt
);
await HttpContext.Response.SendAsync(response, 200, cancellation: ct);
}
}

View File

@@ -0,0 +1,94 @@
using System.Security.Claims;
using api.Features.Plans.Services;
using FastEndpoints;
namespace api.Features.Plans.Endpoints;
public class GetUsageRequest
{
public Guid? WorkspaceId { get; set; }
}
public record UsageResponse(
int Workspaces,
int Links,
int QRCodes,
int Domains,
int EventsThisMonth,
string Plan,
LimitsResponse Limits
);
public record LimitsResponse(
int MaxWorkspaces,
int MaxLinks,
int MaxQRCodes,
int MaxDomains,
int MaxEventsPerMonth,
bool HasCustomDomains,
bool HasPasswordProtection
);
public class GetUsageEndpoint(IPlanLimitsService planLimits)
: Endpoint<GetUsageRequest, UsageResponse>
{
public override void Configure()
{
Get("/usage");
}
public override async Task HandleAsync(GetUsageRequest req, CancellationToken ct)
{
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
if (req.WorkspaceId.HasValue)
{
var wsUsage = await planLimits.GetWorkspaceUsageAsync(req.WorkspaceId.Value, ct);
var response = new UsageResponse(
Workspaces: 1,
Links: wsUsage.Links,
QRCodes: wsUsage.QRCodes,
Domains: wsUsage.Domains,
EventsThisMonth: wsUsage.EventsThisMonth,
Plan: wsUsage.Plan.ToString(),
Limits: new LimitsResponse(
MaxWorkspaces: wsUsage.Limits.MaxWorkspaces,
MaxLinks: wsUsage.Limits.MaxLinksPerWorkspace,
MaxQRCodes: wsUsage.Limits.MaxQRCodesPerWorkspace,
MaxDomains: wsUsage.Limits.MaxDomainsPerWorkspace,
MaxEventsPerMonth: wsUsage.Limits.MaxEventsPerMonth,
HasCustomDomains: wsUsage.Limits.HasCustomDomains,
HasPasswordProtection: wsUsage.Limits.HasPasswordProtection
)
);
await HttpContext.Response.SendAsync(response, cancellation: ct);
}
else
{
var usage = await planLimits.GetUsageAsync(userId, ct);
var limits = planLimits.GetLimits(usage.HighestPlan);
var response = new UsageResponse(
Workspaces: usage.TotalWorkspaces,
Links: usage.TotalLinks,
QRCodes: usage.TotalQRCodes,
Domains: usage.TotalDomains,
EventsThisMonth: usage.EventsThisMonth,
Plan: usage.HighestPlan.ToString(),
Limits: new LimitsResponse(
MaxWorkspaces: limits.MaxWorkspaces,
MaxLinks: limits.MaxLinksPerWorkspace,
MaxQRCodes: limits.MaxQRCodesPerWorkspace,
MaxDomains: limits.MaxDomainsPerWorkspace,
MaxEventsPerMonth: limits.MaxEventsPerMonth,
HasCustomDomains: limits.HasCustomDomains,
HasPasswordProtection: limits.HasPasswordProtection
)
);
await HttpContext.Response.SendAsync(response, cancellation: ct);
}
}
}

View File

@@ -0,0 +1,190 @@
using api.Data;
using api.Models;
using Microsoft.EntityFrameworkCore;
namespace api.Features.Plans.Services;
public interface IPlanLimitsService
{
PlanLimits GetLimits(WorkspacePlan plan);
Task<UsageStats> GetUsageAsync(Guid userId, CancellationToken ct = default);
Task<WorkspaceUsageStats> GetWorkspaceUsageAsync(Guid workspaceId, CancellationToken ct = default);
Task<bool> CanCreateWorkspaceAsync(Guid userId, CancellationToken ct = default);
Task<bool> CanCreateLinkAsync(Guid workspaceId, CancellationToken ct = default);
Task<bool> CanCreateQRCodeAsync(Guid workspaceId, CancellationToken ct = default);
Task<bool> CanCreateDomainAsync(Guid workspaceId, CancellationToken ct = default);
Task<bool> CanTrackEventAsync(Guid workspaceId, CancellationToken ct = default);
}
public record PlanLimits(
int MaxWorkspaces,
int MaxLinksPerWorkspace,
int MaxQRCodesPerWorkspace,
int MaxDomainsPerWorkspace,
int MaxEventsPerMonth,
bool HasCustomDomains,
bool HasPasswordProtection,
bool HasAnalytics
);
public record UsageStats(
int TotalWorkspaces,
int TotalLinks,
int TotalQRCodes,
int TotalDomains,
int EventsThisMonth,
WorkspacePlan HighestPlan
);
public record WorkspaceUsageStats(
Guid WorkspaceId,
WorkspacePlan Plan,
int Links,
int QRCodes,
int Domains,
int EventsThisMonth,
PlanLimits Limits
);
public class PlanLimitsService(IServiceScopeFactory scopeFactory) : IPlanLimitsService
{
private static readonly Dictionary<WorkspacePlan, PlanLimits> PlanConfigs = new()
{
[WorkspacePlan.Free] = new PlanLimits(
MaxWorkspaces: 1,
MaxLinksPerWorkspace: 50,
MaxQRCodesPerWorkspace: 25,
MaxDomainsPerWorkspace: 0,
MaxEventsPerMonth: 10_000,
HasCustomDomains: false,
HasPasswordProtection: false,
HasAnalytics: true
),
[WorkspacePlan.Pro] = new PlanLimits(
MaxWorkspaces: 5,
MaxLinksPerWorkspace: 5_000,
MaxQRCodesPerWorkspace: 1_000,
MaxDomainsPerWorkspace: 3,
MaxEventsPerMonth: 100_000,
HasCustomDomains: true,
HasPasswordProtection: true,
HasAnalytics: true
),
[WorkspacePlan.Business] = new PlanLimits(
MaxWorkspaces: int.MaxValue,
MaxLinksPerWorkspace: int.MaxValue,
MaxQRCodesPerWorkspace: int.MaxValue,
MaxDomainsPerWorkspace: int.MaxValue,
MaxEventsPerMonth: int.MaxValue,
HasCustomDomains: true,
HasPasswordProtection: true,
HasAnalytics: true
)
};
public PlanLimits GetLimits(WorkspacePlan plan) => PlanConfigs[plan];
public async Task<UsageStats> GetUsageAsync(Guid userId, CancellationToken ct = default)
{
using var scope = scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var workspaces = await db.Workspaces
.Where(w => w.OwnerUserId == userId)
.Select(w => new { w.Id, w.Plan })
.ToListAsync(ct);
var workspaceIds = workspaces.Select(w => w.Id).ToList();
var totalLinks = await db.ShortLinks
.CountAsync(l => workspaceIds.Contains(l.WorkspaceId), ct);
var totalQRCodes = await db.QrCodeDesigns
.CountAsync(q => workspaceIds.Contains(q.WorkspaceId), ct);
var totalDomains = await db.Domains
.CountAsync(d => workspaceIds.Contains(d.WorkspaceId), ct);
var monthStart = new DateTime(DateTime.UtcNow.Year, DateTime.UtcNow.Month, 1, 0, 0, 0, DateTimeKind.Utc);
var eventsThisMonth = await db.Events
.CountAsync(e => workspaceIds.Contains(e.WorkspaceId) && e.Timestamp >= monthStart, ct);
var highestPlan = workspaces.Any()
? workspaces.Max(w => w.Plan)
: WorkspacePlan.Free;
return new UsageStats(
TotalWorkspaces: workspaces.Count,
TotalLinks: totalLinks,
TotalQRCodes: totalQRCodes,
TotalDomains: totalDomains,
EventsThisMonth: eventsThisMonth,
HighestPlan: highestPlan
);
}
public async Task<WorkspaceUsageStats> GetWorkspaceUsageAsync(Guid workspaceId, CancellationToken ct = default)
{
using var scope = scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var workspace = await db.Workspaces
.Where(w => w.Id == workspaceId)
.Select(w => new { w.Id, w.Plan })
.FirstOrDefaultAsync(ct);
if (workspace == null)
throw new KeyNotFoundException("Workspace not found");
var links = await db.ShortLinks.CountAsync(l => l.WorkspaceId == workspaceId, ct);
var qrCodes = await db.QrCodeDesigns.CountAsync(q => q.WorkspaceId == workspaceId, ct);
var domains = await db.Domains.CountAsync(d => d.WorkspaceId == workspaceId, ct);
var monthStart = new DateTime(DateTime.UtcNow.Year, DateTime.UtcNow.Month, 1, 0, 0, 0, DateTimeKind.Utc);
var eventsThisMonth = await db.Events
.CountAsync(e => e.WorkspaceId == workspaceId && e.Timestamp >= monthStart, ct);
var limits = GetLimits(workspace.Plan);
return new WorkspaceUsageStats(
WorkspaceId: workspaceId,
Plan: workspace.Plan,
Links: links,
QRCodes: qrCodes,
Domains: domains,
EventsThisMonth: eventsThisMonth,
Limits: limits
);
}
public async Task<bool> CanCreateWorkspaceAsync(Guid userId, CancellationToken ct = default)
{
var usage = await GetUsageAsync(userId, ct);
var limits = GetLimits(usage.HighestPlan);
return usage.TotalWorkspaces < limits.MaxWorkspaces;
}
public async Task<bool> CanCreateLinkAsync(Guid workspaceId, CancellationToken ct = default)
{
var usage = await GetWorkspaceUsageAsync(workspaceId, ct);
return usage.Links < usage.Limits.MaxLinksPerWorkspace;
}
public async Task<bool> CanCreateQRCodeAsync(Guid workspaceId, CancellationToken ct = default)
{
var usage = await GetWorkspaceUsageAsync(workspaceId, ct);
return usage.QRCodes < usage.Limits.MaxQRCodesPerWorkspace;
}
public async Task<bool> CanCreateDomainAsync(Guid workspaceId, CancellationToken ct = default)
{
var usage = await GetWorkspaceUsageAsync(workspaceId, ct);
return usage.Domains < usage.Limits.MaxDomainsPerWorkspace;
}
public async Task<bool> CanTrackEventAsync(Guid workspaceId, CancellationToken ct = default)
{
var usage = await GetWorkspaceUsageAsync(workspaceId, ct);
return usage.EventsThisMonth < usage.Limits.MaxEventsPerMonth;
}
}

View File

@@ -4,6 +4,9 @@ public record ProjectResponse(
Guid Id,
Guid WorkspaceId,
string Name,
string? Description,
int LinkCount,
int QRCodeCount,
DateTime CreatedAt
);

View File

@@ -13,6 +13,7 @@ public class CreateProjectRequest
{
public Guid WorkspaceId { get; set; }
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
}
public class CreateProjectValidator : Validator<CreateProjectRequest>
@@ -52,6 +53,7 @@ public class CreateProjectEndpoint(AppDbContext db)
Id = Guid.NewGuid(),
WorkspaceId = req.WorkspaceId,
Name = req.Name,
Description = req.Description,
CreatedAt = DateTime.UtcNow
};
@@ -62,6 +64,9 @@ public class CreateProjectEndpoint(AppDbContext db)
project.Id,
project.WorkspaceId,
project.Name,
project.Description,
0, // LinkCount - new project has no links
0, // QRCodeCount - new project has no QR codes
project.CreatedAt
);

View File

@@ -31,6 +31,9 @@ public class GetProjectEndpoint(AppDbContext db)
p.Id,
p.WorkspaceId,
p.Name,
p.Description,
p.ShortLinks.Count(l => l.DeletedAt == null),
p.QRCodeDesigns.Count,
p.CreatedAt
))
.FirstOrDefaultAsync(ct);

View File

@@ -41,6 +41,9 @@ public class ListProjectsEndpoint(AppDbContext db)
p.Id,
p.WorkspaceId,
p.Name,
p.Description,
p.ShortLinks.Count(l => l.DeletedAt == null),
p.QRCodeDesigns.Count,
p.CreatedAt
))
.ToListAsync(ct);

View File

@@ -13,6 +13,7 @@ public class UpdateProjectRequest
public Guid WorkspaceId { get; set; }
public Guid Id { get; set; }
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
}
public class UpdateProjectValidator : Validator<UpdateProjectRequest>
@@ -39,6 +40,8 @@ public class UpdateProjectEndpoint(AppDbContext db)
var project = await db.Projects
.Include(p => p.Workspace)
.Include(p => p.ShortLinks)
.Include(p => p.QRCodeDesigns)
.FirstOrDefaultAsync(p => p.Id == req.Id && p.WorkspaceId == req.WorkspaceId && p.Workspace.OwnerUserId == userId, ct);
if (project is null)
@@ -48,12 +51,16 @@ public class UpdateProjectEndpoint(AppDbContext db)
}
project.Name = req.Name;
project.Description = req.Description;
await db.SaveChangesAsync(ct);
var response = new ProjectResponse(
project.Id,
project.WorkspaceId,
project.Name,
project.Description,
project.ShortLinks.Count(l => l.DeletedAt == null),
project.QRCodeDesigns.Count,
project.CreatedAt
);

View File

@@ -35,8 +35,10 @@ public record QRCodeResponse(
Guid? ProjectId,
Guid? ShortLinkId,
string? ShortLinkSlug,
string Name,
QRCodeStyle Style,
Guid? LogoAssetId,
string? LogoUrl,
DateTime CreatedAt,
DateTime UpdatedAt
);

View File

@@ -2,6 +2,7 @@ using System.Security.Claims;
using System.Text.Json;
using api.Data;
using api.Features.Auth.Common;
using api.Features.Plans.Services;
using api.Features.QRCodes.Common;
using api.Models;
using FastEndpoints;
@@ -15,6 +16,8 @@ public class CreateQRCodeRequest
public Guid WorkspaceId { get; set; }
public Guid? ProjectId { get; set; }
public Guid? ShortLinkId { get; set; }
public Guid? LogoAssetId { get; set; }
public string? Name { get; set; }
public QRCodeStyle? Style { get; set; }
}
@@ -27,7 +30,7 @@ public class CreateQRCodeValidator : Validator<CreateQRCodeRequest>
}
}
public class CreateQRCodeEndpoint(AppDbContext db)
public class CreateQRCodeEndpoint(AppDbContext db, IPlanLimitsService planLimits)
: Endpoint<CreateQRCodeRequest, QRCodeResponse>
{
public override void Configure()
@@ -49,6 +52,16 @@ public class CreateQRCodeEndpoint(AppDbContext db)
return;
}
// Check plan limits
if (!await planLimits.CanCreateQRCodeAsync(req.WorkspaceId, ct))
{
await HttpContext.Response.SendAsync(
new MessageResponse("QR code limit reached. Please upgrade your plan to create more QR codes."),
402,
cancellation: ct);
return;
}
// Verify short link belongs to workspace
string? linkSlug = null;
if (req.ShortLinkId.HasValue)
@@ -79,7 +92,26 @@ public class CreateQRCodeEndpoint(AppDbContext db)
}
}
// Verify logo asset belongs to workspace if specified
string? logoUrl = null;
if (req.LogoAssetId.HasValue)
{
var asset = await db.Assets
.Where(a => a.Id == req.LogoAssetId.Value && a.WorkspaceId == req.WorkspaceId)
.Select(a => new { a.StorageKey })
.FirstOrDefaultAsync(ct);
if (asset is null)
{
await HttpContext.Response.SendAsync(new MessageResponse("Logo asset not found"), 404, cancellation: ct);
return;
}
logoUrl = $"{HttpContext.Request.Scheme}://{HttpContext.Request.Host}/assets/{asset.StorageKey}";
}
var style = req.Style ?? new QRCodeStyle();
var name = req.Name ?? $"QR Code {DateTime.UtcNow:yyyy-MM-dd}";
var now = DateTime.UtcNow;
var qrCode = new QRCodeDesign
@@ -88,8 +120,9 @@ public class CreateQRCodeEndpoint(AppDbContext db)
WorkspaceId = req.WorkspaceId,
ProjectId = req.ProjectId,
ShortLinkId = req.ShortLinkId,
Name = name,
StyleJson = JsonSerializer.Serialize(style),
LogoAssetId = null,
LogoAssetId = req.LogoAssetId,
CreatedAt = now,
UpdatedAt = now
};
@@ -103,8 +136,10 @@ public class CreateQRCodeEndpoint(AppDbContext db)
qrCode.ProjectId,
qrCode.ShortLinkId,
linkSlug,
qrCode.Name,
style,
qrCode.LogoAssetId,
logoUrl,
qrCode.CreatedAt,
qrCode.UpdatedAt
);

View File

@@ -1,6 +1,7 @@
using System.Security.Claims;
using System.Text.Json;
using api.Data;
using api.Features.Assets.Services;
using api.Features.Auth.Common;
using api.Features.QRCodes.Common;
using api.Features.QRCodes.Services;
@@ -17,7 +18,7 @@ public class ExportQRCodeRequest
public int? Size { get; set; }
}
public class ExportQRCodeEndpoint(AppDbContext db, IQRCodeGeneratorService qrGenerator)
public class ExportQRCodeEndpoint(AppDbContext db, IQrCodeGeneratorService qrGenerator, IAssetStorageService assetStorage)
: Endpoint<ExportQRCodeRequest>
{
public override void Configure()
@@ -31,6 +32,7 @@ public class ExportQRCodeEndpoint(AppDbContext db, IQRCodeGeneratorService qrGen
var qrCode = await db.QrCodeDesigns
.Include(q => q.ShortLink)
.Include(q => q.LogoAsset)
.Where(q => q.Id == req.Id && q.WorkspaceId == req.WorkspaceId && q.Workspace.OwnerUserId == userId)
.FirstOrDefaultAsync(ct);
@@ -50,25 +52,44 @@ public class ExportQRCodeEndpoint(AppDbContext db, IQRCodeGeneratorService qrGen
var format = (req.Format ?? "png").ToLowerInvariant();
var size = req.Size ?? 512;
// Build the short link URL
// Build the short link URL with QR tracking param
var baseUrl = $"{HttpContext.Request.Scheme}://{HttpContext.Request.Host}";
var linkUrl = $"{baseUrl}/{qrCode.ShortLink.Slug}";
var linkUrl = $"{baseUrl}/{qrCode.ShortLink.Slug}?qr={qrCode.Id}";
var filename = $"qrcode-{qrCode.ShortLink.Slug}";
if (format == "svg")
// Load logo if available
Stream? logoStream = null;
if (qrCode.LogoAsset != null)
{
var svg = qrGenerator.GenerateSvg(linkUrl, style, size);
HttpContext.Response.ContentType = "image/svg+xml";
HttpContext.Response.Headers.ContentDisposition = $"attachment; filename=\"{filename}.svg\"";
await HttpContext.Response.WriteAsync(svg, ct);
var logoResult = await assetStorage.GetAsync(qrCode.LogoAsset.StorageKey);
if (logoResult.HasValue)
{
logoStream = logoResult.Value.Stream;
}
}
else
try
{
var png = qrGenerator.GeneratePng(linkUrl, style, size);
HttpContext.Response.ContentType = "image/png";
HttpContext.Response.Headers.ContentDisposition = $"attachment; filename=\"{filename}.png\"";
await HttpContext.Response.Body.WriteAsync(png, ct);
if (format == "svg")
{
// SVG doesn't support logo overlay currently
var svg = qrGenerator.GenerateSvg(linkUrl, style, size);
HttpContext.Response.ContentType = "image/svg+xml";
HttpContext.Response.Headers.ContentDisposition = $"attachment; filename=\"{filename}.svg\"";
await HttpContext.Response.WriteAsync(svg, ct);
}
else
{
var png = qrGenerator.GeneratePng(linkUrl, style, size, logoStream);
HttpContext.Response.ContentType = "image/png";
HttpContext.Response.Headers.ContentDisposition = $"attachment; filename=\"{filename}.png\"";
await HttpContext.Response.Body.WriteAsync(png, ct);
}
}
finally
{
logoStream?.Dispose();
}
}
}

View File

@@ -0,0 +1,138 @@
using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using api.Models;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
namespace api.Features.QRCodes.Endpoints;
public class GetQRCodeAnalyticsRequest
{
public Guid WorkspaceId { get; set; }
public Guid Id { get; set; }
public string Period { get; set; } = "7d";
}
public record QRCodeAnalyticsSummary(
int TotalScans,
int UniqueVisitors
);
public record QRCodeTimeSeriesPoint(
string Date,
int Scans
);
public record QRCodeAnalyticsResponse(
Guid QRCodeId,
string Name,
string? LinkSlug,
QRCodeAnalyticsSummary Summary,
List<QRCodeTimeSeriesPoint> TimeSeries,
Dictionary<string, int> DeviceBreakdown,
Dictionary<string, int> ReferrerBreakdown,
Dictionary<string, int> CountryBreakdown
);
public class GetQRCodeAnalyticsEndpoint(AppDbContext db)
: Endpoint<GetQRCodeAnalyticsRequest, QRCodeAnalyticsResponse>
{
public override void Configure()
{
Get("/workspaces/{WorkspaceId}/qrcodes/{Id}/analytics");
}
public override async Task HandleAsync(GetQRCodeAnalyticsRequest req, CancellationToken ct)
{
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
var qrCode = await db.QrCodeDesigns
.Include(q => q.ShortLink)
.Where(q => q.Id == req.Id && q.WorkspaceId == req.WorkspaceId && q.Workspace.OwnerUserId == userId)
.FirstOrDefaultAsync(ct);
if (qrCode == null)
{
await HttpContext.Response.SendAsync(new MessageResponse("QR code not found"), 404, cancellation: ct);
return;
}
var startDate = GetStartDate(req.Period);
var events = await db.Events
.Where(e => e.QRCodeId == req.Id && e.Type == EventType.Scan && e.Timestamp >= startDate)
.ToListAsync(ct);
var summary = new QRCodeAnalyticsSummary(
TotalScans: events.Count,
UniqueVisitors: events.Select(e => e.IpHash).Distinct().Count()
);
var timeSeries = events
.GroupBy(e => e.Timestamp.Date)
.OrderBy(g => g.Key)
.Select(g => new QRCodeTimeSeriesPoint(
Date: g.Key.ToString("yyyy-MM-dd"),
Scans: g.Count()
))
.ToList();
var deviceBreakdown = events
.Where(e => !string.IsNullOrEmpty(e.DeviceType))
.GroupBy(e => e.DeviceType!)
.ToDictionary(g => g.Key, g => g.Count());
var referrerBreakdown = events
.Where(e => !string.IsNullOrEmpty(e.Referrer))
.GroupBy(e => GetReferrerDomain(e.Referrer!))
.OrderByDescending(g => g.Count())
.Take(10)
.ToDictionary(g => g.Key, g => g.Count());
var countryBreakdown = events
.Where(e => !string.IsNullOrEmpty(e.CountryCode))
.GroupBy(e => e.CountryCode!)
.OrderByDescending(g => g.Count())
.Take(10)
.ToDictionary(g => g.Key, g => g.Count());
var response = new QRCodeAnalyticsResponse(
QRCodeId: qrCode.Id,
Name: qrCode.Name,
LinkSlug: qrCode.ShortLink?.Slug,
Summary: summary,
TimeSeries: timeSeries,
DeviceBreakdown: deviceBreakdown,
ReferrerBreakdown: referrerBreakdown,
CountryBreakdown: countryBreakdown
);
await HttpContext.Response.SendAsync(response, cancellation: ct);
}
private static DateTime GetStartDate(string period)
{
return period switch
{
"24h" => DateTime.UtcNow.AddHours(-24),
"7d" => DateTime.UtcNow.AddDays(-7),
"30d" => DateTime.UtcNow.AddDays(-30),
"all" => DateTime.MinValue,
_ => DateTime.UtcNow.AddDays(-7)
};
}
private static string GetReferrerDomain(string referrer)
{
try
{
var uri = new Uri(referrer);
return uri.Host;
}
catch
{
return referrer;
}
}
}

View File

@@ -28,6 +28,7 @@ public class GetQRCodeEndpoint(AppDbContext db)
var qrCode = await db.QrCodeDesigns
.Include(q => q.ShortLink)
.Include(q => q.LogoAsset)
.Where(q => q.Id == req.Id && q.WorkspaceId == req.WorkspaceId && q.Workspace.OwnerUserId == userId)
.FirstOrDefaultAsync(ct);
@@ -38,6 +39,7 @@ public class GetQRCodeEndpoint(AppDbContext db)
}
var style = JsonSerializer.Deserialize<QRCodeStyle>(qrCode.StyleJson) ?? new QRCodeStyle();
var baseUrl = $"{HttpContext.Request.Scheme}://{HttpContext.Request.Host}";
var response = new QRCodeResponse(
qrCode.Id,
@@ -45,8 +47,10 @@ public class GetQRCodeEndpoint(AppDbContext db)
qrCode.ProjectId,
qrCode.ShortLinkId,
qrCode.ShortLink?.Slug,
qrCode.Name,
style,
qrCode.LogoAssetId,
qrCode.LogoAsset != null ? $"{baseUrl}/assets/{qrCode.LogoAsset.StorageKey}" : null,
qrCode.CreatedAt,
qrCode.UpdatedAt
);

View File

@@ -52,9 +52,12 @@ public class ListQRCodesEndpoint(AppDbContext db)
var qrCodes = await query
.Include(q => q.ShortLink)
.Include(q => q.LogoAsset)
.OrderByDescending(q => q.CreatedAt)
.ToListAsync(ct);
var baseUrl = $"{HttpContext.Request.Scheme}://{HttpContext.Request.Host}";
var response = new QRCodeListResponse(
qrCodes.Select(q => new QRCodeResponse(
q.Id,
@@ -62,8 +65,10 @@ public class ListQRCodesEndpoint(AppDbContext db)
q.ProjectId,
q.ShortLinkId,
q.ShortLink?.Slug,
q.Name,
JsonSerializer.Deserialize<QRCodeStyle>(q.StyleJson) ?? new QRCodeStyle(),
q.LogoAssetId,
q.LogoAsset != null ? $"{baseUrl}/assets/{q.LogoAsset.StorageKey}" : null,
q.CreatedAt,
q.UpdatedAt
))

View File

@@ -1,6 +1,7 @@
using System.Security.Claims;
using System.Text.Json;
using api.Data;
using api.Features.Assets.Services;
using api.Features.Auth.Common;
using api.Features.QRCodes.Common;
using api.Features.QRCodes.Services;
@@ -16,7 +17,7 @@ public class PreviewQRCodeRequest
public int? Size { get; set; }
}
public class PreviewQRCodeEndpoint(AppDbContext db, IQRCodeGeneratorService qrGenerator)
public class PreviewQRCodeEndpoint(AppDbContext db, IQrCodeGeneratorService qrGenerator, IAssetStorageService assetStorage)
: Endpoint<PreviewQRCodeRequest, QRCodePreviewResponse>
{
public override void Configure()
@@ -30,6 +31,7 @@ public class PreviewQRCodeEndpoint(AppDbContext db, IQRCodeGeneratorService qrGe
var qrCode = await db.QrCodeDesigns
.Include(q => q.ShortLink)
.Include(q => q.LogoAsset)
.Where(q => q.Id == req.Id && q.WorkspaceId == req.WorkspaceId && q.Workspace.OwnerUserId == userId)
.FirstOrDefaultAsync(ct);
@@ -53,15 +55,33 @@ public class PreviewQRCodeEndpoint(AppDbContext db, IQRCodeGeneratorService qrGe
var baseUrl = $"{HttpContext.Request.Scheme}://{HttpContext.Request.Host}";
var linkUrl = $"{baseUrl}/{qrCode.ShortLink.Slug}";
var dataUrl = qrGenerator.GenerateDataUrl(linkUrl, style, size);
// Load logo if available
Stream? logoStream = null;
if (qrCode.LogoAsset != null)
{
var logoResult = await assetStorage.GetAsync(qrCode.LogoAsset.StorageKey);
if (logoResult.HasValue)
{
logoStream = logoResult.Value.Stream;
}
}
var response = new QRCodePreviewResponse(
DataUrl: dataUrl,
Format: "png",
Width: size,
Height: size
);
try
{
var dataUrl = qrGenerator.GenerateDataUrl(linkUrl, style, size, logoStream);
await HttpContext.Response.SendAsync(response, 200, cancellation: ct);
var response = new QRCodePreviewResponse(
DataUrl: dataUrl,
Format: "png",
Width: size,
Height: size
);
await HttpContext.Response.SendAsync(response, 200, cancellation: ct);
}
finally
{
logoStream?.Dispose();
}
}
}

View File

@@ -12,8 +12,11 @@ public class UpdateQRCodeRequest
{
public Guid WorkspaceId { get; set; }
public Guid Id { get; set; }
public string? Name { get; set; }
public Guid? ProjectId { get; set; }
public bool? RemoveProject { get; set; }
public Guid? LogoAssetId { get; set; }
public bool? RemoveLogo { get; set; }
public QRCodeStyle? Style { get; set; }
}
@@ -32,6 +35,7 @@ public class UpdateQRCodeEndpoint(AppDbContext db)
var qrCode = await db.QrCodeDesigns
.Include(q => q.Workspace)
.Include(q => q.ShortLink)
.Include(q => q.LogoAsset)
.FirstOrDefaultAsync(q => q.Id == req.Id && q.WorkspaceId == req.WorkspaceId && q.Workspace.OwnerUserId == userId, ct);
if (qrCode is null)
@@ -58,6 +62,33 @@ public class UpdateQRCodeEndpoint(AppDbContext db)
qrCode.ProjectId = null;
}
// Update name if provided
if (!string.IsNullOrWhiteSpace(req.Name))
{
qrCode.Name = req.Name;
}
// Handle logo asset update
if (req.LogoAssetId.HasValue)
{
var assetExists = await db.Assets
.AnyAsync(a => a.Id == req.LogoAssetId.Value && a.WorkspaceId == req.WorkspaceId, ct);
if (!assetExists)
{
await HttpContext.Response.SendAsync(new MessageResponse("Logo asset not found"), 404, cancellation: ct);
return;
}
qrCode.LogoAssetId = req.LogoAssetId.Value;
// Reload the asset for the response
qrCode.LogoAsset = await db.Assets.FindAsync([req.LogoAssetId.Value], ct);
}
else if (req.RemoveLogo == true)
{
qrCode.LogoAssetId = null;
qrCode.LogoAsset = null;
}
if (req.Style != null)
{
qrCode.StyleJson = JsonSerializer.Serialize(req.Style);
@@ -67,6 +98,7 @@ public class UpdateQRCodeEndpoint(AppDbContext db)
await db.SaveChangesAsync(ct);
var style = JsonSerializer.Deserialize<QRCodeStyle>(qrCode.StyleJson) ?? new QRCodeStyle();
var baseUrl = $"{HttpContext.Request.Scheme}://{HttpContext.Request.Host}";
var response = new QRCodeResponse(
qrCode.Id,
@@ -74,8 +106,10 @@ public class UpdateQRCodeEndpoint(AppDbContext db)
qrCode.ProjectId,
qrCode.ShortLinkId,
qrCode.ShortLink?.Slug,
qrCode.Name,
style,
qrCode.LogoAssetId,
qrCode.LogoAsset != null ? $"{baseUrl}/assets/{qrCode.LogoAsset.StorageKey}" : null,
qrCode.CreatedAt,
qrCode.UpdatedAt
);

View File

@@ -1,34 +1,129 @@
using System.Drawing;
using api.Features.QRCodes.Common;
using QRCoder;
using SkiaSharp;
namespace api.Features.QRCodes.Services;
public interface IQRCodeGeneratorService
public interface IQrCodeGeneratorService
{
byte[] GeneratePng(string content, QRCodeStyle style, int size = 512);
byte[] GeneratePng(string content, QRCodeStyle style, int size = 512, Stream? logoStream = null);
string GenerateSvg(string content, QRCodeStyle style, int size = 512);
string GenerateDataUrl(string content, QRCodeStyle style, int size = 256);
string GenerateDataUrl(string content, QRCodeStyle style, int size = 256, Stream? logoStream = null);
}
public class QRCodeGeneratorService : IQRCodeGeneratorService
public class QrCodeGeneratorService : IQrCodeGeneratorService
{
public byte[] GeneratePng(string content, QRCodeStyle style, int size = 512)
public byte[] GeneratePng(string content, QRCodeStyle style, int size = 512, Stream? logoStream = null)
{
using var qrGenerator = new QRCodeGenerator();
var eccLevel = ParseEccLevel(style.ErrorCorrectionLevel);
using var qrCodeData = qrGenerator.CreateQrCode(content, eccLevel);
using var qrCode = new PngByteQRCode(qrCodeData);
var moduleMatrix = qrCodeData.ModuleMatrix;
var moduleCount = moduleMatrix.Count;
var foreground = ParseColor(style.ForegroundColor);
var background = ParseColor(style.BackgroundColor);
// Calculate pixels per module based on desired size (accounting for quiet zone)
var totalModules = moduleCount + (style.QuietZone * 2);
var pixelsPerModule = Math.Max(4, size / totalModules);
var actualSize = totalModules * pixelsPerModule;
// Calculate pixels per module based on desired size
var moduleCount = qrCodeData.ModuleMatrix.Count;
var pixelsPerModule = Math.Max(1, size / moduleCount);
// Create bitmap with SkiaSharp for custom shapes
var foregroundColor = ParseSkColor(style.ForegroundColor);
var backgroundColor = ParseSkColor(style.BackgroundColor);
return qrCode.GetGraphic(pixelsPerModule, foreground, background, drawQuietZones: style.QuietZone > 0);
using var surface = SKSurface.Create(new SKImageInfo(actualSize, actualSize));
var canvas = surface.Canvas;
// Draw background
canvas.Clear(backgroundColor);
// Draw QR modules with custom shapes
var modulePaint = new SKPaint
{
Color = foregroundColor,
IsAntialias = true,
Style = SKPaintStyle.Fill
};
var quietZoneOffset = style.QuietZone * pixelsPerModule;
for (int y = 0; y < moduleCount; y++)
{
for (int x = 0; x < moduleCount; x++)
{
if (moduleMatrix[y][x])
{
var px = quietZoneOffset + (x * pixelsPerModule);
var py = quietZoneOffset + (y * pixelsPerModule);
// Check if this is part of a finder pattern (eyes)
var isEye = IsFinderPattern(x, y, moduleCount);
if (isEye)
{
DrawModule(canvas, px, py, pixelsPerModule, modulePaint, style.EyeShape);
}
else
{
DrawModule(canvas, px, py, pixelsPerModule, modulePaint, style.ModuleShape);
}
}
}
}
// Encode to PNG
using var image = surface.Snapshot();
using var data = image.Encode(SKEncodedImageFormat.Png, 100);
var qrBytes = data.ToArray();
// If no logo, return the QR code as-is
if (logoStream == null)
{
return qrBytes;
}
// Overlay logo on QR code
return OverlayLogo(qrBytes, logoStream, actualSize);
}
private static bool IsFinderPattern(int x, int y, int moduleCount)
{
// Top-left finder pattern: 0-6, 0-6
if (x <= 6 && y <= 6) return true;
// Top-right finder pattern: moduleCount-7 to moduleCount-1, 0-6
if (x >= moduleCount - 7 && y <= 6) return true;
// Bottom-left finder pattern: 0-6, moduleCount-7 to moduleCount-1
if (x <= 6 && y >= moduleCount - 7) return true;
return false;
}
private static void DrawModule(SKCanvas canvas, float x, float y, float size, SKPaint paint, string shape)
{
var padding = size * 0.1f; // 10% padding between modules
var moduleSize = size - padding;
switch (shape.ToLowerInvariant())
{
case "circle":
case "dots":
var radius = moduleSize / 2;
canvas.DrawCircle(x + size / 2, y + size / 2, radius, paint);
break;
case "rounded":
var cornerRadius = moduleSize * 0.3f;
var rect = new SKRoundRect(
new SKRect(x + padding / 2, y + padding / 2, x + size - padding / 2, y + size - padding / 2),
cornerRadius
);
canvas.DrawRoundRect(rect, paint);
break;
case "square":
default:
canvas.DrawRect(x + padding / 2, y + padding / 2, moduleSize, moduleSize, paint);
break;
}
}
public string GenerateSvg(string content, QRCodeStyle style, int size = 512)
@@ -37,30 +132,120 @@ public class QRCodeGeneratorService : IQRCodeGeneratorService
var eccLevel = ParseEccLevel(style.ErrorCorrectionLevel);
using var qrCodeData = qrGenerator.CreateQrCode(content, eccLevel);
using var qrCode = new SvgQRCode(qrCodeData);
var moduleMatrix = qrCodeData.ModuleMatrix;
var moduleCount = moduleMatrix.Count;
// Calculate pixels per module based on desired size (accounting for quiet zone)
var totalModules = moduleCount + (style.QuietZone * 2);
var pixelsPerModule = (float)size / totalModules;
var actualSize = size;
var foreground = style.ForegroundColor;
var background = style.BackgroundColor;
// Calculate pixels per module
var moduleCount = qrCodeData.ModuleMatrix.Count;
var pixelsPerModule = Math.Max(1, size / moduleCount);
var svg = new System.Text.StringBuilder();
svg.AppendLine($"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 {actualSize} {actualSize}\" width=\"{actualSize}\" height=\"{actualSize}\">");
svg.AppendLine($" <rect width=\"100%\" height=\"100%\" fill=\"{background}\"/>");
return qrCode.GetGraphic(
pixelsPerModule,
foreground,
background,
drawQuietZones: style.QuietZone > 0
);
var quietZoneOffset = style.QuietZone * pixelsPerModule;
for (int y = 0; y < moduleCount; y++)
{
for (int x = 0; x < moduleCount; x++)
{
if (moduleMatrix[y][x])
{
var px = quietZoneOffset + (x * pixelsPerModule);
var py = quietZoneOffset + (y * pixelsPerModule);
var isEye = IsFinderPattern(x, y, moduleCount);
var shape = isEye ? style.EyeShape : style.ModuleShape;
var padding = pixelsPerModule * 0.1f;
var moduleSize = pixelsPerModule - padding;
switch (shape.ToLowerInvariant())
{
case "circle":
case "dots":
var radius = moduleSize / 2;
var cx = px + pixelsPerModule / 2;
var cy = py + pixelsPerModule / 2;
svg.AppendLine($" <circle cx=\"{cx:F2}\" cy=\"{cy:F2}\" r=\"{radius:F2}\" fill=\"{foreground}\"/>");
break;
case "rounded":
var cornerRadius = moduleSize * 0.3f;
svg.AppendLine($" <rect x=\"{px + padding / 2:F2}\" y=\"{py + padding / 2:F2}\" width=\"{moduleSize:F2}\" height=\"{moduleSize:F2}\" rx=\"{cornerRadius:F2}\" fill=\"{foreground}\"/>");
break;
case "square":
default:
svg.AppendLine($" <rect x=\"{px + padding / 2:F2}\" y=\"{py + padding / 2:F2}\" width=\"{moduleSize:F2}\" height=\"{moduleSize:F2}\" fill=\"{foreground}\"/>");
break;
}
}
}
}
svg.AppendLine("</svg>");
return svg.ToString();
}
public string GenerateDataUrl(string content, QRCodeStyle style, int size = 256)
public string GenerateDataUrl(string content, QRCodeStyle style, int size = 256, Stream? logoStream = null)
{
var pngBytes = GeneratePng(content, style, size);
var pngBytes = GeneratePng(content, style, size, logoStream);
var base64 = Convert.ToBase64String(pngBytes);
return $"data:image/png;base64,{base64}";
}
private static byte[] OverlayLogo(byte[] qrBytes, Stream logoStream, int qrSize)
{
using var qrBitmap = SKBitmap.Decode(qrBytes);
using var logoBitmap = SKBitmap.Decode(logoStream);
if (qrBitmap == null || logoBitmap == null)
{
return qrBytes;
}
// Logo should be about 20% of QR code size
var logoSize = (int)(qrSize * 0.2);
var logoX = (qrBitmap.Width - logoSize) / 2;
var logoY = (qrBitmap.Height - logoSize) / 2;
// Create a new surface to draw on
using var surface = SKSurface.Create(new SKImageInfo(qrBitmap.Width, qrBitmap.Height));
var canvas = surface.Canvas;
// Draw QR code
canvas.DrawBitmap(qrBitmap, 0, 0);
// Draw white background circle for logo
var circlePaint = new SKPaint
{
Color = SKColors.White,
IsAntialias = true,
Style = SKPaintStyle.Fill
};
var circleRadius = logoSize * 0.6f;
canvas.DrawCircle(qrBitmap.Width / 2f, qrBitmap.Height / 2f, circleRadius, circlePaint);
// Resize and draw logo
using var resizedLogo = logoBitmap.Resize(
new SKImageInfo(logoSize, logoSize),
new SKSamplingOptions(SKCubicResampler.Mitchell));
if (resizedLogo != null)
{
canvas.DrawBitmap(resizedLogo, logoX, logoY);
}
// Encode to PNG
using var image = surface.Snapshot();
using var data = image.Encode(SKEncodedImageFormat.Png, 100);
return data.ToArray();
}
private static QRCodeGenerator.ECCLevel ParseEccLevel(string level)
{
return level.ToUpperInvariant() switch
@@ -73,22 +258,20 @@ public class QRCodeGeneratorService : IQRCodeGeneratorService
};
}
private static byte[] ParseColor(string hexColor)
private static SKColor ParseSkColor(string hexColor)
{
// Remove # if present
var hex = hexColor.TrimStart('#');
if (hex.Length == 6)
{
return
[
Convert.ToByte(hex[..2], 16),
Convert.ToByte(hex[2..4], 16),
Convert.ToByte(hex[4..6], 16)
];
var r = Convert.ToByte(hex[..2], 16);
var g = Convert.ToByte(hex[2..4], 16);
var b = Convert.ToByte(hex[4..6], 16);
return new SKColor(r, g, b);
}
// Default to black
return [0, 0, 0];
return SKColors.Black;
}
}

View File

@@ -10,6 +10,7 @@ namespace api.Features.Redirect.Endpoints;
public class RedirectRequest
{
public string Slug { get; set; } = string.Empty;
public Guid? Qr { get; set; }
}
public class RedirectResponse
@@ -24,6 +25,7 @@ public class RedirectEndpoint(AppDbContext db, IEventTrackingService eventTracki
{
Get("/{Slug}");
AllowAnonymous();
Options(x => x.RequireRateLimiting("redirect"));
}
public override async Task HandleAsync(RedirectRequest req, CancellationToken ct)
@@ -73,8 +75,16 @@ public class RedirectEndpoint(AppDbContext db, IEventTrackingService eventTracki
return;
}
// Track click event asynchronously (fire and forget)
await eventTracking.TrackClickAsync(link.WorkspaceId, link.Id, HttpContext);
// Track event asynchronously (fire and forget)
// If qr parameter is present, track as scan; otherwise track as click
if (req.Qr.HasValue)
{
await eventTracking.TrackScanAsync(link.WorkspaceId, link.Id, req.Qr.Value, HttpContext);
}
else
{
await eventTracking.TrackClickAsync(link.WorkspaceId, link.Id, HttpContext);
}
// Redirect to destination (302 Found)
HttpContext.Response.StatusCode = StatusCodes.Status302Found;

View File

@@ -1,5 +1,7 @@
using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using api.Features.Plans.Services;
using api.Features.Workspaces.Common;
using api.Models;
using FastEndpoints;
@@ -22,7 +24,7 @@ public class CreateWorkspaceValidator : Validator<CreateWorkspaceRequest>
}
}
public class CreateWorkspaceEndpoint(AppDbContext db)
public class CreateWorkspaceEndpoint(AppDbContext db, IPlanLimitsService planLimits)
: Endpoint<CreateWorkspaceRequest, WorkspaceResponse>
{
public override void Configure()
@@ -34,6 +36,16 @@ public class CreateWorkspaceEndpoint(AppDbContext db)
{
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
// Check plan limits
if (!await planLimits.CanCreateWorkspaceAsync(userId, ct))
{
await HttpContext.Response.SendAsync(
new MessageResponse("Workspace limit reached. Please upgrade your plan to create more workspaces."),
402,
cancellation: ct);
return;
}
var workspace = new Workspace
{
Id = Guid.NewGuid(),

View File

@@ -0,0 +1,55 @@
using System.Net;
using System.Text.Json;
namespace api.Middleware;
public class GlobalExceptionMiddleware(RequestDelegate next, ILogger<GlobalExceptionMiddleware> logger)
{
public async Task InvokeAsync(HttpContext context)
{
try
{
await next(context);
}
catch (Exception ex)
{
await HandleExceptionAsync(context, ex);
}
}
private async Task HandleExceptionAsync(HttpContext context, Exception exception)
{
var (statusCode, message) = exception switch
{
UnauthorizedAccessException => (HttpStatusCode.Unauthorized, "Unauthorized access"),
KeyNotFoundException => (HttpStatusCode.NotFound, "Resource not found"),
ArgumentException => (HttpStatusCode.BadRequest, exception.Message),
InvalidOperationException => (HttpStatusCode.BadRequest, exception.Message),
_ => (HttpStatusCode.InternalServerError, "An unexpected error occurred")
};
// Log the exception
if (statusCode == HttpStatusCode.InternalServerError)
{
logger.LogError(exception, "Unhandled exception: {Message}", exception.Message);
}
else
{
logger.LogWarning("Request error: {StatusCode} - {Message}", (int)statusCode, message);
}
context.Response.ContentType = "application/json";
context.Response.StatusCode = (int)statusCode;
var response = new ErrorResponse(
StatusCode: (int)statusCode,
Message: message,
TraceId: context.TraceIdentifier
);
var options = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
await context.Response.WriteAsync(JsonSerializer.Serialize(response, options));
}
}
public record ErrorResponse(int StatusCode, string Message, string TraceId);

View File

@@ -0,0 +1,708 @@
// <auto-generated />
using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using api.Data;
#nullable disable
namespace api.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260130185641_AddQRCodeNameAndLogo")]
partial class AddQRCodeNameAndLogo
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.0")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("api.Models.ApiKey", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<DateTime?>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<string>("KeyHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("KeyPrefix")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<DateTime?>("LastUsedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.PrimitiveCollection<List<string>>("Scopes")
.HasColumnType("text[]");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("KeyHash")
.IsUnique();
b.HasIndex("WorkspaceId");
b.ToTable("ApiKeys");
});
modelBuilder.Entity("api.Models.Asset", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("Mime")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<long>("Size")
.HasColumnType("bigint");
b.Property<string>("StorageKey")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("WorkspaceId");
b.ToTable("Assets");
});
modelBuilder.Entity("api.Models.Domain", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("Hostname")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("VerificationToken")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("Hostname")
.IsUnique();
b.HasIndex("WorkspaceId");
b.ToTable("Domains");
});
modelBuilder.Entity("api.Models.EmailVerificationToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Token")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("Token")
.IsUnique();
b.HasIndex("UserId");
b.ToTable("EmailVerificationTokens");
});
modelBuilder.Entity("api.Models.Event", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("CountryCode")
.HasMaxLength(2)
.HasColumnType("character varying(2)");
b.Property<string>("DedupeKey")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("DeviceType")
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("IpHash")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid?>("QRCodeId")
.HasColumnType("uuid");
b.Property<string>("Referrer")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<Guid>("ShortLinkId")
.HasColumnType("uuid");
b.Property<DateTime>("Timestamp")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("character varying(10)");
b.Property<string>("UserAgent")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("QRCodeId");
b.HasIndex("Timestamp");
b.HasIndex("ShortLinkId", "Timestamp");
b.HasIndex("WorkspaceId", "Timestamp");
b.ToTable("Events");
});
modelBuilder.Entity("api.Models.PasswordResetToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Token")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<bool>("Used")
.HasColumnType("boolean");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("Token")
.IsUnique();
b.HasIndex("UserId");
b.ToTable("PasswordResetTokens");
});
modelBuilder.Entity("api.Models.Project", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("WorkspaceId");
b.ToTable("Projects");
});
modelBuilder.Entity("api.Models.QRCodeDesign", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid?>("LogoAssetId")
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<Guid?>("ProjectId")
.HasColumnType("uuid");
b.Property<Guid?>("ShortLinkId")
.HasColumnType("uuid");
b.Property<string>("StyleJson")
.IsRequired()
.HasColumnType("jsonb");
b.Property<DateTime>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("LogoAssetId");
b.HasIndex("ProjectId");
b.HasIndex("ShortLinkId");
b.HasIndex("WorkspaceId");
b.ToTable("QrCodeDesigns");
});
modelBuilder.Entity("api.Models.ShortLink", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DestinationUrl")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<Guid?>("DomainId")
.HasColumnType("uuid");
b.Property<DateTime?>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("PasswordHash")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<Guid?>("ProjectId")
.HasColumnType("uuid");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("Title")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<DateTime>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ProjectId");
b.HasIndex("WorkspaceId");
b.HasIndex("DomainId", "Slug")
.IsUnique();
b.ToTable("ShortLinks");
});
modelBuilder.Entity("api.Models.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("PasswordHash")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("StripeCustomerId")
.HasColumnType("text");
b.Property<DateTime?>("VerifiedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("Email")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("api.Models.Workspace", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<Guid>("OwnerUserId")
.HasColumnType("uuid");
b.Property<string>("Plan")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("StripeSubscriptionId")
.HasColumnType("text");
b.Property<DateTime?>("SubscriptionEndsAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("OwnerUserId");
b.ToTable("Workspaces");
});
modelBuilder.Entity("api.Models.ApiKey", b =>
{
b.HasOne("api.Models.Workspace", "Workspace")
.WithMany()
.HasForeignKey("WorkspaceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Workspace");
});
modelBuilder.Entity("api.Models.Asset", b =>
{
b.HasOne("api.Models.Workspace", "Workspace")
.WithMany("Assets")
.HasForeignKey("WorkspaceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Workspace");
});
modelBuilder.Entity("api.Models.Domain", b =>
{
b.HasOne("api.Models.Workspace", "Workspace")
.WithMany("Domains")
.HasForeignKey("WorkspaceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Workspace");
});
modelBuilder.Entity("api.Models.EmailVerificationToken", b =>
{
b.HasOne("api.Models.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("api.Models.Event", b =>
{
b.HasOne("api.Models.QRCodeDesign", "QRCode")
.WithMany("Events")
.HasForeignKey("QRCodeId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("api.Models.ShortLink", "ShortLink")
.WithMany("Events")
.HasForeignKey("ShortLinkId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("api.Models.Workspace", "Workspace")
.WithMany("Events")
.HasForeignKey("WorkspaceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("QRCode");
b.Navigation("ShortLink");
b.Navigation("Workspace");
});
modelBuilder.Entity("api.Models.PasswordResetToken", b =>
{
b.HasOne("api.Models.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("api.Models.Project", b =>
{
b.HasOne("api.Models.Workspace", "Workspace")
.WithMany("Projects")
.HasForeignKey("WorkspaceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Workspace");
});
modelBuilder.Entity("api.Models.QRCodeDesign", b =>
{
b.HasOne("api.Models.Asset", "LogoAsset")
.WithMany()
.HasForeignKey("LogoAssetId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("api.Models.Project", "Project")
.WithMany("QRCodeDesigns")
.HasForeignKey("ProjectId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("api.Models.ShortLink", "ShortLink")
.WithMany("QRCodeDesigns")
.HasForeignKey("ShortLinkId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("api.Models.Workspace", "Workspace")
.WithMany("QRCodeDesigns")
.HasForeignKey("WorkspaceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("LogoAsset");
b.Navigation("Project");
b.Navigation("ShortLink");
b.Navigation("Workspace");
});
modelBuilder.Entity("api.Models.ShortLink", b =>
{
b.HasOne("api.Models.Domain", "Domain")
.WithMany("ShortLinks")
.HasForeignKey("DomainId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("api.Models.Project", "Project")
.WithMany("ShortLinks")
.HasForeignKey("ProjectId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("api.Models.Workspace", "Workspace")
.WithMany("ShortLinks")
.HasForeignKey("WorkspaceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Domain");
b.Navigation("Project");
b.Navigation("Workspace");
});
modelBuilder.Entity("api.Models.Workspace", b =>
{
b.HasOne("api.Models.User", "Owner")
.WithMany("Workspaces")
.HasForeignKey("OwnerUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Owner");
});
modelBuilder.Entity("api.Models.Domain", b =>
{
b.Navigation("ShortLinks");
});
modelBuilder.Entity("api.Models.Project", b =>
{
b.Navigation("QRCodeDesigns");
b.Navigation("ShortLinks");
});
modelBuilder.Entity("api.Models.QRCodeDesign", b =>
{
b.Navigation("Events");
});
modelBuilder.Entity("api.Models.ShortLink", b =>
{
b.Navigation("Events");
b.Navigation("QRCodeDesigns");
});
modelBuilder.Entity("api.Models.User", b =>
{
b.Navigation("Workspaces");
});
modelBuilder.Entity("api.Models.Workspace", b =>
{
b.Navigation("Assets");
b.Navigation("Domains");
b.Navigation("Events");
b.Navigation("Projects");
b.Navigation("QRCodeDesigns");
b.Navigation("ShortLinks");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,182 @@
using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace api.Migrations
{
/// <inheritdoc />
public partial class AddQRCodeNameAndLogo : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "StripeSubscriptionId",
table: "Workspaces",
type: "text",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "SubscriptionEndsAt",
table: "Workspaces",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "StripeCustomerId",
table: "Users",
type: "text",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "DeletedAt",
table: "ShortLinks",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Name",
table: "QrCodeDesigns",
type: "text",
nullable: false,
defaultValue: "");
migrationBuilder.CreateTable(
name: "ApiKeys",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
WorkspaceId = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
KeyHash = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
KeyPrefix = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"),
LastUsedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
ExpiresAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
IsActive = table.Column<bool>(type: "boolean", nullable: false),
Scopes = table.Column<List<string>>(type: "text[]", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_ApiKeys", x => x.Id);
table.ForeignKey(
name: "FK_ApiKeys_Workspaces_WorkspaceId",
column: x => x.WorkspaceId,
principalTable: "Workspaces",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "EmailVerificationTokens",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
Token = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
ExpiresAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
},
constraints: table =>
{
table.PrimaryKey("PK_EmailVerificationTokens", x => x.Id);
table.ForeignKey(
name: "FK_EmailVerificationTokens_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "PasswordResetTokens",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
Token = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
ExpiresAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
Used = table.Column<bool>(type: "boolean", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
},
constraints: table =>
{
table.PrimaryKey("PK_PasswordResetTokens", x => x.Id);
table.ForeignKey(
name: "FK_PasswordResetTokens_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_ApiKeys_KeyHash",
table: "ApiKeys",
column: "KeyHash",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_ApiKeys_WorkspaceId",
table: "ApiKeys",
column: "WorkspaceId");
migrationBuilder.CreateIndex(
name: "IX_EmailVerificationTokens_Token",
table: "EmailVerificationTokens",
column: "Token",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_EmailVerificationTokens_UserId",
table: "EmailVerificationTokens",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_PasswordResetTokens_Token",
table: "PasswordResetTokens",
column: "Token",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_PasswordResetTokens_UserId",
table: "PasswordResetTokens",
column: "UserId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ApiKeys");
migrationBuilder.DropTable(
name: "EmailVerificationTokens");
migrationBuilder.DropTable(
name: "PasswordResetTokens");
migrationBuilder.DropColumn(
name: "StripeSubscriptionId",
table: "Workspaces");
migrationBuilder.DropColumn(
name: "SubscriptionEndsAt",
table: "Workspaces");
migrationBuilder.DropColumn(
name: "StripeCustomerId",
table: "Users");
migrationBuilder.DropColumn(
name: "DeletedAt",
table: "ShortLinks");
migrationBuilder.DropColumn(
name: "Name",
table: "QrCodeDesigns");
}
}
}

View File

@@ -0,0 +1,711 @@
// <auto-generated />
using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using api.Data;
#nullable disable
namespace api.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260130193730_AddProjectDescription")]
partial class AddProjectDescription
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.0")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("api.Models.ApiKey", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<DateTime?>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<string>("KeyHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("KeyPrefix")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<DateTime?>("LastUsedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.PrimitiveCollection<List<string>>("Scopes")
.HasColumnType("text[]");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("KeyHash")
.IsUnique();
b.HasIndex("WorkspaceId");
b.ToTable("ApiKeys");
});
modelBuilder.Entity("api.Models.Asset", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("Mime")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<long>("Size")
.HasColumnType("bigint");
b.Property<string>("StorageKey")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("WorkspaceId");
b.ToTable("Assets");
});
modelBuilder.Entity("api.Models.Domain", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("Hostname")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("VerificationToken")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("Hostname")
.IsUnique();
b.HasIndex("WorkspaceId");
b.ToTable("Domains");
});
modelBuilder.Entity("api.Models.EmailVerificationToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Token")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("Token")
.IsUnique();
b.HasIndex("UserId");
b.ToTable("EmailVerificationTokens");
});
modelBuilder.Entity("api.Models.Event", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("CountryCode")
.HasMaxLength(2)
.HasColumnType("character varying(2)");
b.Property<string>("DedupeKey")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("DeviceType")
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("IpHash")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid?>("QRCodeId")
.HasColumnType("uuid");
b.Property<string>("Referrer")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<Guid>("ShortLinkId")
.HasColumnType("uuid");
b.Property<DateTime>("Timestamp")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("character varying(10)");
b.Property<string>("UserAgent")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("QRCodeId");
b.HasIndex("Timestamp");
b.HasIndex("ShortLinkId", "Timestamp");
b.HasIndex("WorkspaceId", "Timestamp");
b.ToTable("Events");
});
modelBuilder.Entity("api.Models.PasswordResetToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Token")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<bool>("Used")
.HasColumnType("boolean");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("Token")
.IsUnique();
b.HasIndex("UserId");
b.ToTable("PasswordResetTokens");
});
modelBuilder.Entity("api.Models.Project", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("WorkspaceId");
b.ToTable("Projects");
});
modelBuilder.Entity("api.Models.QRCodeDesign", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid?>("LogoAssetId")
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<Guid?>("ProjectId")
.HasColumnType("uuid");
b.Property<Guid?>("ShortLinkId")
.HasColumnType("uuid");
b.Property<string>("StyleJson")
.IsRequired()
.HasColumnType("jsonb");
b.Property<DateTime>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("LogoAssetId");
b.HasIndex("ProjectId");
b.HasIndex("ShortLinkId");
b.HasIndex("WorkspaceId");
b.ToTable("QrCodeDesigns");
});
modelBuilder.Entity("api.Models.ShortLink", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DestinationUrl")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<Guid?>("DomainId")
.HasColumnType("uuid");
b.Property<DateTime?>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("PasswordHash")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<Guid?>("ProjectId")
.HasColumnType("uuid");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("Title")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<DateTime>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ProjectId");
b.HasIndex("WorkspaceId");
b.HasIndex("DomainId", "Slug")
.IsUnique();
b.ToTable("ShortLinks");
});
modelBuilder.Entity("api.Models.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("PasswordHash")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("StripeCustomerId")
.HasColumnType("text");
b.Property<DateTime?>("VerifiedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("Email")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("api.Models.Workspace", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<Guid>("OwnerUserId")
.HasColumnType("uuid");
b.Property<string>("Plan")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("StripeSubscriptionId")
.HasColumnType("text");
b.Property<DateTime?>("SubscriptionEndsAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("OwnerUserId");
b.ToTable("Workspaces");
});
modelBuilder.Entity("api.Models.ApiKey", b =>
{
b.HasOne("api.Models.Workspace", "Workspace")
.WithMany()
.HasForeignKey("WorkspaceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Workspace");
});
modelBuilder.Entity("api.Models.Asset", b =>
{
b.HasOne("api.Models.Workspace", "Workspace")
.WithMany("Assets")
.HasForeignKey("WorkspaceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Workspace");
});
modelBuilder.Entity("api.Models.Domain", b =>
{
b.HasOne("api.Models.Workspace", "Workspace")
.WithMany("Domains")
.HasForeignKey("WorkspaceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Workspace");
});
modelBuilder.Entity("api.Models.EmailVerificationToken", b =>
{
b.HasOne("api.Models.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("api.Models.Event", b =>
{
b.HasOne("api.Models.QRCodeDesign", "QRCode")
.WithMany("Events")
.HasForeignKey("QRCodeId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("api.Models.ShortLink", "ShortLink")
.WithMany("Events")
.HasForeignKey("ShortLinkId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("api.Models.Workspace", "Workspace")
.WithMany("Events")
.HasForeignKey("WorkspaceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("QRCode");
b.Navigation("ShortLink");
b.Navigation("Workspace");
});
modelBuilder.Entity("api.Models.PasswordResetToken", b =>
{
b.HasOne("api.Models.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("api.Models.Project", b =>
{
b.HasOne("api.Models.Workspace", "Workspace")
.WithMany("Projects")
.HasForeignKey("WorkspaceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Workspace");
});
modelBuilder.Entity("api.Models.QRCodeDesign", b =>
{
b.HasOne("api.Models.Asset", "LogoAsset")
.WithMany()
.HasForeignKey("LogoAssetId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("api.Models.Project", "Project")
.WithMany("QRCodeDesigns")
.HasForeignKey("ProjectId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("api.Models.ShortLink", "ShortLink")
.WithMany("QRCodeDesigns")
.HasForeignKey("ShortLinkId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("api.Models.Workspace", "Workspace")
.WithMany("QRCodeDesigns")
.HasForeignKey("WorkspaceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("LogoAsset");
b.Navigation("Project");
b.Navigation("ShortLink");
b.Navigation("Workspace");
});
modelBuilder.Entity("api.Models.ShortLink", b =>
{
b.HasOne("api.Models.Domain", "Domain")
.WithMany("ShortLinks")
.HasForeignKey("DomainId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("api.Models.Project", "Project")
.WithMany("ShortLinks")
.HasForeignKey("ProjectId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("api.Models.Workspace", "Workspace")
.WithMany("ShortLinks")
.HasForeignKey("WorkspaceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Domain");
b.Navigation("Project");
b.Navigation("Workspace");
});
modelBuilder.Entity("api.Models.Workspace", b =>
{
b.HasOne("api.Models.User", "Owner")
.WithMany("Workspaces")
.HasForeignKey("OwnerUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Owner");
});
modelBuilder.Entity("api.Models.Domain", b =>
{
b.Navigation("ShortLinks");
});
modelBuilder.Entity("api.Models.Project", b =>
{
b.Navigation("QRCodeDesigns");
b.Navigation("ShortLinks");
});
modelBuilder.Entity("api.Models.QRCodeDesign", b =>
{
b.Navigation("Events");
});
modelBuilder.Entity("api.Models.ShortLink", b =>
{
b.Navigation("Events");
b.Navigation("QRCodeDesigns");
});
modelBuilder.Entity("api.Models.User", b =>
{
b.Navigation("Workspaces");
});
modelBuilder.Entity("api.Models.Workspace", b =>
{
b.Navigation("Assets");
b.Navigation("Domains");
b.Navigation("Events");
b.Navigation("Projects");
b.Navigation("QRCodeDesigns");
b.Navigation("ShortLinks");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace api.Migrations
{
/// <inheritdoc />
public partial class AddProjectDescription : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Description",
table: "Projects",
type: "text",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Description",
table: "Projects");
}
}
}

View File

@@ -1,5 +1,6 @@
// <auto-generated />
using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
@@ -22,6 +23,57 @@ namespace api.Migrations
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("api.Models.ApiKey", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<DateTime?>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<string>("KeyHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("KeyPrefix")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<DateTime?>("LastUsedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.PrimitiveCollection<List<string>>("Scopes")
.HasColumnType("text[]");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("KeyHash")
.IsUnique();
b.HasIndex("WorkspaceId");
b.ToTable("ApiKeys");
});
modelBuilder.Entity("api.Models.Asset", b =>
{
b.Property<Guid>("Id")
@@ -100,6 +152,38 @@ namespace api.Migrations
b.ToTable("Domains");
});
modelBuilder.Entity("api.Models.EmailVerificationToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Token")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("Token")
.IsUnique();
b.HasIndex("UserId");
b.ToTable("EmailVerificationTokens");
});
modelBuilder.Entity("api.Models.Event", b =>
{
b.Property<long>("Id")
@@ -164,6 +248,41 @@ namespace api.Migrations
b.ToTable("Events");
});
modelBuilder.Entity("api.Models.PasswordResetToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Token")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<bool>("Used")
.HasColumnType("boolean");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("Token")
.IsUnique();
b.HasIndex("UserId");
b.ToTable("PasswordResetTokens");
});
modelBuilder.Entity("api.Models.Project", b =>
{
b.Property<Guid>("Id")
@@ -175,6 +294,9 @@ namespace api.Migrations
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
@@ -204,6 +326,10 @@ namespace api.Migrations
b.Property<Guid?>("LogoAssetId")
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<Guid?>("ProjectId")
.HasColumnType("uuid");
@@ -246,6 +372,9 @@ namespace api.Migrations
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DestinationUrl")
.IsRequired()
.HasMaxLength(2048)
@@ -319,6 +448,9 @@ namespace api.Migrations
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("StripeCustomerId")
.HasColumnType("text");
b.Property<DateTime?>("VerifiedAt")
.HasColumnType("timestamp with time zone");
@@ -354,6 +486,12 @@ namespace api.Migrations
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("StripeSubscriptionId")
.HasColumnType("text");
b.Property<DateTime?>("SubscriptionEndsAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("OwnerUserId");
@@ -361,6 +499,17 @@ namespace api.Migrations
b.ToTable("Workspaces");
});
modelBuilder.Entity("api.Models.ApiKey", b =>
{
b.HasOne("api.Models.Workspace", "Workspace")
.WithMany()
.HasForeignKey("WorkspaceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Workspace");
});
modelBuilder.Entity("api.Models.Asset", b =>
{
b.HasOne("api.Models.Workspace", "Workspace")
@@ -383,6 +532,17 @@ namespace api.Migrations
b.Navigation("Workspace");
});
modelBuilder.Entity("api.Models.EmailVerificationToken", b =>
{
b.HasOne("api.Models.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("api.Models.Event", b =>
{
b.HasOne("api.Models.QRCodeDesign", "QRCode")
@@ -409,6 +569,17 @@ namespace api.Migrations
b.Navigation("Workspace");
});
modelBuilder.Entity("api.Models.PasswordResetToken", b =>
{
b.HasOne("api.Models.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("api.Models.Project", b =>
{
b.HasOne("api.Models.Workspace", "Workspace")

17
src/api/Models/ApiKey.cs Normal file
View File

@@ -0,0 +1,17 @@
namespace api.Models;
public class ApiKey
{
public Guid Id { get; set; }
public Guid WorkspaceId { get; set; }
public required string Name { get; set; }
public required string KeyHash { get; set; } // Only store hash, never the raw key
public required string KeyPrefix { get; set; } // First 8 chars for identification (e.g., "trk_abc1...")
public DateTime CreatedAt { get; set; }
public DateTime? LastUsedAt { get; set; }
public DateTime? ExpiresAt { get; set; }
public bool IsActive { get; set; } = true;
public List<string>? Scopes { get; set; } // e.g., ["links:read", "links:write", "qrcodes:read"]
public Workspace Workspace { get; set; } = null!;
}

View File

@@ -0,0 +1,13 @@
namespace api.Models;
public class EmailVerificationToken
{
public Guid Id { get; set; }
public Guid UserId { get; set; }
public required string Token { get; set; }
public DateTime ExpiresAt { get; set; }
public DateTime CreatedAt { get; set; }
// Navigation
public User User { get; set; } = null!;
}

View File

@@ -0,0 +1,14 @@
namespace api.Models;
public class PasswordResetToken
{
public Guid Id { get; set; }
public Guid UserId { get; set; }
public required string Token { get; set; }
public DateTime ExpiresAt { get; set; }
public bool Used { get; set; }
public DateTime CreatedAt { get; set; }
// Navigation
public User User { get; set; } = null!;
}

View File

@@ -5,6 +5,7 @@ public class Project
public Guid Id { get; set; }
public Guid WorkspaceId { get; set; }
public required string Name { get; set; }
public string? Description { get; set; }
public DateTime CreatedAt { get; set; }
// Navigation properties

View File

@@ -6,6 +6,7 @@ public class QRCodeDesign
public Guid WorkspaceId { get; set; }
public Guid? ProjectId { get; set; }
public Guid? ShortLinkId { get; set; }
public required string Name { get; set; }
public required string StyleJson { get; set; }
public Guid? LogoAssetId { get; set; }
public DateTime CreatedAt { get; set; }

View File

@@ -20,6 +20,7 @@ public class ShortLink
public string? PasswordHash { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
public DateTime? DeletedAt { get; set; } // Soft delete
// Navigation properties
public Workspace Workspace { get; set; } = null!;

View File

@@ -5,6 +5,7 @@ public class User
public Guid Id { get; set; }
public required string Email { get; set; }
public required string PasswordHash { get; set; }
public string? StripeCustomerId { get; set; }
public DateTime? VerifiedAt { get; set; }
public DateTime CreatedAt { get; set; }

View File

@@ -13,6 +13,8 @@ public class Workspace
public Guid OwnerUserId { get; set; }
public required string Name { get; set; }
public WorkspacePlan Plan { get; set; } = WorkspacePlan.Free;
public string? StripeSubscriptionId { get; set; }
public DateTime? SubscriptionEndsAt { get; set; }
public DateTime CreatedAt { get; set; }
// Navigation properties

View File

@@ -1,81 +1,217 @@
using System.Text;
using System.Threading.RateLimiting;
using api.Data;
using api.Features.Auth.Settings;
using api.Features.Events.Services;
using api.Features.Assets.Services;
using api.Features.Email.Services;
using api.Features.Billing.Services;
using api.Features.Billing.Settings;
using api.Features.Plans.Services;
using api.Features.QRCodes.Services;
using api.Middleware;
using FastEndpoints;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using Serilog;
var builder = WebApplication.CreateBuilder(args);
// Configure Serilog
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.MinimumLevel.Override("Microsoft", Serilog.Events.LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.EntityFrameworkCore", Serilog.Events.LogEventLevel.Warning)
.Enrich.FromLogContext()
.WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}")
.WriteTo.File("logs/api-.log", rollingInterval: RollingInterval.Day, retainedFileCountLimit: 7)
.CreateLogger();
// Add cors
if (builder.Environment.IsDevelopment())
try
{
Log.Information("Starting TrakQR API");
var builder = WebApplication.CreateBuilder(args);
// Use Serilog
builder.Host.UseSerilog();
// Configure CORS
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy =>
{
policy.SetIsOriginAllowed(origin => new Uri(origin).IsLoopback)
.AllowAnyHeader()
.AllowAnyMethod();
if (builder.Environment.IsDevelopment())
{
policy.SetIsOriginAllowed(origin => new Uri(origin).IsLoopback)
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials();
}
else
{
// Production: configure allowed origins from config
var allowedOrigins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>()
?? ["https://trakqr.com"];
policy.WithOrigins(allowedOrigins)
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials();
}
});
});
}
// Add services to the container.
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("PostgresConnection")));
// Register application services
builder.Services.AddSingleton<IEventTrackingService, EventTrackingService>();
builder.Services.AddSingleton<IQRCodeGeneratorService, QRCodeGeneratorService>();
builder.Services.AddSingleton<IAssetStorageService, LocalAssetStorageService>();
// Configure JWT settings
builder.Services.Configure<JwtSettings>(builder.Configuration.GetSection("Jwt"));
var jwtSettings = builder.Configuration.GetSection("Jwt").Get<JwtSettings>()!;
// Configure authentication
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
// Configure Rate Limiting (skip in Testing environment)
var isTestingEnvironment = builder.Environment.EnvironmentName == "Testing";
builder.Services.AddRateLimiter(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
// Use very high limits in testing environment
var authLimit = isTestingEnvironment ? 100000 : 10;
var globalLimit = isTestingEnvironment ? 100000 : 100;
var redirectLimit = isTestingEnvironment ? 100000 : 1000;
var apiLimit = isTestingEnvironment ? 100000 : 200;
// Global rate limit for all endpoints
options.AddPolicy("global", context =>
RateLimitPartition.GetFixedWindowLimiter(
partitionKey: context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = globalLimit,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0
}));
// Strict rate limit for authentication endpoints
options.AddPolicy("auth", context =>
RateLimitPartition.GetFixedWindowLimiter(
partitionKey: context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = authLimit,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0
}));
// Higher limit for redirect endpoint (public, needs to be fast)
options.AddPolicy("redirect", context =>
RateLimitPartition.GetSlidingWindowLimiter(
partitionKey: context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
factory: _ => new SlidingWindowRateLimiterOptions
{
PermitLimit = redirectLimit,
Window = TimeSpan.FromMinutes(1),
SegmentsPerWindow = 4,
QueueLimit = 0
}));
// API rate limit for authenticated endpoints
options.AddPolicy("api", context =>
RateLimitPartition.GetTokenBucketLimiter(
partitionKey: context.User?.Identity?.Name ?? context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
factory: _ => new TokenBucketRateLimiterOptions
{
TokenLimit = apiLimit,
ReplenishmentPeriod = TimeSpan.FromMinutes(1),
TokensPerPeriod = apiLimit,
QueueLimit = 0
}));
});
// Add services to the container
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("PostgresConnection")));
// Register application services
builder.Services.AddSingleton<IGeoIpService, GeoIpService>();
builder.Services.AddSingleton<IEventTrackingService, EventTrackingService>();
builder.Services.AddSingleton<IQrCodeGeneratorService, QrCodeGeneratorService>();
builder.Services.AddSingleton<IAssetStorageService, LocalAssetStorageService>();
builder.Services.AddSingleton<IPlanLimitsService, PlanLimitsService>();
// Configure email service
builder.Services.Configure<EmailSettings>(builder.Configuration.GetSection("Email"));
var emailProvider = builder.Configuration.GetValue<string>("Email:Provider") ?? "console";
if (emailProvider == "smtp")
{
builder.Services.AddSingleton<IEmailService, SmtpEmailService>();
}
else
{
// Use console email service for development
builder.Services.AddSingleton<IEmailService, ConsoleEmailService>();
}
// Configure Stripe
builder.Services.Configure<StripeSettings>(builder.Configuration.GetSection("Stripe"));
builder.Services.AddSingleton<IStripeService, StripeService>();
// Configure JWT settings
builder.Services.Configure<JwtSettings>(builder.Configuration.GetSection("Jwt"));
var jwtSettings = builder.Configuration.GetSection("Jwt").Get<JwtSettings>()!;
// Configure authentication
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = jwtSettings.Issuer,
ValidAudience = jwtSettings.Audience,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSettings.Secret))
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = jwtSettings.Issuer,
ValidAudience = jwtSettings.Audience,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSettings.Secret))
};
});
builder.Services.AddAuthorization();
builder.Services.AddFastEndpoints();
builder.Services.AddOpenApi();
var app = builder.Build();
// Global error handling middleware (must be first)
app.UseMiddleware<GlobalExceptionMiddleware>();
// Request logging middleware
app.UseSerilogRequestLogging(options =>
{
options.MessageTemplate = "{RequestMethod} {RequestPath} responded {StatusCode} in {Elapsed:0.0000} ms";
options.EnrichDiagnosticContext = (diagnosticContext, httpContext) =>
{
diagnosticContext.Set("RequestHost", httpContext.Request.Host.Value);
diagnosticContext.Set("UserAgent", httpContext.Request.Headers.UserAgent.ToString());
diagnosticContext.Set("ClientIP", httpContext.Connection.RemoteIpAddress?.ToString());
};
});
builder.Services.AddAuthorization();
builder.Services.AddFastEndpoints();
builder.Services.AddOpenApi();
app.UseCors();
app.UseRateLimiter();
var app = builder.Build();
// Configure the HTTP request pipeline
if (app.Environment.IsDevelopment())
{
app.MapOpenApi().CacheOutput();
app.UseSwaggerUI(options => { options.SwaggerEndpoint("/openapi/v1.json", "v1"); });
}
app.UseCors();
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.MapOpenApi().CacheOutput();
app.UseFastEndpoints();
app.UseSwaggerUI(options => { options.SwaggerEndpoint("/openapi/v1.json", "v1"); });
app.Run();
}
catch (Exception ex)
{
Log.Fatal(ex, "Application terminated unexpectedly");
}
finally
{
Log.CloseAndFlush();
}
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.UseFastEndpoints();
app.Run();

View File

@@ -13,6 +13,7 @@
<ItemGroup>
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="FastEndpoints" Version="7.2.0" />
<PackageReference Include="MaxMind.GeoIP2" Version="5.2.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.2" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.0">
@@ -21,6 +22,12 @@
</PackageReference>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
<PackageReference Include="QRCoder" Version="1.7.0" />
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
<PackageReference Include="SkiaSharp" Version="3.116.1" />
<PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="3.116.1" />
<PackageReference Include="Stripe.net" Version="47.4.0" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="10.1.0" />
</ItemGroup>

View File

@@ -9,6 +9,24 @@
"PostgresConnection": "Host=localhost;Port=5400;Database=trakqr;Username=sa;Password=P@ssword123!"
},
"Jwt": {
"Secret": "dev-secret-key-min-32-characters-long-for-hmac256!"
"Secret": "dev-secret-key-min-32-characters-long-for-hmac256!",
"Issuer": "TrakQR",
"Audience": "TrakQR",
"ExpirationMinutes": 60
},
"Email": {
"Provider": "console",
"FromEmail": "noreply@trakqr.local",
"FromName": "TrakQR",
"BaseUrl": "http://localhost:5173"
},
"Cors": {
"AllowedOrigins": ["http://localhost:5173", "https://localhost:5173"]
},
"Stripe": {
"SecretKey": "sk_test_your_test_key_here",
"WebhookSecret": "whsec_your_webhook_secret_here",
"ProPriceId": "price_pro_monthly",
"BusinessPriceId": "price_business_monthly"
}
}

View File

@@ -14,5 +14,30 @@
"Issuer": "TrakQR",
"Audience": "TrakQR",
"ExpirationMinutes": 60
},
"Email": {
"Provider": "smtp",
"FromEmail": "noreply@trakqr.com",
"FromName": "TrakQR",
"BaseUrl": "https://trakqr.com",
"Smtp": {
"Host": "",
"Port": 587,
"UseSsl": true,
"Username": "",
"Password": ""
}
},
"Cors": {
"AllowedOrigins": ["https://trakqr.com"]
},
"GeoIP": {
"DatabasePath": ""
},
"Stripe": {
"SecretKey": "",
"WebhookSecret": "",
"ProPriceId": "",
"BusinessPriceId": ""
}
}

View File

@@ -1,3 +1,22 @@
<template>
<router-view />
</template>
<script setup>
import { onMounted } from 'vue';
import { useAuthStore } from './stores/auth';
import { useWorkspaceStore } from './stores/workspace';
const authStore = useAuthStore();
const workspaceStore = useWorkspaceStore();
onMounted(async () => {
// Initialize auth first
await authStore.initialize();
// If authenticated, initialize workspace
if (authStore.isAuthenticated) {
await workspaceStore.initialize();
}
});
</script>

View File

@@ -77,6 +77,38 @@ class ApiClient {
return this.request('POST', '/auth/login', { email, password });
}
forgotPassword(email) {
return this.request('POST', '/auth/forgot', { email });
}
resetPassword(token, newPassword) {
return this.request('POST', '/auth/reset', { token, newPassword });
}
getProfile() {
return this.request('GET', '/auth/profile');
}
updateProfile(data) {
return this.request('PUT', '/auth/profile', data);
}
changePassword(currentPassword, newPassword) {
return this.request('POST', '/auth/change-password', { currentPassword, newPassword });
}
resendVerification() {
return this.request('POST', '/auth/resend-verification');
}
verifyEmail(token) {
return this.request('POST', '/auth/verify-email', { token });
}
deleteAccount(password) {
return this.request('DELETE', '/auth/account', { password });
}
// Workspaces
listWorkspaces() {
return this.request('GET', '/workspaces');
@@ -126,6 +158,10 @@ class ApiClient {
return this.request('GET', path);
}
restoreLink(workspaceId, id) {
return this.request('POST', `/workspaces/${workspaceId}/links/${id}/restore`);
}
createLink(workspaceId, data) {
return this.request('POST', `/workspaces/${workspaceId}/links`, data);
}
@@ -142,8 +178,19 @@ class ApiClient {
return this.request('DELETE', `/workspaces/${workspaceId}/links/${id}`);
}
getLinkAnalytics(workspaceId, linkId, period = '7d') {
return this.request('GET', `/workspaces/${workspaceId}/links/${linkId}/analytics?period=${period}`);
bulkCreateLinks(workspaceId, links) {
return this.request('POST', `/workspaces/${workspaceId}/links/bulk`, { links });
}
getLinkAnalytics(workspaceId, linkId, period = '7d', startDate = null, endDate = null) {
const params = new URLSearchParams();
if (startDate && endDate) {
params.set('startDate', startDate);
params.set('endDate', endDate);
} else {
params.set('period', period);
}
return this.request('GET', `/workspaces/${workspaceId}/links/${linkId}/analytics?${params.toString()}`);
}
// QR Codes
@@ -175,9 +222,20 @@ class ApiClient {
return `${API_BASE}/workspaces/${workspaceId}/qrcodes/${id}/export?format=${format}&size=${size}`;
}
getQRCodeAnalytics(workspaceId, qrCodeId, period = '7d') {
return this.request('GET', `/workspaces/${workspaceId}/qrcodes/${qrCodeId}/analytics?period=${period}`);
}
// Analytics
getWorkspaceAnalytics(workspaceId, period = '7d') {
return this.request('GET', `/workspaces/${workspaceId}/analytics?period=${period}`);
getWorkspaceAnalytics(workspaceId, period = '7d', startDate = null, endDate = null) {
const params = new URLSearchParams();
if (startDate && endDate) {
params.set('startDate', startDate);
params.set('endDate', endDate);
} else {
params.set('period', period);
}
return this.request('GET', `/workspaces/${workspaceId}/analytics?${params.toString()}`);
}
// Domains
@@ -209,6 +267,43 @@ class ApiClient {
deleteAsset(workspaceId, id) {
return this.request('DELETE', `/workspaces/${workspaceId}/assets/${id}`);
}
// Billing
createCheckoutSession(workspaceId, plan, successUrl, cancelUrl) {
return this.request('POST', '/billing/checkout', {
workspaceId,
plan,
successUrl,
cancelUrl,
});
}
createPortalSession(returnUrl) {
return this.request('POST', '/billing/portal', { returnUrl });
}
getSubscription(workspaceId) {
return this.request('GET', `/workspaces/${workspaceId}/subscription`);
}
// Usage
getUsage(workspaceId = null) {
const path = workspaceId ? `/usage?workspaceId=${workspaceId}` : '/usage';
return this.request('GET', path);
}
// API Keys
listApiKeys(workspaceId) {
return this.request('GET', `/workspaces/${workspaceId}/api-keys`);
}
createApiKey(workspaceId, name, expiresAt = null, scopes = null) {
return this.request('POST', `/workspaces/${workspaceId}/api-keys`, { name, expiresAt, scopes });
}
deleteApiKey(workspaceId, id) {
return this.request('DELETE', `/workspaces/${workspaceId}/api-keys/${id}`);
}
}
export const api = new ApiClient();

View File

@@ -9,19 +9,141 @@
</div>
<div class="workspace-selector" v-if="workspaceStore.currentWorkspace">
<select
:value="workspaceStore.currentWorkspace?.id"
@change="onWorkspaceChange"
class="workspace-select"
>
<option
v-for="ws in workspaceStore.workspaces"
:key="ws.id"
:value="ws.id"
<div class="workspace-dropdown">
<select
:value="workspaceStore.currentWorkspace?.id"
@change="onWorkspaceChange"
class="workspace-select"
>
{{ ws.name }}
</option>
</select>
<option
v-for="ws in workspaceStore.workspaces"
:key="ws.id"
:value="ws.id"
>
{{ ws.name }}
</option>
</select>
<div class="workspace-actions">
<button class="ws-action-btn" @click="showCreateWorkspace = true" title="Create workspace">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/>
</svg>
</button>
<button class="ws-action-btn" @click="showWorkspaceSettings = true" title="Workspace settings">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/>
<path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"/>
</svg>
</button>
</div>
</div>
</div>
<!-- Create Workspace Modal -->
<div v-if="showCreateWorkspace" class="modal-overlay" @click.self="showCreateWorkspace = false">
<div class="modal">
<div class="modal-header">
<h2>Create Workspace</h2>
<button class="close-btn" @click="showCreateWorkspace = false">&times;</button>
</div>
<form @submit.prevent="createWorkspace">
<div class="modal-body">
<div class="form-group">
<label for="ws-name">Workspace Name</label>
<input
id="ws-name"
v-model="newWorkspaceName"
type="text"
placeholder="e.g., Marketing Team"
required
/>
</div>
<div v-if="wsError" class="error-message">{{ wsError }}</div>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" @click="showCreateWorkspace = false">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="creatingWs">
{{ creatingWs ? 'Creating...' : 'Create' }}
</button>
</div>
</form>
</div>
</div>
<!-- Workspace Settings Modal -->
<div v-if="showWorkspaceSettings" class="modal-overlay" @click.self="showWorkspaceSettings = false">
<div class="modal">
<div class="modal-header">
<h2>Workspace Settings</h2>
<button class="close-btn" @click="showWorkspaceSettings = false">&times;</button>
</div>
<form @submit.prevent="updateWorkspace">
<div class="modal-body">
<div class="form-group">
<label for="ws-edit-name">Workspace Name</label>
<input
id="ws-edit-name"
v-model="editWorkspaceName"
type="text"
required
/>
</div>
<div class="workspace-info">
<div class="info-row">
<span class="info-label">Plan:</span>
<span class="info-value plan-badge" :class="workspaceStore.currentWorkspace?.plan?.toLowerCase()">
{{ workspaceStore.currentWorkspace?.plan }}
</span>
</div>
<div class="info-row">
<span class="info-label">Created:</span>
<span class="info-value">{{ formatDate(workspaceStore.currentWorkspace?.createdAt) }}</span>
</div>
</div>
<div v-if="wsError" class="error-message">{{ wsError }}</div>
</div>
<div class="modal-actions split">
<button
type="button"
class="btn btn-danger-outline"
@click="confirmDeleteWorkspace"
:disabled="workspaceStore.workspaces.length <= 1"
:title="workspaceStore.workspaces.length <= 1 ? 'Cannot delete your only workspace' : 'Delete workspace'"
>
Delete
</button>
<div class="action-group">
<button type="button" class="btn btn-secondary" @click="showWorkspaceSettings = false">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="updatingWs">
{{ updatingWs ? 'Saving...' : 'Save' }}
</button>
</div>
</div>
</form>
</div>
</div>
<!-- Delete Workspace Confirmation -->
<div v-if="showDeleteWorkspace" class="modal-overlay" @click.self="showDeleteWorkspace = false">
<div class="modal modal-sm">
<div class="modal-header">
<h2>Delete Workspace</h2>
<button class="close-btn" @click="showDeleteWorkspace = false">&times;</button>
</div>
<div class="modal-body">
<p class="delete-warning">
Are you sure you want to delete <strong>{{ workspaceStore.currentWorkspace?.name }}</strong>?
All links, QR codes, and analytics data will be permanently deleted.
</p>
</div>
<div class="modal-actions">
<button class="btn btn-secondary" @click="showDeleteWorkspace = false">Cancel</button>
<button class="btn btn-danger" @click="deleteWorkspace" :disabled="deletingWs">
{{ deletingWs ? 'Deleting...' : 'Delete Workspace' }}
</button>
</div>
</div>
</div>
<nav class="sidebar-nav">
@@ -59,6 +181,34 @@
</svg>
Analytics
</router-link>
<router-link to="/projects" class="nav-item" :class="{ active: $route.name === 'projects' }">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
</svg>
Projects
</router-link>
<router-link to="/domains" class="nav-item" :class="{ active: $route.name === 'domains' }">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line x1="2" y1="12" x2="22" y2="12"/>
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
</svg>
Domains
</router-link>
<router-link to="/billing" class="nav-item" :class="{ active: $route.name === 'billing' }">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="1" y="4" width="22" height="16" rx="2" ry="2"/>
<line x1="1" y1="10" x2="23" y2="10"/>
</svg>
Billing
</router-link>
<router-link to="/settings" class="nav-item" :class="{ active: $route.name === 'settings' }">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
Settings
</router-link>
</nav>
<div class="sidebar-footer">
@@ -80,7 +230,7 @@
</template>
<script setup>
import { onMounted } from 'vue';
import { ref, onMounted, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useAuthStore } from '../../stores/auth';
import { useWorkspaceStore } from '../../stores/workspace';
@@ -89,11 +239,29 @@ const router = useRouter();
const authStore = useAuthStore();
const workspaceStore = useWorkspaceStore();
// Workspace management state
const showCreateWorkspace = ref(false);
const showWorkspaceSettings = ref(false);
const showDeleteWorkspace = ref(false);
const newWorkspaceName = ref('');
const editWorkspaceName = ref('');
const wsError = ref('');
const creatingWs = ref(false);
const updatingWs = ref(false);
const deletingWs = ref(false);
onMounted(async () => {
authStore.checkAuth();
await workspaceStore.fetchWorkspaces();
// Ensure stores are initialized (in case component mounts before App.vue init completes)
await authStore.initialize();
await workspaceStore.initialize();
});
watch(() => workspaceStore.currentWorkspace, (ws) => {
if (ws) {
editWorkspaceName.value = ws.name;
}
}, { immediate: true });
const onWorkspaceChange = (e) => {
const workspace = workspaceStore.workspaces.find(w => w.id === e.target.value);
if (workspace) {
@@ -101,7 +269,65 @@ const onWorkspaceChange = (e) => {
}
};
const createWorkspace = async () => {
creatingWs.value = true;
wsError.value = '';
try {
const workspace = await workspaceStore.createWorkspace(newWorkspaceName.value);
workspaceStore.setCurrentWorkspace(workspace);
showCreateWorkspace.value = false;
newWorkspaceName.value = '';
} catch (err) {
wsError.value = err.message;
} finally {
creatingWs.value = false;
}
};
const updateWorkspace = async () => {
updatingWs.value = true;
wsError.value = '';
try {
const wsId = workspaceStore.currentWorkspace?.id;
await workspaceStore.updateWorkspace(wsId, editWorkspaceName.value);
showWorkspaceSettings.value = false;
} catch (err) {
wsError.value = err.message;
} finally {
updatingWs.value = false;
}
};
const confirmDeleteWorkspace = () => {
showWorkspaceSettings.value = false;
showDeleteWorkspace.value = true;
};
const deleteWorkspace = async () => {
deletingWs.value = true;
wsError.value = '';
try {
const wsId = workspaceStore.currentWorkspace?.id;
await workspaceStore.deleteWorkspace(wsId);
showDeleteWorkspace.value = false;
} catch (err) {
wsError.value = err.message;
} finally {
deletingWs.value = false;
}
};
const formatDate = (dateStr) => {
if (!dateStr) return '';
return new Date(dateStr).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
});
};
const logout = () => {
workspaceStore.clearAll();
authStore.logout();
router.push('/login');
};
@@ -227,4 +453,260 @@ const logout = () => {
padding: 32px;
overflow-y: auto;
}
/* Workspace dropdown */
.workspace-dropdown {
display: flex;
gap: 8px;
}
.workspace-dropdown .workspace-select {
flex: 1;
}
.workspace-actions {
display: flex;
gap: 4px;
}
.ws-action-btn {
width: 36px;
height: 36px;
border: 1px solid var(--line);
background: var(--bg);
border-radius: 8px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: var(--muted);
transition: all 0.15s;
}
.ws-action-btn:hover {
background: var(--surface);
color: var(--ink);
}
/* Modal styles */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: white;
border-radius: 12px;
width: 100%;
max-width: 420px;
max-height: 90vh;
overflow-y: auto;
}
.modal-sm {
max-width: 380px;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid #e5e7eb;
}
.modal-header h2 {
font-size: 1.125rem;
font-weight: 600;
margin: 0;
}
.close-btn {
width: 32px;
height: 32px;
border: none;
background: transparent;
font-size: 1.5rem;
color: #6b7280;
cursor: pointer;
border-radius: 6px;
}
.close-btn:hover {
background: #f3f4f6;
}
.modal-body {
padding: 1.5rem;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 0.5rem;
}
.form-group input {
width: 100%;
padding: 0.625rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 0.875rem;
}
.form-group input:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.workspace-info {
background: #f9fafb;
border-radius: 8px;
padding: 1rem;
margin-top: 1rem;
}
.info-row {
display: flex;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.info-row:last-child {
margin-bottom: 0;
}
.info-label {
font-size: 0.875rem;
color: #6b7280;
}
.info-value {
font-size: 0.875rem;
font-weight: 500;
}
.plan-badge {
padding: 0.125rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
}
.plan-badge.free {
background: #e5e7eb;
color: #374151;
}
.plan-badge.pro {
background: #dbeafe;
color: #1d4ed8;
}
.plan-badge.business {
background: #fef3c7;
color: #b45309;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding: 1rem 1.5rem;
border-top: 1px solid #e5e7eb;
}
.modal-actions.split {
justify-content: space-between;
}
.action-group {
display: flex;
gap: 0.75rem;
}
.delete-warning {
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 8px;
padding: 1rem;
color: #991b1b;
line-height: 1.5;
}
.error-message {
background: #fee2e2;
border: 1px solid #fecaca;
border-radius: 8px;
padding: 0.75rem;
color: #dc2626;
font-size: 0.875rem;
margin-top: 1rem;
}
.btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1rem;
border-radius: 8px;
font-weight: 500;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s;
border: none;
}
.btn-primary {
background: #3b82f6;
color: white;
}
.btn-primary:hover:not(:disabled) {
background: #2563eb;
}
.btn-secondary {
background: white;
color: #374151;
border: 1px solid #d1d5db;
}
.btn-secondary:hover:not(:disabled) {
background: #f9fafb;
}
.btn-danger {
background: #dc2626;
color: white;
}
.btn-danger:hover:not(:disabled) {
background: #b91c1c;
}
.btn-danger-outline {
background: white;
color: #dc2626;
border: 1px solid #fecaca;
}
.btn-danger-outline:hover:not(:disabled) {
background: #fef2f2;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>

View File

@@ -5,12 +5,20 @@ import { useAuthStore } from '../stores/auth';
import Landing from '../views/Landing.vue';
import Login from '../views/auth/Login.vue';
import Register from '../views/auth/Register.vue';
import ForgotPassword from '../views/auth/ForgotPassword.vue';
import ResetPassword from '../views/auth/ResetPassword.vue';
import VerifyEmail from '../views/auth/VerifyEmail.vue';
import Dashboard from '../views/dashboard/Dashboard.vue';
import Links from '../views/links/Links.vue';
import LinkDetail from '../views/links/LinkDetail.vue';
import QRCodes from '../views/qrcodes/QRCodes.vue';
import QRCodeDesigner from '../views/qrcodes/QRCodeDesigner.vue';
import QRCodeDetail from '../views/qrcodes/QRCodeDetail.vue';
import Analytics from '../views/analytics/Analytics.vue';
import Billing from '../views/billing/Billing.vue';
import Projects from '../views/projects/Projects.vue';
import Domains from '../views/domains/Domains.vue';
import Settings from '../views/settings/Settings.vue';
const routes = [
{
@@ -30,6 +38,23 @@ const routes = [
component: Register,
meta: { guest: true },
},
{
path: '/forgot-password',
name: 'forgot-password',
component: ForgotPassword,
meta: { guest: true },
},
{
path: '/reset-password',
name: 'reset-password',
component: ResetPassword,
meta: { guest: true },
},
{
path: '/verify-email',
name: 'verify-email',
component: VerifyEmail,
},
{
path: '/dashboard',
name: 'dashboard',
@@ -66,12 +91,42 @@ const routes = [
component: QRCodeDesigner,
meta: { requiresAuth: true },
},
{
path: '/qrcodes/:id/analytics',
name: 'qrcode-analytics',
component: QRCodeDetail,
meta: { requiresAuth: true },
},
{
path: '/analytics',
name: 'analytics',
component: Analytics,
meta: { requiresAuth: true },
},
{
path: '/billing',
name: 'billing',
component: Billing,
meta: { requiresAuth: true },
},
{
path: '/projects',
name: 'projects',
component: Projects,
meta: { requiresAuth: true },
},
{
path: '/domains',
name: 'domains',
component: Domains,
meta: { requiresAuth: true },
},
{
path: '/settings',
name: 'settings',
component: Settings,
meta: { requiresAuth: true },
},
];
const router = createRouter({

View File

@@ -7,6 +7,7 @@ export const useAuthStore = defineStore('auth', {
token: localStorage.getItem('token'),
loading: false,
error: null,
initialized: false,
}),
getters: {
@@ -14,13 +15,30 @@ export const useAuthStore = defineStore('auth', {
},
actions: {
async initialize() {
if (this.initialized) return;
if (this.token) {
api.setToken(this.token);
try {
const profile = await api.getProfile();
this.user = profile;
} catch (err) {
// Token is invalid, clear it
this.logout();
}
}
this.initialized = true;
},
async register(email, password) {
this.loading = true;
this.error = null;
try {
const response = await api.register(email, password);
this.token = response.token;
this.user = { email: response.email };
this.user = { email: response.email, isVerified: false };
localStorage.setItem('token', response.token);
api.setToken(response.token);
return true;
} catch (err) {
@@ -38,6 +56,7 @@ export const useAuthStore = defineStore('auth', {
const response = await api.login(email, password);
this.token = response.token;
this.user = { email: response.email };
localStorage.setItem('token', response.token);
api.setToken(response.token);
return true;
} catch (err) {
@@ -51,15 +70,17 @@ export const useAuthStore = defineStore('auth', {
logout() {
this.token = null;
this.user = null;
localStorage.removeItem('token');
localStorage.removeItem('currentWorkspaceId');
api.setToken(null);
},
checkAuth() {
if (this.token) {
api.setToken(this.token);
return true;
async fetchProfile() {
try {
this.user = await api.getProfile();
} catch (err) {
this.error = err.message;
}
return false;
},
},
});

View File

@@ -8,23 +8,44 @@ export const useWorkspaceStore = defineStore('workspace', {
projects: [],
links: [],
qrcodes: [],
domains: [],
assets: [],
analytics: null,
loading: false,
error: null,
initialized: false,
}),
getters: {
currentWorkspaceId: (state) => state.currentWorkspace?.id,
currentPlan: (state) => state.currentWorkspace?.plan || 'Free',
canUseCustomDomains: (state) => {
const plan = state.currentWorkspace?.plan;
return plan === 'Pro' || plan === 'Business';
},
},
actions: {
async initialize() {
if (this.initialized) return;
await this.fetchWorkspaces();
this.initialized = true;
},
async fetchWorkspaces() {
this.loading = true;
try {
const response = await api.listWorkspaces();
this.workspaces = response.workspaces;
if (!this.currentWorkspace && this.workspaces.length > 0) {
this.currentWorkspace = this.workspaces[0];
this.workspaces = response.workspaces || [];
// Restore saved workspace or use first one
const savedId = localStorage.getItem('currentWorkspaceId');
const saved = savedId ? this.workspaces.find(w => w.id === savedId) : null;
if (saved) {
this.currentWorkspace = saved;
} else if (this.workspaces.length > 0) {
this.setCurrentWorkspace(this.workspaces[0]);
}
} catch (err) {
this.error = err.message;
@@ -35,9 +56,17 @@ export const useWorkspaceStore = defineStore('workspace', {
setCurrentWorkspace(workspace) {
this.currentWorkspace = workspace;
if (workspace) {
localStorage.setItem('currentWorkspaceId', workspace.id);
} else {
localStorage.removeItem('currentWorkspaceId');
}
// Clear workspace-specific data
this.projects = [];
this.links = [];
this.qrcodes = [];
this.domains = [];
this.assets = [];
this.analytics = null;
},
@@ -52,12 +81,42 @@ export const useWorkspaceStore = defineStore('workspace', {
}
},
async updateWorkspace(id, name) {
try {
const updated = await api.updateWorkspace(id, name);
const index = this.workspaces.findIndex(w => w.id === id);
if (index !== -1) {
this.workspaces[index] = updated;
}
if (this.currentWorkspace?.id === id) {
this.currentWorkspace = updated;
}
return updated;
} catch (err) {
this.error = err.message;
throw err;
}
},
async deleteWorkspace(id) {
try {
await api.deleteWorkspace(id);
this.workspaces = this.workspaces.filter(w => w.id !== id);
if (this.currentWorkspace?.id === id) {
this.setCurrentWorkspace(this.workspaces[0] || null);
}
} catch (err) {
this.error = err.message;
throw err;
}
},
// Projects
async fetchProjects() {
if (!this.currentWorkspaceId) return;
try {
const response = await api.listProjects(this.currentWorkspaceId);
this.projects = response.projects;
this.projects = response.projects || [];
} catch (err) {
this.error = err.message;
}
@@ -67,7 +126,22 @@ export const useWorkspaceStore = defineStore('workspace', {
if (!this.currentWorkspaceId) return;
try {
const project = await api.createProject(this.currentWorkspaceId, name, description);
this.projects.push(project);
this.projects.unshift(project);
return project;
} catch (err) {
this.error = err.message;
throw err;
}
},
async updateProject(id, data) {
if (!this.currentWorkspaceId) return;
try {
const project = await api.updateProject(this.currentWorkspaceId, id, data);
const index = this.projects.findIndex(p => p.id === id);
if (index !== -1) {
this.projects[index] = project;
}
return project;
} catch (err) {
this.error = err.message;
@@ -91,7 +165,7 @@ export const useWorkspaceStore = defineStore('workspace', {
if (!this.currentWorkspaceId) return;
try {
const response = await api.listLinks(this.currentWorkspaceId, params);
this.links = response.links;
this.links = response.links || [];
} catch (err) {
this.error = err.message;
}
@@ -140,7 +214,7 @@ export const useWorkspaceStore = defineStore('workspace', {
if (!this.currentWorkspaceId) return;
try {
const response = await api.listQRCodes(this.currentWorkspaceId);
this.qrcodes = response.qrCodes;
this.qrcodes = response.qrCodes || [];
} catch (err) {
this.error = err.message;
}
@@ -184,14 +258,109 @@ export const useWorkspaceStore = defineStore('workspace', {
}
},
// Analytics
async fetchAnalytics(period = '7d') {
// Domains
async fetchDomains() {
if (!this.currentWorkspaceId) return;
try {
this.analytics = await api.getWorkspaceAnalytics(this.currentWorkspaceId, period);
const response = await api.listDomains(this.currentWorkspaceId);
this.domains = response.domains || [];
} catch (err) {
this.error = err.message;
}
},
async addDomain(hostname) {
if (!this.currentWorkspaceId) return;
try {
const domain = await api.addDomain(this.currentWorkspaceId, hostname);
this.domains.unshift(domain);
return domain;
} catch (err) {
this.error = err.message;
throw err;
}
},
async verifyDomain(id) {
if (!this.currentWorkspaceId) return;
try {
const result = await api.verifyDomain(this.currentWorkspaceId, id);
// Refresh domains to get updated status
await this.fetchDomains();
return result;
} catch (err) {
this.error = err.message;
throw err;
}
},
async deleteDomain(id) {
if (!this.currentWorkspaceId) return;
try {
await api.deleteDomain(this.currentWorkspaceId, id);
this.domains = this.domains.filter(d => d.id !== id);
} catch (err) {
this.error = err.message;
throw err;
}
},
// Assets
async fetchAssets() {
if (!this.currentWorkspaceId) return;
try {
const response = await api.listAssets(this.currentWorkspaceId);
this.assets = response.assets || [];
} catch (err) {
this.error = err.message;
}
},
async uploadAsset(file) {
if (!this.currentWorkspaceId) return;
try {
const asset = await api.uploadAsset(this.currentWorkspaceId, file);
this.assets.unshift(asset);
return asset;
} catch (err) {
this.error = err.message;
throw err;
}
},
async deleteAsset(id) {
if (!this.currentWorkspaceId) return;
try {
await api.deleteAsset(this.currentWorkspaceId, id);
this.assets = this.assets.filter(a => a.id !== id);
} catch (err) {
this.error = err.message;
throw err;
}
},
// Analytics
async fetchAnalytics(period = '7d', startDate = null, endDate = null) {
if (!this.currentWorkspaceId) return;
try {
this.analytics = await api.getWorkspaceAnalytics(this.currentWorkspaceId, period, startDate, endDate);
} catch (err) {
this.error = err.message;
}
},
// Clear all data (for logout)
clearAll() {
this.workspaces = [];
this.currentWorkspace = null;
this.projects = [];
this.links = [];
this.qrcodes = [];
this.domains = [];
this.assets = [];
this.analytics = null;
this.initialized = false;
localStorage.removeItem('currentWorkspaceId');
},
},
});

View File

@@ -6,15 +6,28 @@
<h1>Analytics</h1>
<p class="subtitle">Track performance across your workspace</p>
</div>
<div class="period-selector">
<button
v-for="p in periods"
:key="p.value"
:class="{ active: period === p.value }"
@click="setPeriod(p.value)"
>
{{ p.label }}
</button>
<div class="period-controls">
<div class="period-selector">
<button
v-for="p in periods"
:key="p.value"
:class="{ active: period === p.value && !isCustomRange }"
@click="setPeriod(p.value)"
>
{{ p.label }}
</button>
<button
:class="{ active: isCustomRange }"
@click="toggleCustomRange"
>
Custom
</button>
</div>
<div v-if="isCustomRange" class="date-range">
<input type="date" v-model="startDate" @change="applyCustomRange" />
<span>to</span>
<input type="date" v-model="endDate" @change="applyCustomRange" />
</div>
</div>
</header>
@@ -198,13 +211,34 @@ const periods = [
];
const period = ref('7d');
const isCustomRange = ref(false);
const startDate = ref('');
const endDate = ref('');
const analytics = computed(() => workspaceStore.analytics);
const setPeriod = async (p) => {
isCustomRange.value = false;
period.value = p;
await workspaceStore.fetchAnalytics(p);
};
const toggleCustomRange = () => {
isCustomRange.value = true;
// Set default range to last 30 days
const end = new Date();
const start = new Date();
start.setDate(start.getDate() - 30);
startDate.value = start.toISOString().split('T')[0];
endDate.value = end.toISOString().split('T')[0];
applyCustomRange();
};
const applyCustomRange = async () => {
if (startDate.value && endDate.value) {
await workspaceStore.fetchAnalytics(null, startDate.value, endDate.value);
}
};
const maxEvents = computed(() => {
if (!analytics.value?.timeSeries) return 1;
return Math.max(...analytics.value.timeSeries.map(p => Math.max(p.clicks, p.scans)), 1);
@@ -264,6 +298,13 @@ watch(() => workspaceStore.currentWorkspaceId, async () => {
color: var(--muted);
}
.period-controls {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 12px;
}
.period-selector {
display: flex;
gap: 4px;
@@ -290,6 +331,36 @@ watch(() => workspaceStore.currentWorkspaceId, async () => {
color: white;
}
.date-range {
display: flex;
align-items: center;
gap: 12px;
background: var(--surface);
padding: 8px 12px;
border-radius: 10px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.date-range input[type="date"] {
padding: 8px 12px;
border: 1px solid var(--line);
border-radius: 8px;
font-size: 0.875rem;
font-family: inherit;
background: var(--bg);
color: var(--ink);
}
.date-range input[type="date"]:focus {
outline: none;
border-color: var(--accent);
}
.date-range span {
color: var(--muted);
font-size: 0.875rem;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);

View File

@@ -0,0 +1,215 @@
<template>
<div class="auth-page">
<div class="auth-card">
<div class="auth-header">
<div class="brand">
<span class="brand-mark">TQ</span>
<span class="brand-name">TrakQR</span>
</div>
<h1>Reset your password</h1>
<p>Enter your email and we'll send you a reset link</p>
</div>
<form v-if="!submitted" @submit.prevent="handleSubmit" class="auth-form">
<div class="form-group">
<label for="email">Email</label>
<input
id="email"
v-model="email"
type="email"
placeholder="you@example.com"
required
/>
</div>
<div v-if="error" class="error-message">
{{ error }}
</div>
<button type="submit" class="cta full" :disabled="loading">
{{ loading ? 'Sending...' : 'Send reset link' }}
</button>
</form>
<div v-else class="success-message">
<div class="success-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
</div>
<h2>Check your email</h2>
<p>If an account exists for {{ email }}, we've sent password reset instructions.</p>
<router-link to="/login" class="cta full">Back to login</router-link>
</div>
<p class="auth-footer" v-if="!submitted">
Remember your password?
<router-link to="/login">Sign in</router-link>
</p>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
import { api } from '../../api/client';
const email = ref('');
const loading = ref(false);
const error = ref('');
const submitted = ref(false);
const handleSubmit = async () => {
loading.value = true;
error.value = '';
try {
await api.forgotPassword(email.value);
submitted.value = true;
} catch (err) {
error.value = err.message;
} finally {
loading.value = false;
}
};
</script>
<style scoped>
.auth-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.auth-card {
width: 100%;
max-width: 400px;
background: var(--surface);
border-radius: 24px;
padding: 40px;
box-shadow: var(--shadow);
}
.auth-header {
text-align: center;
margin-bottom: 32px;
}
.brand {
display: inline-flex;
align-items: center;
gap: 12px;
margin-bottom: 24px;
}
.brand-mark {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 12px;
background: var(--ink);
color: #fff4ec;
font-weight: 700;
}
.brand-name {
font-size: 1.2rem;
font-weight: 600;
}
.auth-header h1 {
font-size: 1.5rem;
margin-bottom: 8px;
}
.auth-header p {
color: var(--muted);
}
.auth-form {
display: flex;
flex-direction: column;
gap: 20px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-group label {
font-weight: 500;
font-size: 0.9rem;
}
.form-group input {
padding: 12px 16px;
border: 1px solid var(--line);
border-radius: 12px;
font-size: 1rem;
font-family: inherit;
transition: border-color 0.15s ease;
}
.form-group input:focus {
outline: none;
border-color: var(--accent);
}
.error-message {
padding: 12px;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 10px;
color: #dc2626;
font-size: 0.9rem;
}
.success-message {
text-align: center;
}
.success-icon {
color: #22c55e;
margin-bottom: 16px;
}
.success-message h2 {
margin-bottom: 12px;
}
.success-message p {
color: var(--muted);
margin-bottom: 24px;
}
.cta.full {
width: 100%;
padding: 14px;
font-size: 1rem;
display: block;
text-align: center;
}
.cta:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.auth-footer {
text-align: center;
margin-top: 24px;
color: var(--muted);
}
.auth-footer a {
color: var(--accent);
font-weight: 500;
}
</style>

View File

@@ -37,6 +37,10 @@
{{ authStore.error }}
</div>
<div class="form-actions">
<router-link to="/forgot-password" class="forgot-link">Forgot password?</router-link>
</div>
<button type="submit" class="cta full" :disabled="authStore.loading">
{{ authStore.loading ? 'Signing in...' : 'Sign in' }}
</button>
@@ -167,6 +171,19 @@ const handleSubmit = async () => {
font-size: 0.9rem;
}
.form-actions {
text-align: right;
}
.forgot-link {
color: var(--muted);
font-size: 0.9rem;
}
.forgot-link:hover {
color: var(--accent);
}
.cta.full {
width: 100%;
padding: 14px;

View File

@@ -0,0 +1,231 @@
<template>
<div class="auth-page">
<div class="auth-card">
<div class="auth-header">
<div class="brand">
<span class="brand-mark">TQ</span>
<span class="brand-name">TrakQR</span>
</div>
<h1>Set new password</h1>
<p>Enter your new password below</p>
</div>
<form v-if="!success" @submit.prevent="handleSubmit" class="auth-form">
<div class="form-group">
<label for="password">New Password</label>
<input
id="password"
v-model="password"
type="password"
placeholder="At least 8 characters"
minlength="8"
required
/>
</div>
<div class="form-group">
<label for="confirmPassword">Confirm Password</label>
<input
id="confirmPassword"
v-model="confirmPassword"
type="password"
placeholder="Repeat your password"
required
/>
</div>
<div v-if="error" class="error-message">
{{ error }}
</div>
<button type="submit" class="cta full" :disabled="loading || !isValid">
{{ loading ? 'Resetting...' : 'Reset password' }}
</button>
</form>
<div v-else class="success-message">
<div class="success-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
</div>
<h2>Password reset complete</h2>
<p>Your password has been updated. You can now sign in with your new password.</p>
<router-link to="/login" class="cta full">Sign in</router-link>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
import { useRoute } from 'vue-router';
import { api } from '../../api/client';
const route = useRoute();
const password = ref('');
const confirmPassword = ref('');
const loading = ref(false);
const error = ref('');
const success = ref(false);
const token = computed(() => route.query.token || '');
const isValid = computed(() => {
return password.value.length >= 8 && password.value === confirmPassword.value;
});
const handleSubmit = async () => {
if (!token.value) {
error.value = 'Invalid reset link. Please request a new one.';
return;
}
if (password.value !== confirmPassword.value) {
error.value = 'Passwords do not match';
return;
}
loading.value = true;
error.value = '';
try {
await api.resetPassword(token.value, password.value);
success.value = true;
} catch (err) {
error.value = err.message;
} finally {
loading.value = false;
}
};
</script>
<style scoped>
.auth-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.auth-card {
width: 100%;
max-width: 400px;
background: var(--surface);
border-radius: 24px;
padding: 40px;
box-shadow: var(--shadow);
}
.auth-header {
text-align: center;
margin-bottom: 32px;
}
.brand {
display: inline-flex;
align-items: center;
gap: 12px;
margin-bottom: 24px;
}
.brand-mark {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 12px;
background: var(--ink);
color: #fff4ec;
font-weight: 700;
}
.brand-name {
font-size: 1.2rem;
font-weight: 600;
}
.auth-header h1 {
font-size: 1.5rem;
margin-bottom: 8px;
}
.auth-header p {
color: var(--muted);
}
.auth-form {
display: flex;
flex-direction: column;
gap: 20px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-group label {
font-weight: 500;
font-size: 0.9rem;
}
.form-group input {
padding: 12px 16px;
border: 1px solid var(--line);
border-radius: 12px;
font-size: 1rem;
font-family: inherit;
transition: border-color 0.15s ease;
}
.form-group input:focus {
outline: none;
border-color: var(--accent);
}
.error-message {
padding: 12px;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 10px;
color: #dc2626;
font-size: 0.9rem;
}
.success-message {
text-align: center;
}
.success-icon {
color: #22c55e;
margin-bottom: 16px;
}
.success-message h2 {
margin-bottom: 12px;
}
.success-message p {
color: var(--muted);
margin-bottom: 24px;
}
.cta.full {
width: 100%;
padding: 14px;
font-size: 1rem;
display: block;
text-align: center;
}
.cta:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>

View File

@@ -0,0 +1,244 @@
<template>
<div class="auth-page">
<div class="auth-card">
<div class="auth-header">
<div class="brand">
<span class="brand-mark">TQ</span>
<span class="brand-name">TrakQR</span>
</div>
<h1>{{ title }}</h1>
<p>{{ subtitle }}</p>
</div>
<!-- Loading state -->
<div v-if="loading" class="status-message loading">
<div class="spinner"></div>
<p>Verifying your email...</p>
</div>
<!-- Success state -->
<div v-else-if="success" class="status-message success">
<div class="status-icon success-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
</div>
<h2>Email verified!</h2>
<p>Your email has been verified successfully. You can now access all features.</p>
<router-link to="/dashboard" class="cta full">Go to Dashboard</router-link>
</div>
<!-- Error state -->
<div v-else-if="error" class="status-message error">
<div class="status-icon error-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line x1="15" y1="9" x2="9" y2="15"/>
<line x1="9" y1="9" x2="15" y2="15"/>
</svg>
</div>
<h2>Verification failed</h2>
<p>{{ error }}</p>
<div class="action-buttons">
<router-link to="/settings" class="cta full">Request new link</router-link>
<router-link to="/login" class="link-secondary">Back to login</router-link>
</div>
</div>
<!-- No token state -->
<div v-else class="status-message error">
<div class="status-icon error-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="8" x2="12" y2="12"/>
<line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
</div>
<h2>Invalid link</h2>
<p>This verification link is invalid or missing. Please check your email for the correct link.</p>
<div class="action-buttons">
<router-link to="/settings" class="cta full">Request new link</router-link>
<router-link to="/login" class="link-secondary">Back to login</router-link>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { api } from '../../api/client';
const route = useRoute();
const loading = ref(false);
const success = ref(false);
const error = ref('');
const token = computed(() => route.query.token || '');
const title = computed(() => {
if (loading.value) return 'Verifying email';
if (success.value) return 'Email verified';
if (error.value || !token.value) return 'Verification failed';
return 'Verify your email';
});
const subtitle = computed(() => {
if (loading.value) return 'Please wait while we verify your email address';
if (success.value) return 'Your account is now fully activated';
return 'There was a problem with your verification';
});
onMounted(async () => {
if (!token.value) {
return;
}
loading.value = true;
try {
await api.verifyEmail(token.value);
success.value = true;
} catch (err) {
error.value = err.message || 'Unable to verify your email. The link may be invalid or expired.';
} finally {
loading.value = false;
}
});
</script>
<style scoped>
.auth-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.auth-card {
width: 100%;
max-width: 400px;
background: var(--surface);
border-radius: 24px;
padding: 40px;
box-shadow: var(--shadow);
}
.auth-header {
text-align: center;
margin-bottom: 32px;
}
.brand {
display: inline-flex;
align-items: center;
gap: 12px;
margin-bottom: 24px;
}
.brand-mark {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 12px;
background: var(--ink);
color: #fff4ec;
font-weight: 700;
}
.brand-name {
font-size: 1.2rem;
font-weight: 600;
}
.auth-header h1 {
font-size: 1.5rem;
margin-bottom: 8px;
}
.auth-header p {
color: var(--muted);
}
.status-message {
text-align: center;
}
.status-message.loading {
padding: 20px 0;
}
.spinner {
width: 48px;
height: 48px;
border: 3px solid var(--line);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 16px;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.status-message.loading p {
color: var(--muted);
}
.status-icon {
margin-bottom: 16px;
}
.success-icon {
color: #22c55e;
}
.error-icon {
color: #dc2626;
}
.status-message h2 {
font-size: 1.25rem;
margin-bottom: 12px;
}
.status-message p {
color: var(--muted);
margin-bottom: 24px;
line-height: 1.5;
}
.action-buttons {
display: flex;
flex-direction: column;
gap: 12px;
}
.cta.full {
width: 100%;
padding: 14px;
font-size: 1rem;
display: block;
text-align: center;
}
.link-secondary {
color: var(--muted);
text-decoration: none;
font-size: 0.9rem;
}
.link-secondary:hover {
color: var(--ink);
text-decoration: underline;
}
</style>

View File

@@ -0,0 +1,598 @@
<template>
<AppLayout>
<div class="billing">
<header class="page-header">
<h1>Billing & Plans</h1>
<p class="subtitle">Manage your subscription and view usage</p>
</header>
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<!-- Current Plan Card -->
<section class="card current-plan">
<div class="plan-header">
<div>
<h2>Current Plan</h2>
<div class="plan-badge" :class="currentPlan.toLowerCase()">
{{ currentPlan }}
</div>
</div>
<button
v-if="subscription?.subscriptionId"
class="btn btn-secondary"
@click="openPortal"
:disabled="portalLoading"
>
{{ portalLoading ? 'Loading...' : 'Manage Subscription' }}
</button>
</div>
<div v-if="subscription?.cancelAtPeriodEnd" class="cancel-notice">
Your subscription will be canceled on {{ formatDate(subscription.currentPeriodEnd) }}.
You'll be downgraded to the Free plan after this date.
</div>
<div class="usage-section" v-if="usage">
<h3>Usage This Month</h3>
<div class="usage-grid">
<div class="usage-item">
<div class="usage-bar">
<div
class="usage-fill"
:style="{ width: getUsagePercent(usage.workspaces, usage.limits.maxWorkspaces) + '%' }"
:class="{ warning: getUsagePercent(usage.workspaces, usage.limits.maxWorkspaces) > 80 }"
></div>
</div>
<div class="usage-label">
<span>Workspaces</span>
<span>{{ usage.workspaces }} / {{ formatLimit(usage.limits.maxWorkspaces) }}</span>
</div>
</div>
<div class="usage-item">
<div class="usage-bar">
<div
class="usage-fill"
:style="{ width: getUsagePercent(usage.links, usage.limits.maxLinks) + '%' }"
:class="{ warning: getUsagePercent(usage.links, usage.limits.maxLinks) > 80 }"
></div>
</div>
<div class="usage-label">
<span>Links</span>
<span>{{ usage.links }} / {{ formatLimit(usage.limits.maxLinks) }}</span>
</div>
</div>
<div class="usage-item">
<div class="usage-bar">
<div
class="usage-fill"
:style="{ width: getUsagePercent(usage.qrCodes, usage.limits.maxQRCodes) + '%' }"
:class="{ warning: getUsagePercent(usage.qrCodes, usage.limits.maxQRCodes) > 80 }"
></div>
</div>
<div class="usage-label">
<span>QR Codes</span>
<span>{{ usage.qrCodes }} / {{ formatLimit(usage.limits.maxQRCodes) }}</span>
</div>
</div>
<div class="usage-item">
<div class="usage-bar">
<div
class="usage-fill"
:style="{ width: getUsagePercent(usage.eventsThisMonth, usage.limits.maxEventsPerMonth) + '%' }"
:class="{ warning: getUsagePercent(usage.eventsThisMonth, usage.limits.maxEventsPerMonth) > 80 }"
></div>
</div>
<div class="usage-label">
<span>Events</span>
<span>{{ formatNumber(usage.eventsThisMonth) }} / {{ formatLimit(usage.limits.maxEventsPerMonth) }}</span>
</div>
</div>
</div>
</div>
</section>
<!-- Plan Comparison -->
<section class="plans-section">
<h2>Available Plans</h2>
<div class="plans-grid">
<div class="plan-card" :class="{ current: currentPlan === 'Free' }">
<div class="plan-name">Free</div>
<div class="plan-price">
<span class="amount">$0</span>
<span class="period">/month</span>
</div>
<ul class="plan-features">
<li>1 workspace</li>
<li>50 links</li>
<li>25 QR codes</li>
<li>10,000 events/month</li>
<li>Basic analytics</li>
</ul>
<button
v-if="currentPlan === 'Free'"
class="btn btn-secondary"
disabled
>
Current Plan
</button>
</div>
<div class="plan-card featured" :class="{ current: currentPlan === 'Pro' }">
<div class="popular-badge">Most Popular</div>
<div class="plan-name">Pro</div>
<div class="plan-price">
<span class="amount">$29</span>
<span class="period">/month</span>
</div>
<ul class="plan-features">
<li>5 workspaces</li>
<li>5,000 links</li>
<li>1,000 QR codes</li>
<li>100,000 events/month</li>
<li>3 custom domains</li>
<li>Password protection</li>
<li>Advanced analytics</li>
</ul>
<button
v-if="currentPlan === 'Pro'"
class="btn btn-secondary"
disabled
>
Current Plan
</button>
<button
v-else-if="currentPlan === 'Free'"
class="btn btn-primary"
@click="upgrade('Pro')"
:disabled="upgrading"
>
{{ upgrading ? 'Loading...' : 'Upgrade to Pro' }}
</button>
<button
v-else
class="btn btn-secondary"
@click="openPortal"
>
Downgrade
</button>
</div>
<div class="plan-card" :class="{ current: currentPlan === 'Business' }">
<div class="plan-name">Business</div>
<div class="plan-price">
<span class="amount">$99</span>
<span class="period">/month</span>
</div>
<ul class="plan-features">
<li>Unlimited workspaces</li>
<li>Unlimited links</li>
<li>Unlimited QR codes</li>
<li>Unlimited events</li>
<li>Unlimited custom domains</li>
<li>Password protection</li>
<li>Priority support</li>
<li>API access</li>
</ul>
<button
v-if="currentPlan === 'Business'"
class="btn btn-secondary"
disabled
>
Current Plan
</button>
<button
v-else
class="btn btn-primary"
@click="upgrade('Business')"
:disabled="upgrading"
>
{{ upgrading ? 'Loading...' : 'Upgrade to Business' }}
</button>
</div>
</div>
</section>
</template>
<div v-if="error" class="error-message">{{ error }}</div>
</div>
</AppLayout>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { api } from '../../api/client';
import { useWorkspaceStore } from '../../stores/workspace';
import AppLayout from '../../components/layout/AppLayout.vue';
const route = useRoute();
const workspaceStore = useWorkspaceStore();
const loading = ref(true);
const upgrading = ref(false);
const portalLoading = ref(false);
const error = ref('');
const usage = ref(null);
const subscription = ref(null);
const currentPlan = computed(() => usage.value?.plan || 'Free');
onMounted(async () => {
// Check for success from checkout
if (route.query.session_id) {
// Payment was successful, refresh data
setTimeout(() => loadData(), 1000);
} else {
loadData();
}
});
async function loadData() {
loading.value = true;
error.value = '';
try {
const workspaceId = workspaceStore.currentWorkspace?.id;
const [usageData, subData] = await Promise.all([
api.getUsage(workspaceId),
workspaceId ? api.getSubscription(workspaceId) : Promise.resolve(null),
]);
usage.value = usageData;
subscription.value = subData;
} catch (err) {
error.value = err.message;
} finally {
loading.value = false;
}
}
async function upgrade(plan) {
upgrading.value = true;
error.value = '';
try {
const workspaceId = workspaceStore.currentWorkspace?.id;
if (!workspaceId) {
throw new Error('No workspace selected');
}
const successUrl = window.location.origin + '/billing';
const cancelUrl = window.location.origin + '/billing';
const { url } = await api.createCheckoutSession(
workspaceId,
plan,
successUrl,
cancelUrl
);
window.location.href = url;
} catch (err) {
error.value = err.message;
upgrading.value = false;
}
}
async function openPortal() {
portalLoading.value = true;
error.value = '';
try {
const returnUrl = window.location.origin + '/billing';
const { url } = await api.createPortalSession(returnUrl);
window.location.href = url;
} catch (err) {
error.value = err.message;
portalLoading.value = false;
}
}
function getUsagePercent(used, limit) {
if (limit === 2147483647 || limit === -1) return 0; // Unlimited
return Math.min(100, (used / limit) * 100);
}
function formatLimit(limit) {
if (limit === 2147483647 || limit === -1) return 'Unlimited';
return formatNumber(limit);
}
function formatNumber(num) {
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
if (num >= 1000) return (num / 1000).toFixed(1) + 'K';
return num.toString();
}
function formatDate(dateStr) {
if (!dateStr) return '';
return new Date(dateStr).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
}
</script>
<style scoped>
.billing {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.page-header {
margin-bottom: 2rem;
}
.page-header h1 {
font-size: 1.75rem;
font-weight: 600;
margin: 0 0 0.5rem;
}
.subtitle {
color: #6b7280;
margin: 0;
}
.loading {
text-align: center;
padding: 3rem;
color: #6b7280;
}
.card {
background: white;
border-radius: 12px;
padding: 1.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
margin-bottom: 2rem;
}
.current-plan .plan-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1.5rem;
}
.current-plan h2 {
font-size: 1.125rem;
font-weight: 600;
margin: 0 0 0.5rem;
}
.plan-badge {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.875rem;
font-weight: 500;
}
.plan-badge.free {
background: #e5e7eb;
color: #374151;
}
.plan-badge.pro {
background: #dbeafe;
color: #1d4ed8;
}
.plan-badge.business {
background: #fef3c7;
color: #b45309;
}
.cancel-notice {
background: #fef3c7;
border: 1px solid #fcd34d;
border-radius: 8px;
padding: 1rem;
margin-bottom: 1.5rem;
color: #92400e;
}
.usage-section h3 {
font-size: 1rem;
font-weight: 600;
margin: 0 0 1rem;
color: #374151;
}
.usage-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
}
.usage-item {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.usage-bar {
height: 8px;
background: #e5e7eb;
border-radius: 4px;
overflow: hidden;
}
.usage-fill {
height: 100%;
background: #3b82f6;
border-radius: 4px;
transition: width 0.3s ease;
}
.usage-fill.warning {
background: #f59e0b;
}
.usage-label {
display: flex;
justify-content: space-between;
font-size: 0.875rem;
color: #6b7280;
}
.plans-section {
margin-top: 2rem;
}
.plans-section h2 {
font-size: 1.25rem;
font-weight: 600;
margin: 0 0 1.5rem;
}
.plans-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1.5rem;
}
.plan-card {
background: white;
border: 2px solid #e5e7eb;
border-radius: 12px;
padding: 1.5rem;
position: relative;
transition: border-color 0.2s, box-shadow 0.2s;
}
.plan-card:hover {
border-color: #d1d5db;
}
.plan-card.featured {
border-color: #3b82f6;
box-shadow: 0 4px 20px rgba(59, 130, 246, 0.15);
}
.plan-card.current {
background: #f9fafb;
}
.popular-badge {
position: absolute;
top: -12px;
left: 50%;
transform: translateX(-50%);
background: #3b82f6;
color: white;
font-size: 0.75rem;
font-weight: 600;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
}
.plan-name {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
.plan-price {
margin-bottom: 1.5rem;
}
.plan-price .amount {
font-size: 2rem;
font-weight: 700;
}
.plan-price .period {
color: #6b7280;
}
.plan-features {
list-style: none;
padding: 0;
margin: 0 0 1.5rem;
}
.plan-features li {
padding: 0.5rem 0;
padding-left: 1.5rem;
position: relative;
color: #374151;
}
.plan-features li::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 16px;
height: 16px;
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='%2310b981'%3E%3Cpath fill-rule='evenodd' d='M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z' clip-rule='evenodd'/%3E%3C/svg%3E") no-repeat center;
}
.btn {
width: 100%;
padding: 0.75rem 1rem;
border-radius: 8px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
}
.btn-primary {
background: #3b82f6;
color: white;
border: none;
}
.btn-primary:hover:not(:disabled) {
background: #2563eb;
}
.btn-secondary {
background: white;
color: #374151;
border: 1px solid #d1d5db;
}
.btn-secondary:hover:not(:disabled) {
background: #f9fafb;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.error-message {
background: #fee2e2;
border: 1px solid #fecaca;
border-radius: 8px;
padding: 1rem;
color: #dc2626;
margin-top: 1rem;
}
@media (max-width: 768px) {
.billing {
padding: 1rem;
}
.current-plan .plan-header {
flex-direction: column;
gap: 1rem;
}
.plans-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -145,6 +145,26 @@
<p>No referrer data yet</p>
</div>
</section>
<section class="card">
<div class="card-header">
<h2>Top Countries</h2>
</div>
<div class="breakdown" v-if="analytics?.countryBreakdown?.length">
<div v-for="country in analytics.countryBreakdown.slice(0, 5)" :key="country.key" class="breakdown-item">
<div class="breakdown-bar country-bar" :style="{ width: getPercentage(country.count) + '%' }"></div>
<span class="breakdown-label">
<span class="country-flag">{{ getCountryFlag(country.key) }}</span>
{{ getCountryName(country.key) }}
</span>
<span class="breakdown-value">{{ country.count }}</span>
</div>
</div>
<div v-else class="empty-state">
<p>No geographic data yet</p>
<p class="hint">Country detection requires a GeoIP database</p>
</div>
</section>
</div>
</div>
</AppLayout>
@@ -189,6 +209,52 @@ const getPercentage = (value) => {
return Math.max((value / totalEvents.value) * 100, 5);
};
// Country code to flag emoji converter
const getCountryFlag = (countryCode) => {
if (!countryCode || countryCode.length !== 2) return '';
const codePoints = countryCode
.toUpperCase()
.split('')
.map(char => 127397 + char.charCodeAt(0));
return String.fromCodePoint(...codePoints);
};
// Country code to name mapping (common codes)
const countryNames = {
US: 'United States',
GB: 'United Kingdom',
CA: 'Canada',
AU: 'Australia',
DE: 'Germany',
FR: 'France',
JP: 'Japan',
CN: 'China',
IN: 'India',
BR: 'Brazil',
MX: 'Mexico',
ES: 'Spain',
IT: 'Italy',
NL: 'Netherlands',
SE: 'Sweden',
NO: 'Norway',
DK: 'Denmark',
FI: 'Finland',
PL: 'Poland',
RU: 'Russia',
KR: 'South Korea',
SG: 'Singapore',
NZ: 'New Zealand',
IE: 'Ireland',
CH: 'Switzerland',
AT: 'Austria',
BE: 'Belgium',
PT: 'Portugal',
};
const getCountryName = (countryCode) => {
return countryNames[countryCode] || countryCode;
};
onMounted(async () => {
await workspaceStore.fetchLinks();
await workspaceStore.fetchAnalytics(period.value);
@@ -417,10 +483,24 @@ watch(() => workspaceStore.currentWorkspaceId, async () => {
color: var(--muted);
}
.empty-state .hint {
font-size: 0.85rem;
margin-top: 8px;
}
.empty-state .cta {
margin-top: 16px;
}
.country-bar {
background: rgba(59, 130, 246, 0.15);
}
.country-flag {
margin-right: 8px;
font-size: 1.1em;
}
.cta.small {
padding: 10px 16px;
font-size: 0.9rem;

View File

@@ -0,0 +1,761 @@
<template>
<AppLayout>
<div class="domains">
<header class="page-header">
<div>
<h1>Custom Domains</h1>
<p class="subtitle">Use your own domain for branded short links</p>
</div>
<button class="btn btn-primary" @click="showAddModal = true" :disabled="!canAddDomain">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/>
</svg>
Add Domain
</button>
</header>
<div v-if="!canAddDomain" class="upgrade-banner">
<div class="banner-content">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="8" x2="12" y2="12"/>
<line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
<div>
<strong>Custom domains require a Pro or Business plan.</strong>
<p>Upgrade to use your own branded domains for short links.</p>
</div>
</div>
<router-link to="/billing" class="btn btn-primary">Upgrade</router-link>
</div>
<div v-if="loading" class="loading">Loading domains...</div>
<div v-else-if="domains.length === 0 && canAddDomain" class="empty-state">
<div class="empty-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="12" cy="12" r="10"/>
<line x1="2" y1="12" x2="22" y2="12"/>
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
</svg>
</div>
<h3>No custom domains yet</h3>
<p>Add your own domain to create branded short links.</p>
<button class="btn btn-primary" @click="showAddModal = true">Add Domain</button>
</div>
<div v-else-if="domains.length > 0" class="domains-list">
<div v-for="domain in domains" :key="domain.id" class="domain-card">
<div class="domain-info">
<div class="domain-status" :class="domain.status.toLowerCase()">
{{ domain.status }}
</div>
<h3 class="domain-name">{{ domain.hostname }}</h3>
<p class="domain-date">Added {{ formatDate(domain.createdAt) }}</p>
</div>
<div class="domain-actions">
<template v-if="domain.status === 'Pending'">
<button class="btn btn-secondary" @click="showVerifyInstructions(domain)">
Verify Domain
</button>
</template>
<button class="icon-btn danger" @click="confirmDelete(domain)" title="Delete">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3,6 5,6 21,6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
</button>
</div>
</div>
</div>
<!-- Add Domain Modal -->
<div v-if="showAddModal" class="modal-overlay" @click.self="showAddModal = false">
<div class="modal">
<div class="modal-header">
<h2>Add Custom Domain</h2>
<button class="close-btn" @click="showAddModal = false">&times;</button>
</div>
<form @submit.prevent="addDomain">
<div class="form-group">
<label for="hostname">Domain</label>
<input
id="hostname"
v-model="newDomain"
type="text"
placeholder="links.yourdomain.com"
required
autofocus
/>
<p class="form-hint">Enter the subdomain or domain you want to use for short links.</p>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" @click="showAddModal = false">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="adding">
{{ adding ? 'Adding...' : 'Add Domain' }}
</button>
</div>
</form>
</div>
</div>
<!-- Verification Instructions Modal -->
<div v-if="showVerifyModal" class="modal-overlay" @click.self="showVerifyModal = false">
<div class="modal modal-lg">
<div class="modal-header">
<h2>Verify {{ verifyingDomain?.hostname }}</h2>
<button class="close-btn" @click="showVerifyModal = false">&times;</button>
</div>
<div class="verify-content">
<div class="step">
<div class="step-number">1</div>
<div class="step-content">
<h4>Add DNS TXT Record</h4>
<p>Add the following TXT record to your domain's DNS settings:</p>
<div class="dns-record">
<div class="record-row">
<span class="record-label">Type:</span>
<span class="record-value">TXT</span>
</div>
<div class="record-row">
<span class="record-label">Host/Name:</span>
<span class="record-value">_trakqr-verification</span>
</div>
<div class="record-row">
<span class="record-label">Value:</span>
<div class="record-value token">
{{ verifyingDomain?.verificationToken }}
<button class="copy-btn" @click="copyToken" title="Copy">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
</button>
</div>
</div>
</div>
</div>
</div>
<div class="step">
<div class="step-number">2</div>
<div class="step-content">
<h4>Add CNAME Record</h4>
<p>Point your domain to our servers:</p>
<div class="dns-record">
<div class="record-row">
<span class="record-label">Type:</span>
<span class="record-value">CNAME</span>
</div>
<div class="record-row">
<span class="record-label">Host/Name:</span>
<span class="record-value">{{ verifyingDomain?.hostname }}</span>
</div>
<div class="record-row">
<span class="record-label">Value:</span>
<span class="record-value">cname.trakqr.com</span>
</div>
</div>
</div>
</div>
<div class="step">
<div class="step-number">3</div>
<div class="step-content">
<h4>Verify Ownership</h4>
<p>DNS changes can take up to 48 hours to propagate. Once ready, click verify.</p>
</div>
</div>
<div v-if="verifyError" class="error-message">{{ verifyError }}</div>
</div>
<div class="modal-actions">
<button class="btn btn-secondary" @click="showVerifyModal = false">Close</button>
<button class="btn btn-primary" @click="verifyDomain" :disabled="verifying">
{{ verifying ? 'Verifying...' : 'Verify Domain' }}
</button>
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div v-if="showDeleteModal" class="modal-overlay" @click.self="showDeleteModal = false">
<div class="modal modal-sm">
<div class="modal-header">
<h2>Delete Domain</h2>
<button class="close-btn" @click="showDeleteModal = false">&times;</button>
</div>
<p class="delete-warning">
Are you sure you want to delete <strong>{{ domainToDelete?.hostname }}</strong>?
Links using this domain will stop working.
</p>
<div class="modal-actions">
<button class="btn btn-secondary" @click="showDeleteModal = false">Cancel</button>
<button class="btn btn-danger" @click="deleteDomain" :disabled="deleting">
{{ deleting ? 'Deleting...' : 'Delete' }}
</button>
</div>
</div>
</div>
</div>
</AppLayout>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue';
import { useWorkspaceStore } from '../../stores/workspace';
import AppLayout from '../../components/layout/AppLayout.vue';
const workspaceStore = useWorkspaceStore();
const domains = computed(() => workspaceStore.domains);
const loading = ref(true);
const adding = ref(false);
const verifying = ref(false);
const deleting = ref(false);
const error = ref('');
const verifyError = ref('');
const showAddModal = ref(false);
const showVerifyModal = ref(false);
const showDeleteModal = ref(false);
const newDomain = ref('');
const verifyingDomain = ref(null);
const domainToDelete = ref(null);
const canAddDomain = computed(() => workspaceStore.canUseCustomDomains);
onMounted(async () => {
await loadDomains();
});
watch(() => workspaceStore.currentWorkspaceId, async () => {
if (workspaceStore.currentWorkspaceId) {
await loadDomains();
}
});
async function loadDomains() {
loading.value = true;
try {
await workspaceStore.fetchDomains();
} catch (err) {
error.value = err.message;
} finally {
loading.value = false;
}
}
async function addDomain() {
adding.value = true;
error.value = '';
try {
const domain = await workspaceStore.addDomain(newDomain.value);
showAddModal.value = false;
newDomain.value = '';
// Show verification instructions for the new domain
verifyingDomain.value = domain;
showVerifyModal.value = true;
} catch (err) {
error.value = err.message;
} finally {
adding.value = false;
}
}
function showVerifyInstructions(domain) {
verifyingDomain.value = domain;
verifyError.value = '';
showVerifyModal.value = true;
}
async function verifyDomain() {
verifying.value = true;
verifyError.value = '';
try {
await workspaceStore.verifyDomain(verifyingDomain.value.id);
showVerifyModal.value = false;
verifyingDomain.value = null;
} catch (err) {
verifyError.value = err.message;
} finally {
verifying.value = false;
}
}
function confirmDelete(domain) {
domainToDelete.value = domain;
showDeleteModal.value = true;
}
async function deleteDomain() {
deleting.value = true;
try {
await workspaceStore.deleteDomain(domainToDelete.value.id);
showDeleteModal.value = false;
domainToDelete.value = null;
} catch (err) {
error.value = err.message;
} finally {
deleting.value = false;
}
}
function copyToken() {
navigator.clipboard.writeText(verifyingDomain.value?.verificationToken);
}
function formatDate(dateStr) {
return new Date(dateStr).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
});
}
</script>
<style scoped>
.domains {
max-width: 900px;
margin: 0 auto;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 2rem;
}
.page-header h1 {
font-size: 1.75rem;
font-weight: 600;
margin: 0 0 0.5rem;
}
.subtitle {
color: #6b7280;
margin: 0;
}
.upgrade-banner {
display: flex;
align-items: center;
justify-content: space-between;
background: #fef3c7;
border: 1px solid #fcd34d;
border-radius: 12px;
padding: 1.25rem 1.5rem;
margin-bottom: 2rem;
}
.banner-content {
display: flex;
align-items: flex-start;
gap: 1rem;
color: #92400e;
}
.banner-content svg {
flex-shrink: 0;
margin-top: 2px;
}
.banner-content strong {
display: block;
margin-bottom: 0.25rem;
}
.banner-content p {
margin: 0;
font-size: 0.875rem;
opacity: 0.9;
}
.loading {
text-align: center;
padding: 3rem;
color: #6b7280;
}
.empty-state {
text-align: center;
padding: 4rem 2rem;
background: white;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.empty-icon {
color: #d1d5db;
margin-bottom: 1rem;
}
.empty-state h3 {
font-size: 1.25rem;
font-weight: 600;
margin: 0 0 0.5rem;
}
.empty-state p {
color: #6b7280;
margin: 0 0 1.5rem;
}
.domains-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.domain-card {
display: flex;
justify-content: space-between;
align-items: center;
background: white;
border-radius: 12px;
padding: 1.25rem 1.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.domain-status {
display: inline-block;
font-size: 0.75rem;
font-weight: 500;
padding: 0.25rem 0.5rem;
border-radius: 4px;
margin-bottom: 0.5rem;
}
.domain-status.pending {
background: #fef3c7;
color: #92400e;
}
.domain-status.verified {
background: #d1fae5;
color: #065f46;
}
.domain-name {
font-size: 1.125rem;
font-weight: 600;
margin: 0 0 0.25rem;
}
.domain-date {
font-size: 0.875rem;
color: #6b7280;
margin: 0;
}
.domain-actions {
display: flex;
align-items: center;
gap: 0.75rem;
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1rem;
border-radius: 8px;
font-weight: 500;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s;
border: none;
text-decoration: none;
}
.btn-primary {
background: #3b82f6;
color: white;
}
.btn-primary:hover:not(:disabled) {
background: #2563eb;
}
.btn-secondary {
background: white;
color: #374151;
border: 1px solid #d1d5db;
}
.btn-secondary:hover:not(:disabled) {
background: #f9fafb;
}
.btn-danger {
background: #dc2626;
color: white;
}
.btn-danger:hover:not(:disabled) {
background: #b91c1c;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.icon-btn {
width: 36px;
height: 36px;
border: none;
background: transparent;
border-radius: 8px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: #6b7280;
transition: all 0.2s;
}
.icon-btn:hover {
background: #f3f4f6;
}
.icon-btn.danger:hover {
background: #fee2e2;
color: #dc2626;
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: white;
border-radius: 12px;
width: 100%;
max-width: 480px;
max-height: 90vh;
overflow-y: auto;
}
.modal-sm {
max-width: 400px;
}
.modal-lg {
max-width: 600px;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid #e5e7eb;
}
.modal-header h2 {
font-size: 1.125rem;
font-weight: 600;
margin: 0;
}
.close-btn {
width: 32px;
height: 32px;
border: none;
background: transparent;
font-size: 1.5rem;
color: #6b7280;
cursor: pointer;
border-radius: 6px;
}
.close-btn:hover {
background: #f3f4f6;
}
.modal form {
padding: 1.5rem;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 0.5rem;
}
.form-group input {
width: 100%;
padding: 0.625rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 0.875rem;
}
.form-group input:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.form-hint {
font-size: 0.75rem;
color: #6b7280;
margin: 0.5rem 0 0;
}
.verify-content {
padding: 1.5rem;
}
.step {
display: flex;
gap: 1rem;
margin-bottom: 1.5rem;
}
.step:last-of-type {
margin-bottom: 0;
}
.step-number {
width: 28px;
height: 28px;
background: #3b82f6;
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.875rem;
font-weight: 600;
flex-shrink: 0;
}
.step-content h4 {
font-size: 1rem;
font-weight: 600;
margin: 0 0 0.5rem;
}
.step-content p {
font-size: 0.875rem;
color: #6b7280;
margin: 0 0 1rem;
}
.dns-record {
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 1rem;
}
.record-row {
display: flex;
margin-bottom: 0.5rem;
}
.record-row:last-child {
margin-bottom: 0;
}
.record-label {
width: 100px;
font-size: 0.75rem;
color: #6b7280;
flex-shrink: 0;
}
.record-value {
font-size: 0.875rem;
font-family: monospace;
word-break: break-all;
}
.record-value.token {
display: flex;
align-items: center;
gap: 0.5rem;
}
.copy-btn {
background: none;
border: none;
padding: 0.25rem;
cursor: pointer;
color: #6b7280;
border-radius: 4px;
}
.copy-btn:hover {
background: #e5e7eb;
color: #374151;
}
.delete-warning {
padding: 1.5rem;
color: #374151;
line-height: 1.5;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding: 1rem 1.5rem;
border-top: 1px solid #e5e7eb;
}
.error-message {
background: #fee2e2;
border: 1px solid #fecaca;
border-radius: 8px;
padding: 0.75rem;
color: #dc2626;
font-size: 0.875rem;
margin: 1rem 0 0;
}
@media (max-width: 640px) {
.page-header {
flex-direction: column;
gap: 1rem;
}
.upgrade-banner {
flex-direction: column;
gap: 1rem;
text-align: center;
}
.banner-content {
flex-direction: column;
align-items: center;
}
.domain-card {
flex-direction: column;
align-items: flex-start;
gap: 1rem;
}
}
</style>

View File

@@ -97,6 +97,23 @@
<p>No referrer data yet</p>
</div>
</section>
<section class="card">
<h2>Countries</h2>
<div class="breakdown" v-if="analytics?.countryBreakdown?.length">
<div v-for="country in analytics.countryBreakdown" :key="country.key" class="breakdown-item">
<div class="breakdown-bar country-bar" :style="{ width: getPercentage(country.count) + '%' }"></div>
<span class="breakdown-label">
<span class="country-flag">{{ getCountryFlag(country.key) }}</span>
{{ getCountryName(country.key) }}
</span>
<span class="breakdown-value">{{ country.count }}</span>
</div>
</div>
<div v-else class="empty-state">
<p>No geographic data yet</p>
</div>
</section>
</div>
<section class="card link-info-card">
@@ -211,6 +228,28 @@ const copyToClipboard = async () => {
}
};
// Country code to flag emoji converter
const getCountryFlag = (countryCode) => {
if (!countryCode || countryCode.length !== 2) return '';
const codePoints = countryCode
.toUpperCase()
.split('')
.map(char => 127397 + char.charCodeAt(0));
return String.fromCodePoint(...codePoints);
};
// Country code to name mapping
const countryNames = {
US: 'United States', GB: 'United Kingdom', CA: 'Canada', AU: 'Australia',
DE: 'Germany', FR: 'France', JP: 'Japan', CN: 'China', IN: 'India',
BR: 'Brazil', MX: 'Mexico', ES: 'Spain', IT: 'Italy', NL: 'Netherlands',
SE: 'Sweden', NO: 'Norway', DK: 'Denmark', FI: 'Finland', PL: 'Poland',
RU: 'Russia', KR: 'South Korea', SG: 'Singapore', NZ: 'New Zealand',
IE: 'Ireland', CH: 'Switzerland', AT: 'Austria', BE: 'Belgium', PT: 'Portugal',
};
const getCountryName = (countryCode) => countryNames[countryCode] || countryCode;
onMounted(async () => {
await fetchData();
});
@@ -440,6 +479,15 @@ onMounted(async () => {
color: var(--muted);
}
.country-bar {
background: rgba(59, 130, 246, 0.15);
}
.country-flag {
margin-right: 8px;
font-size: 1.1em;
}
.loading {
text-align: center;
padding: 60px;

View File

@@ -6,13 +6,35 @@
<h1>Links</h1>
<p class="subtitle">Manage your short links</p>
</div>
<button @click="showCreateModal = true" class="cta">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/>
</svg>
Create Link
</button>
<div class="view-toggle">
<button :class="{ active: !showDeleted }" @click="showDeleted = false">
Active
</button>
<button :class="{ active: showDeleted }" @click="toggleDeleted">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
Trash
</button>
</div>
<div class="header-actions">
<button @click="showBulkModal = true" class="ghost">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
Bulk Import
</button>
<button @click="showCreateModal = true" class="cta">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/>
</svg>
Create Link
</button>
</div>
</header>
<div class="links-list" v-if="workspaceStore.links.length">
@@ -125,6 +147,60 @@
<p class="hint">Leave empty for auto-generated slug</p>
</div>
<!-- UTM Builder -->
<div class="utm-section">
<button type="button" class="utm-toggle" @click="showUtmBuilder = !showUtmBuilder">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 5v14M5 12h14"/>
</svg>
{{ showUtmBuilder ? 'Hide' : 'Add' }} UTM Parameters
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" :class="{ rotated: showUtmBuilder }">
<path d="M6 9l6 6 6-6"/>
</svg>
</button>
<div v-if="showUtmBuilder" class="utm-fields">
<div class="utm-presets">
<span class="utm-preset-label">Presets:</span>
<button type="button" @click="applyUtmPreset('google')" class="utm-preset-btn">Google Ads</button>
<button type="button" @click="applyUtmPreset('facebook')" class="utm-preset-btn">Facebook</button>
<button type="button" @click="applyUtmPreset('email')" class="utm-preset-btn">Email</button>
<button type="button" @click="applyUtmPreset('social')" class="utm-preset-btn">Social</button>
</div>
<div class="form-row">
<div class="form-group">
<label for="utm_source">Source</label>
<input id="utm_source" v-model="utmParams.source" type="text" placeholder="google, facebook, newsletter" />
</div>
<div class="form-group">
<label for="utm_medium">Medium</label>
<input id="utm_medium" v-model="utmParams.medium" type="text" placeholder="cpc, social, email" />
</div>
</div>
<div class="form-group">
<label for="utm_campaign">Campaign</label>
<input id="utm_campaign" v-model="utmParams.campaign" type="text" placeholder="summer_sale, product_launch" />
</div>
<div class="form-row">
<div class="form-group">
<label for="utm_term">Term (optional)</label>
<input id="utm_term" v-model="utmParams.term" type="text" placeholder="keywords" />
</div>
<div class="form-group">
<label for="utm_content">Content (optional)</label>
<input id="utm_content" v-model="utmParams.content" type="text" placeholder="banner, textlink" />
</div>
</div>
<p v-if="utmPreview" class="utm-preview">
<strong>Preview:</strong> {{ utmPreview }}
</p>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="expiresAt">Expires (optional)</label>
@@ -171,24 +247,91 @@
</div>
</div>
</div>
<!-- Bulk Import Modal -->
<div v-if="showBulkModal" class="modal-overlay" @click.self="closeBulkModal">
<div class="modal modal-lg">
<div class="modal-header">
<h2>Bulk Import Links</h2>
<button @click="closeBulkModal" class="close-btn">&times;</button>
</div>
<div class="bulk-content">
<p class="bulk-instructions">Paste URLs below, one per line. You can optionally add a title after the URL separated by a comma.</p>
<p class="bulk-example">Example: https://example.com, My Link Title</p>
<textarea
v-model="bulkUrls"
placeholder="https://example.com&#10;https://another-site.com, My Page&#10;https://third-url.com"
rows="10"
class="bulk-textarea"
></textarea>
<div class="bulk-stats">
<span>{{ parsedBulkLinks.length }} URLs detected</span>
</div>
<div v-if="bulkError" class="error-message">{{ bulkError }}</div>
<div v-if="bulkResults" class="bulk-results">
<div v-if="bulkResults.created.length" class="bulk-success">
<strong>{{ bulkResults.created.length }}</strong> links created successfully
</div>
<div v-if="bulkResults.errors.length" class="bulk-errors">
<strong>{{ bulkResults.errors.length }}</strong> errors:
<ul>
<li v-for="(err, i) in bulkResults.errors.slice(0, 5)" :key="i">
{{ err.url }}: {{ err.error }}
</li>
<li v-if="bulkResults.errors.length > 5">
...and {{ bulkResults.errors.length - 5 }} more
</li>
</ul>
</div>
</div>
</div>
<div class="modal-actions">
<button @click="closeBulkModal" class="ghost">{{ bulkResults ? 'Close' : 'Cancel' }}</button>
<button
v-if="!bulkResults"
@click="importBulkLinks"
class="cta"
:disabled="bulkImporting || parsedBulkLinks.length === 0"
>
{{ bulkImporting ? 'Importing...' : `Import ${parsedBulkLinks.length} Links` }}
</button>
</div>
</div>
</div>
</div>
</AppLayout>
</template>
<script setup>
import { ref, onMounted, watch } from 'vue';
import { ref, computed, onMounted, watch } from 'vue';
import AppLayout from '../../components/layout/AppLayout.vue';
import { useWorkspaceStore } from '../../stores/workspace';
import { api } from '../../api/client';
const workspaceStore = useWorkspaceStore();
const showCreateModal = ref(false);
const showDeleteModal = ref(false);
const showBulkModal = ref(false);
const showUtmBuilder = ref(false);
const showDeleted = ref(false);
const editingLink = ref(null);
const deletingLink = ref(null);
const saving = ref(false);
const formError = ref('');
// Bulk import state
const bulkUrls = ref('');
const bulkImporting = ref(false);
const bulkError = ref('');
const bulkResults = ref(null);
const formData = ref({
destinationUrl: '',
title: '',
@@ -197,6 +340,44 @@ const formData = ref({
password: '',
});
const utmParams = ref({
source: '',
medium: '',
campaign: '',
term: '',
content: '',
});
const utmPresets = {
google: { source: 'google', medium: 'cpc', campaign: '' },
facebook: { source: 'facebook', medium: 'social', campaign: '' },
email: { source: 'newsletter', medium: 'email', campaign: '' },
social: { source: 'twitter', medium: 'social', campaign: '' },
};
const applyUtmPreset = (preset) => {
const p = utmPresets[preset];
if (p) {
utmParams.value = { ...utmParams.value, ...p };
}
};
const utmPreview = computed(() => {
const params = [];
if (utmParams.value.source) params.push(`utm_source=${utmParams.value.source}`);
if (utmParams.value.medium) params.push(`utm_medium=${utmParams.value.medium}`);
if (utmParams.value.campaign) params.push(`utm_campaign=${utmParams.value.campaign}`);
if (utmParams.value.term) params.push(`utm_term=${utmParams.value.term}`);
if (utmParams.value.content) params.push(`utm_content=${utmParams.value.content}`);
return params.length ? '?' + params.join('&') : '';
});
const buildUrlWithUtm = (baseUrl) => {
if (!utmPreview.value) return baseUrl;
const separator = baseUrl.includes('?') ? '&' : '?';
return baseUrl + separator + utmPreview.value.substring(1);
};
const resetForm = () => {
formData.value = {
destinationUrl: '',
@@ -205,6 +386,14 @@ const resetForm = () => {
expiresAt: '',
password: '',
};
utmParams.value = {
source: '',
medium: '',
campaign: '',
term: '',
content: '',
};
showUtmBuilder.value = false;
formError.value = '';
editingLink.value = null;
};
@@ -231,8 +420,11 @@ const saveLink = async () => {
formError.value = '';
try {
// Build destination URL with UTM parameters
const finalUrl = buildUrlWithUtm(formData.value.destinationUrl);
const data = {
destinationUrl: formData.value.destinationUrl,
destinationUrl: finalUrl,
title: formData.value.title || null,
expiresAt: formData.value.expiresAt ? new Date(formData.value.expiresAt).toISOString() : null,
password: formData.value.password || null,
@@ -287,15 +479,84 @@ const copyToClipboard = async (text) => {
}
};
// Bulk import functions
const parsedBulkLinks = computed(() => {
if (!bulkUrls.value.trim()) return [];
return bulkUrls.value
.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0)
.map(line => {
const parts = line.split(',');
const url = parts[0].trim();
const title = parts.length > 1 ? parts.slice(1).join(',').trim() : null;
return { destinationUrl: url, title };
})
.filter(item => {
try {
new URL(item.destinationUrl);
return true;
} catch {
return false;
}
});
});
const closeBulkModal = () => {
showBulkModal.value = false;
bulkUrls.value = '';
bulkError.value = '';
bulkResults.value = null;
};
const importBulkLinks = async () => {
if (parsedBulkLinks.value.length === 0) return;
bulkImporting.value = true;
bulkError.value = '';
try {
const workspaceId = workspaceStore.currentWorkspaceId;
const result = await api.bulkCreateLinks(workspaceId, parsedBulkLinks.value);
bulkResults.value = result;
// Refresh links list
await workspaceStore.fetchLinks();
} catch (err) {
bulkError.value = err.message;
} finally {
bulkImporting.value = false;
}
};
const toggleDeleted = async () => {
showDeleted.value = true;
await workspaceStore.fetchLinks({ includeDeleted: true });
};
const restoreLink = async (link) => {
try {
await api.restoreLink(workspaceStore.currentWorkspaceId, link.id);
await workspaceStore.fetchLinks({ includeDeleted: showDeleted.value });
} catch (err) {
console.error('Failed to restore link:', err);
}
};
onMounted(async () => {
await workspaceStore.fetchLinks();
});
watch(() => workspaceStore.currentWorkspaceId, async () => {
if (workspaceStore.currentWorkspaceId) {
await workspaceStore.fetchLinks();
await workspaceStore.fetchLinks({ includeDeleted: showDeleted.value });
}
});
watch(showDeleted, async (value) => {
await workspaceStore.fetchLinks({ includeDeleted: value });
});
</script>
<style scoped>
@@ -581,6 +842,176 @@ watch(() => workspaceStore.currentWorkspaceId, async () => {
background: #dc2626;
}
/* UTM Builder styles */
.utm-section {
border: 1px solid var(--line);
border-radius: 12px;
overflow: hidden;
}
.utm-toggle {
width: 100%;
padding: 12px 16px;
display: flex;
align-items: center;
gap: 8px;
background: var(--bg);
border: none;
font-size: 0.9rem;
font-weight: 500;
color: var(--muted);
cursor: pointer;
transition: all 0.15s ease;
}
.utm-toggle:hover {
background: var(--line);
color: var(--ink);
}
.utm-toggle svg:last-child {
margin-left: auto;
transition: transform 0.2s ease;
}
.utm-toggle svg.rotated {
transform: rotate(180deg);
}
.utm-fields {
padding: 16px;
display: flex;
flex-direction: column;
gap: 16px;
border-top: 1px solid var(--line);
}
.utm-presets {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.utm-preset-label {
font-size: 0.85rem;
color: var(--muted);
}
.utm-preset-btn {
padding: 6px 12px;
background: var(--bg);
border: 1px solid var(--line);
border-radius: 8px;
font-size: 0.8rem;
font-weight: 500;
color: var(--ink);
cursor: pointer;
transition: all 0.15s ease;
}
.utm-preset-btn:hover {
background: var(--accent);
color: white;
border-color: var(--accent);
}
.utm-preview {
padding: 12px;
background: var(--bg);
border-radius: 8px;
font-size: 0.85rem;
color: var(--muted);
word-break: break-all;
}
.utm-preview strong {
color: var(--ink);
}
/* Bulk import styles */
.header-actions {
display: flex;
gap: 12px;
}
.modal-lg {
max-width: 600px;
}
.bulk-content {
display: flex;
flex-direction: column;
gap: 16px;
}
.bulk-instructions {
color: var(--ink);
font-size: 0.95rem;
}
.bulk-example {
color: var(--muted);
font-size: 0.85rem;
font-style: italic;
}
.bulk-textarea {
width: 100%;
padding: 16px;
border: 1px solid var(--line);
border-radius: 12px;
font-size: 0.9rem;
font-family: monospace;
resize: vertical;
line-height: 1.6;
}
.bulk-textarea:focus {
outline: none;
border-color: var(--accent);
}
.bulk-stats {
display: flex;
align-items: center;
gap: 16px;
font-size: 0.875rem;
color: var(--muted);
}
.bulk-results {
display: flex;
flex-direction: column;
gap: 12px;
}
.bulk-success {
padding: 12px 16px;
background: #dcfce7;
border-radius: 10px;
color: #16a34a;
font-size: 0.9rem;
}
.bulk-errors {
padding: 12px 16px;
background: #fef2f2;
border-radius: 10px;
color: #dc2626;
font-size: 0.9rem;
}
.bulk-errors ul {
margin: 8px 0 0;
padding-left: 20px;
}
.bulk-errors li {
margin-top: 4px;
font-size: 0.85rem;
}
@media (max-width: 600px) {
.link-card {
flex-direction: column;

View File

@@ -0,0 +1,566 @@
<template>
<AppLayout>
<div class="projects">
<header class="page-header">
<div>
<h1>Projects</h1>
<p class="subtitle">Organize your links and QR codes into projects</p>
</div>
<button class="btn btn-primary" @click="showCreateModal = true">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/>
</svg>
New Project
</button>
</header>
<div v-if="loading" class="loading">Loading projects...</div>
<div v-else-if="projects.length === 0" class="empty-state">
<div class="empty-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
</svg>
</div>
<h3>No projects yet</h3>
<p>Create your first project to organize your links and QR codes.</p>
<button class="btn btn-primary" @click="showCreateModal = true">Create Project</button>
</div>
<div v-else class="projects-grid">
<div v-for="project in projects" :key="project.id" class="project-card">
<div class="project-header">
<div class="project-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
</svg>
</div>
<div class="project-actions">
<button class="icon-btn" @click="editProject(project)" title="Edit">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
</button>
<button class="icon-btn danger" @click="confirmDelete(project)" title="Delete">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3,6 5,6 21,6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
</button>
</div>
</div>
<h3 class="project-name">{{ project.name }}</h3>
<p class="project-description">{{ project.description || 'No description' }}</p>
<div class="project-meta">
<span class="meta-item">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
</svg>
{{ project.linkCount || 0 }} links
</span>
<span class="meta-item">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<rect x="7" y="7" width="3" height="3"/>
<rect x="14" y="7" width="3" height="3"/>
<rect x="7" y="14" width="3" height="3"/>
<rect x="14" y="14" width="3" height="3"/>
</svg>
{{ project.qrCodeCount || 0 }} QR codes
</span>
<span class="meta-item">
Created {{ formatDate(project.createdAt) }}
</span>
</div>
</div>
</div>
<!-- Create/Edit Modal -->
<div v-if="showCreateModal || showEditModal" class="modal-overlay" @click.self="closeModals">
<div class="modal">
<div class="modal-header">
<h2>{{ showEditModal ? 'Edit Project' : 'New Project' }}</h2>
<button class="close-btn" @click="closeModals">&times;</button>
</div>
<form @submit.prevent="showEditModal ? updateProject() : createProject()">
<div class="form-group">
<label for="name">Project Name</label>
<input
id="name"
v-model="form.name"
type="text"
placeholder="e.g., Marketing Campaign Q1"
required
autofocus
/>
</div>
<div class="form-group">
<label for="description">Description (optional)</label>
<textarea
id="description"
v-model="form.description"
placeholder="Describe what this project is for..."
rows="3"
></textarea>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" @click="closeModals">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="saving">
{{ saving ? 'Saving...' : (showEditModal ? 'Update' : 'Create') }}
</button>
</div>
</form>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div v-if="showDeleteModal" class="modal-overlay" @click.self="showDeleteModal = false">
<div class="modal modal-sm">
<div class="modal-header">
<h2>Delete Project</h2>
<button class="close-btn" @click="showDeleteModal = false">&times;</button>
</div>
<p class="delete-warning">
Are you sure you want to delete <strong>{{ projectToDelete?.name }}</strong>?
This action cannot be undone.
</p>
<div class="modal-actions">
<button class="btn btn-secondary" @click="showDeleteModal = false">Cancel</button>
<button class="btn btn-danger" @click="deleteProject" :disabled="deleting">
{{ deleting ? 'Deleting...' : 'Delete' }}
</button>
</div>
</div>
</div>
</div>
</AppLayout>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue';
import { useWorkspaceStore } from '../../stores/workspace';
import AppLayout from '../../components/layout/AppLayout.vue';
const workspaceStore = useWorkspaceStore();
const projects = computed(() => workspaceStore.projects);
const loading = ref(true);
const saving = ref(false);
const deleting = ref(false);
const error = ref('');
const showCreateModal = ref(false);
const showEditModal = ref(false);
const showDeleteModal = ref(false);
const form = ref({ name: '', description: '' });
const editingProject = ref(null);
const projectToDelete = ref(null);
onMounted(async () => {
await loadProjects();
});
watch(() => workspaceStore.currentWorkspaceId, async () => {
if (workspaceStore.currentWorkspaceId) {
await loadProjects();
}
});
async function loadProjects() {
loading.value = true;
try {
await workspaceStore.fetchProjects();
} catch (err) {
error.value = err.message;
} finally {
loading.value = false;
}
}
async function createProject() {
saving.value = true;
error.value = '';
try {
await workspaceStore.createProject(form.value.name, form.value.description);
closeModals();
} catch (err) {
error.value = err.message;
} finally {
saving.value = false;
}
}
function editProject(project) {
editingProject.value = project;
form.value = { name: project.name, description: project.description || '' };
showEditModal.value = true;
}
async function updateProject() {
saving.value = true;
error.value = '';
try {
await workspaceStore.updateProject(editingProject.value.id, {
name: form.value.name,
description: form.value.description
});
closeModals();
} catch (err) {
error.value = err.message;
} finally {
saving.value = false;
}
}
function confirmDelete(project) {
projectToDelete.value = project;
showDeleteModal.value = true;
}
async function deleteProject() {
deleting.value = true;
try {
await workspaceStore.deleteProject(projectToDelete.value.id);
showDeleteModal.value = false;
projectToDelete.value = null;
} catch (err) {
error.value = err.message;
} finally {
deleting.value = false;
}
}
function closeModals() {
showCreateModal.value = false;
showEditModal.value = false;
form.value = { name: '', description: '' };
editingProject.value = null;
error.value = '';
}
function formatDate(dateStr) {
return new Date(dateStr).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
});
}
</script>
<style scoped>
.projects {
max-width: 1200px;
margin: 0 auto;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 2rem;
}
.page-header h1 {
font-size: 1.75rem;
font-weight: 600;
margin: 0 0 0.5rem;
}
.subtitle {
color: #6b7280;
margin: 0;
}
.loading {
text-align: center;
padding: 3rem;
color: #6b7280;
}
.empty-state {
text-align: center;
padding: 4rem 2rem;
background: white;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.empty-icon {
color: #d1d5db;
margin-bottom: 1rem;
}
.empty-state h3 {
font-size: 1.25rem;
font-weight: 600;
margin: 0 0 0.5rem;
}
.empty-state p {
color: #6b7280;
margin: 0 0 1.5rem;
}
.projects-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1.5rem;
}
.project-card {
background: white;
border-radius: 12px;
padding: 1.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
transition: box-shadow 0.2s;
}
.project-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.project-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1rem;
}
.project-icon {
width: 40px;
height: 40px;
background: #f3f4f6;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
color: #6b7280;
}
.project-actions {
display: flex;
gap: 0.5rem;
}
.icon-btn {
width: 32px;
height: 32px;
border: none;
background: transparent;
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: #6b7280;
transition: all 0.2s;
}
.icon-btn:hover {
background: #f3f4f6;
color: #374151;
}
.icon-btn.danger:hover {
background: #fee2e2;
color: #dc2626;
}
.project-name {
font-size: 1.125rem;
font-weight: 600;
margin: 0 0 0.5rem;
}
.project-description {
color: #6b7280;
font-size: 0.875rem;
margin: 0 0 1rem;
line-height: 1.5;
}
.project-meta {
display: flex;
gap: 1rem;
font-size: 0.75rem;
color: #9ca3af;
}
.meta-item {
display: flex;
align-items: center;
gap: 0.25rem;
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1rem;
border-radius: 8px;
font-weight: 500;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s;
border: none;
}
.btn-primary {
background: #3b82f6;
color: white;
}
.btn-primary:hover:not(:disabled) {
background: #2563eb;
}
.btn-secondary {
background: white;
color: #374151;
border: 1px solid #d1d5db;
}
.btn-secondary:hover:not(:disabled) {
background: #f9fafb;
}
.btn-danger {
background: #dc2626;
color: white;
}
.btn-danger:hover:not(:disabled) {
background: #b91c1c;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: white;
border-radius: 12px;
width: 100%;
max-width: 480px;
max-height: 90vh;
overflow-y: auto;
}
.modal-sm {
max-width: 400px;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid #e5e7eb;
}
.modal-header h2 {
font-size: 1.125rem;
font-weight: 600;
margin: 0;
}
.close-btn {
width: 32px;
height: 32px;
border: none;
background: transparent;
font-size: 1.5rem;
color: #6b7280;
cursor: pointer;
border-radius: 6px;
}
.close-btn:hover {
background: #f3f4f6;
}
.modal form {
padding: 1.5rem;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 0.5rem;
}
.form-group input,
.form-group textarea {
width: 100%;
padding: 0.625rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 0.875rem;
font-family: inherit;
}
.form-group input:focus,
.form-group textarea:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.delete-warning {
padding: 1.5rem;
color: #374151;
line-height: 1.5;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding: 1rem 1.5rem;
border-top: 1px solid #e5e7eb;
}
.error-message {
background: #fee2e2;
border: 1px solid #fecaca;
border-radius: 8px;
padding: 0.75rem;
color: #dc2626;
font-size: 0.875rem;
margin-bottom: 1rem;
}
@media (max-width: 640px) {
.page-header {
flex-direction: column;
gap: 1rem;
}
.projects-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -142,6 +142,84 @@
/>
<span class="range-value">{{ formData.style.quietZone }} modules</span>
</div>
<div class="form-group">
<label>Module Shape</label>
<div class="shape-selector">
<button
v-for="shape in moduleShapes"
:key="shape.value"
:class="{ active: formData.style.moduleShape === shape.value }"
@click="formData.style.moduleShape = shape.value"
class="shape-btn"
>
<span class="shape-icon" :class="shape.value.toLowerCase()"></span>
{{ shape.label }}
</button>
</div>
</div>
<div class="form-group">
<label>Eye Shape</label>
<div class="shape-selector">
<button
v-for="shape in eyeShapes"
:key="shape.value"
:class="{ active: formData.style.eyeShape === shape.value }"
@click="formData.style.eyeShape = shape.value"
class="shape-btn"
>
<span class="shape-icon" :class="shape.value.toLowerCase()"></span>
{{ shape.label }}
</button>
</div>
<p class="hint">Eyes are the large corner patterns for scanner detection</p>
</div>
</div>
<div class="settings-card">
<h2>Logo</h2>
<div class="logo-section">
<div class="current-logo" v-if="formData.logoAssetId">
<img :src="getLogoUrl(formData.logoAssetId)" alt="Current logo" class="logo-preview" />
<button @click="removeLogo" class="remove-logo-btn" title="Remove logo">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<div class="logo-upload" v-else>
<label class="upload-btn">
<input
type="file"
@change="handleLogoUpload"
accept="image/png,image/jpeg,image/svg+xml"
hidden
/>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
Upload Logo
</label>
</div>
<div v-if="assets.length > 0 && !formData.logoAssetId" class="existing-logos">
<p class="hint">Or select from existing:</p>
<div class="logos-grid">
<button
v-for="asset in assets"
:key="asset.id"
@click="selectLogo(asset)"
class="logo-option"
>
<img :src="asset.url" :alt="asset.filename" />
</button>
</div>
</div>
<p class="hint" v-if="formData.logoAssetId">Use high error correction (H) for better logo visibility</p>
</div>
</div>
<div class="settings-card">
@@ -188,42 +266,96 @@ const saving = ref(false);
const error = ref('');
const previewUrl = ref('');
const previewTimeout = ref(null);
const uploadingLogo = ref(false);
const assets = computed(() => workspaceStore.assets.filter(a => a.type === 'Logo'));
const formData = ref({
name: '',
linkId: '',
logoAssetId: null,
style: {
foregroundColor: '#000000',
backgroundColor: '#ffffff',
errorCorrectionLevel: 'M',
quietZone: 4,
moduleShape: 'Square',
eyeShape: 'Square',
},
});
const moduleShapes = [
{ value: 'Square', label: 'Square' },
{ value: 'Rounded', label: 'Rounded' },
{ value: 'Dots', label: 'Dots' },
];
const eyeShapes = [
{ value: 'Square', label: 'Square' },
{ value: 'Rounded', label: 'Rounded' },
{ value: 'Circle', label: 'Circle' },
];
const getLogoUrl = (assetId) => {
const asset = workspaceStore.assets.find(a => a.id === assetId);
return asset?.url || '';
};
const handleLogoUpload = async (event) => {
const file = event.target.files[0];
if (!file) return;
uploadingLogo.value = true;
try {
const asset = await workspaceStore.uploadAsset(file);
formData.value.logoAssetId = asset.id;
// If adding a logo, suggest high error correction
if (formData.value.style.errorCorrectionLevel !== 'H') {
formData.value.style.errorCorrectionLevel = 'H';
}
} catch (err) {
error.value = 'Failed to upload logo: ' + err.message;
} finally {
uploadingLogo.value = false;
event.target.value = '';
}
};
const selectLogo = (asset) => {
formData.value.logoAssetId = asset.id;
// If adding a logo, suggest high error correction
if (formData.value.style.errorCorrectionLevel !== 'H') {
formData.value.style.errorCorrectionLevel = 'H';
}
};
const removeLogo = () => {
formData.value.logoAssetId = null;
};
const presets = [
{
name: 'Classic',
style: { foregroundColor: '#000000', backgroundColor: '#ffffff', errorCorrectionLevel: 'M', quietZone: 4 }
style: { foregroundColor: '#000000', backgroundColor: '#ffffff', errorCorrectionLevel: 'M', quietZone: 4, moduleShape: 'Square', eyeShape: 'Square' }
},
{
name: 'Dark',
style: { foregroundColor: '#ffffff', backgroundColor: '#1a1a1a', errorCorrectionLevel: 'M', quietZone: 4 }
name: 'Modern',
style: { foregroundColor: '#1a1a1a', backgroundColor: '#ffffff', errorCorrectionLevel: 'M', quietZone: 4, moduleShape: 'Rounded', eyeShape: 'Rounded' }
},
{
name: 'Dots',
style: { foregroundColor: '#000000', backgroundColor: '#ffffff', errorCorrectionLevel: 'H', quietZone: 4, moduleShape: 'Dots', eyeShape: 'Circle' }
},
{
name: 'Ocean',
style: { foregroundColor: '#0369a1', backgroundColor: '#e0f2fe', errorCorrectionLevel: 'M', quietZone: 4 }
style: { foregroundColor: '#0369a1', backgroundColor: '#e0f2fe', errorCorrectionLevel: 'M', quietZone: 4, moduleShape: 'Rounded', eyeShape: 'Rounded' }
},
{
name: 'Forest',
style: { foregroundColor: '#166534', backgroundColor: '#dcfce7', errorCorrectionLevel: 'M', quietZone: 4 }
},
{
name: 'Sunset',
style: { foregroundColor: '#c2410c', backgroundColor: '#fff7ed', errorCorrectionLevel: 'M', quietZone: 4 }
style: { foregroundColor: '#166534', backgroundColor: '#dcfce7', errorCorrectionLevel: 'M', quietZone: 4, moduleShape: 'Square', eyeShape: 'Square' }
},
{
name: 'Purple',
style: { foregroundColor: '#7c3aed', backgroundColor: '#f3e8ff', errorCorrectionLevel: 'M', quietZone: 4 }
style: { foregroundColor: '#7c3aed', backgroundColor: '#f3e8ff', errorCorrectionLevel: 'M', quietZone: 4, moduleShape: 'Dots', eyeShape: 'Circle' }
},
];
@@ -258,11 +390,16 @@ const save = async () => {
try {
const data = {
name: formData.value.name,
linkId: formData.value.linkId,
shortLinkId: formData.value.linkId,
logoAssetId: formData.value.logoAssetId,
style: formData.value.style,
};
if (isEditing.value) {
// For updates, handle logo removal
if (!formData.value.logoAssetId) {
data.removeLogo = true;
}
await workspaceStore.updateQRCode(route.params.id, data);
} else {
await workspaceStore.createQRCode(data);
@@ -291,15 +428,19 @@ const loadExisting = async () => {
try {
const qr = await api.getQRCode(workspaceStore.currentWorkspaceId, route.params.id);
const defaultStyle = {
foregroundColor: '#000000',
backgroundColor: '#ffffff',
errorCorrectionLevel: 'M',
quietZone: 4,
moduleShape: 'Square',
eyeShape: 'Square',
};
formData.value = {
name: qr.name,
linkId: qr.linkId,
style: qr.style || {
foregroundColor: '#000000',
backgroundColor: '#ffffff',
errorCorrectionLevel: 'M',
quietZone: 4,
},
linkId: qr.shortLinkId || qr.linkId,
logoAssetId: qr.logoAssetId || null,
style: { ...defaultStyle, ...qr.style },
};
await fetchPreview();
} catch (err) {
@@ -312,8 +453,16 @@ watch(() => formData.value.style, () => {
previewTimeout.value = setTimeout(fetchPreview, 500);
}, { deep: true });
watch(() => formData.value.logoAssetId, () => {
if (previewTimeout.value) clearTimeout(previewTimeout.value);
previewTimeout.value = setTimeout(fetchPreview, 500);
});
onMounted(async () => {
await workspaceStore.fetchLinks();
await Promise.all([
workspaceStore.fetchLinks(),
workspaceStore.fetchAssets(),
]);
if (isEditing.value) {
await loadExisting();
}
@@ -519,6 +668,150 @@ onMounted(async () => {
font-weight: 500;
}
.logo-section {
display: flex;
flex-direction: column;
gap: 16px;
}
.current-logo {
position: relative;
display: inline-block;
width: 80px;
height: 80px;
}
.logo-preview {
width: 80px;
height: 80px;
object-fit: contain;
border-radius: 12px;
border: 1px solid var(--line);
background: var(--bg);
}
.remove-logo-btn {
position: absolute;
top: -8px;
right: -8px;
width: 24px;
height: 24px;
border-radius: 50%;
background: #dc2626;
color: white;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.remove-logo-btn:hover {
background: #b91c1c;
}
.logo-upload {
display: flex;
gap: 12px;
}
.upload-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 12px 20px;
background: var(--bg);
border: 2px dashed var(--line);
border-radius: 12px;
cursor: pointer;
font-weight: 500;
transition: all 0.15s ease;
}
.upload-btn:hover {
border-color: var(--accent);
color: var(--accent);
}
.existing-logos {
margin-top: 8px;
}
.logos-grid {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 8px;
}
.logo-option {
width: 48px;
height: 48px;
padding: 4px;
border: 2px solid var(--line);
border-radius: 8px;
background: var(--surface);
cursor: pointer;
transition: all 0.15s ease;
}
.logo-option:hover {
border-color: var(--accent);
}
.logo-option img {
width: 100%;
height: 100%;
object-fit: contain;
}
.shape-selector {
display: flex;
gap: 8px;
}
.shape-btn {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 12px 8px;
border: 2px solid var(--line);
border-radius: 12px;
background: var(--surface);
cursor: pointer;
transition: all 0.15s ease;
}
.shape-btn:hover {
border-color: var(--muted);
}
.shape-btn.active {
border-color: var(--accent);
background: rgba(255, 106, 61, 0.1);
}
.shape-icon {
width: 24px;
height: 24px;
background: var(--ink);
}
.shape-icon.square {
border-radius: 2px;
}
.shape-icon.rounded {
border-radius: 6px;
}
.shape-icon.dots,
.shape-icon.circle {
border-radius: 50%;
}
@media (max-width: 900px) {
.designer-grid {
grid-template-columns: 1fr;

View File

@@ -0,0 +1,514 @@
<template>
<AppLayout>
<div class="qrcode-detail">
<header class="page-header">
<div class="header-left">
<router-link to="/qrcodes" class="back-link">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 12H5M12 19l-7-7 7-7"/>
</svg>
Back to QR Codes
</router-link>
<h1>{{ qrCode?.name || 'QR Code' }}</h1>
</div>
<div class="header-actions">
<router-link :to="`/qrcodes/${id}`" class="btn btn-secondary">
Edit Design
</router-link>
</div>
</header>
<div v-if="loading" class="loading">Loading analytics...</div>
<template v-else-if="analytics">
<!-- Stats Grid -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-icon scans">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="6" height="6"/>
<rect x="15" y="3" width="6" height="6"/>
<rect x="3" y="15" width="6" height="6"/>
<rect x="15" y="15" width="6" height="6"/>
</svg>
</div>
<div class="stat-content">
<p class="stat-value">{{ analytics.summary.totalScans }}</p>
<p class="stat-label">Total Scans</p>
</div>
</div>
<div class="stat-card">
<div class="stat-icon visitors">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
</svg>
</div>
<div class="stat-content">
<p class="stat-value">{{ analytics.summary.uniqueVisitors }}</p>
<p class="stat-label">Unique Visitors</p>
</div>
</div>
</div>
<!-- Period Selector -->
<div class="period-selector">
<button
v-for="p in periods"
:key="p.value"
:class="{ active: period === p.value }"
@click="setPeriod(p.value)"
>
{{ p.label }}
</button>
</div>
<!-- Chart -->
<section class="card chart-card">
<h2>Scans Over Time</h2>
<div class="chart-container" v-if="analytics.timeSeries?.length">
<div class="chart">
<div
v-for="(point, i) in analytics.timeSeries"
:key="i"
class="chart-bar"
:style="{ height: getBarHeight(point.scans) + '%' }"
:title="`${point.date}: ${point.scans} scans`"
></div>
</div>
</div>
<div v-else class="empty-state">
<p>No scan data for this period</p>
</div>
</section>
<!-- Breakdowns -->
<div class="breakdown-grid">
<!-- Device Breakdown -->
<section class="card">
<h2>Devices</h2>
<div v-if="Object.keys(analytics.deviceBreakdown).length" class="breakdown-list">
<div
v-for="(count, device) in analytics.deviceBreakdown"
:key="device"
class="breakdown-item"
>
<span class="breakdown-label">{{ device }}</span>
<div class="breakdown-bar-container">
<div
class="breakdown-bar"
:style="{ width: getBreakdownPercent(count, analytics.summary.totalScans) + '%' }"
></div>
</div>
<span class="breakdown-count">{{ count }}</span>
</div>
</div>
<div v-else class="empty-state">
<p>No device data</p>
</div>
</section>
<!-- Country Breakdown -->
<section class="card">
<h2>Countries</h2>
<div v-if="Object.keys(analytics.countryBreakdown).length" class="breakdown-list">
<div
v-for="(count, country) in analytics.countryBreakdown"
:key="country"
class="breakdown-item"
>
<span class="breakdown-label">
{{ getCountryFlag(country) }} {{ getCountryName(country) }}
</span>
<div class="breakdown-bar-container">
<div
class="breakdown-bar"
:style="{ width: getBreakdownPercent(count, analytics.summary.totalScans) + '%' }"
></div>
</div>
<span class="breakdown-count">{{ count }}</span>
</div>
</div>
<div v-else class="empty-state">
<p>No country data</p>
</div>
</section>
<!-- Referrer Breakdown -->
<section class="card">
<h2>Referrers</h2>
<div v-if="Object.keys(analytics.referrerBreakdown).length" class="breakdown-list">
<div
v-for="(count, referrer) in analytics.referrerBreakdown"
:key="referrer"
class="breakdown-item"
>
<span class="breakdown-label">{{ referrer }}</span>
<div class="breakdown-bar-container">
<div
class="breakdown-bar"
:style="{ width: getBreakdownPercent(count, analytics.summary.totalScans) + '%' }"
></div>
</div>
<span class="breakdown-count">{{ count }}</span>
</div>
</div>
<div v-else class="empty-state">
<p>No referrer data</p>
</div>
</section>
</div>
</template>
<div v-if="error" class="error-message">{{ error }}</div>
</div>
</AppLayout>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue';
import { useRoute } from 'vue-router';
import { api } from '../../api/client';
import { useWorkspaceStore } from '../../stores/workspace';
import AppLayout from '../../components/layout/AppLayout.vue';
const route = useRoute();
const workspaceStore = useWorkspaceStore();
const id = computed(() => route.params.id);
const loading = ref(true);
const error = ref('');
const qrCode = ref(null);
const analytics = ref(null);
const period = ref('7d');
const periods = [
{ value: '24h', label: '24h' },
{ value: '7d', label: '7d' },
{ value: '30d', label: '30d' },
{ value: 'all', label: 'All' }
];
const countryNames = {
US: 'United States', GB: 'United Kingdom', CA: 'Canada', AU: 'Australia',
DE: 'Germany', FR: 'France', JP: 'Japan', CN: 'China', IN: 'India',
BR: 'Brazil', MX: 'Mexico', ES: 'Spain', IT: 'Italy', NL: 'Netherlands'
};
onMounted(() => {
loadData();
});
async function loadData() {
loading.value = true;
error.value = '';
try {
const workspaceId = workspaceStore.currentWorkspace?.id;
if (!workspaceId) return;
const [qrData, analyticsData] = await Promise.all([
api.getQRCode(workspaceId, id.value),
api.getQRCodeAnalytics(workspaceId, id.value, period.value)
]);
qrCode.value = qrData;
analytics.value = analyticsData;
} catch (err) {
error.value = err.message;
} finally {
loading.value = false;
}
}
async function setPeriod(newPeriod) {
period.value = newPeriod;
loading.value = true;
try {
const workspaceId = workspaceStore.currentWorkspace?.id;
analytics.value = await api.getQRCodeAnalytics(workspaceId, id.value, newPeriod);
} catch (err) {
error.value = err.message;
} finally {
loading.value = false;
}
}
function getBarHeight(value) {
if (!analytics.value?.timeSeries?.length) return 0;
const max = Math.max(...analytics.value.timeSeries.map(p => p.scans));
return max > 0 ? (value / max) * 100 : 0;
}
function getBreakdownPercent(count, total) {
return total > 0 ? (count / total) * 100 : 0;
}
function getCountryFlag(code) {
const codePoints = code
.toUpperCase()
.split('')
.map(char => 127397 + char.charCodeAt(0));
return String.fromCodePoint(...codePoints);
}
function getCountryName(code) {
return countryNames[code] || code;
}
</script>
<style scoped>
.qrcode-detail {
max-width: 1200px;
margin: 0 auto;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 2rem;
}
.header-left {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.back-link {
display: inline-flex;
align-items: center;
gap: 0.5rem;
color: #6b7280;
font-size: 0.875rem;
text-decoration: none;
}
.back-link:hover {
color: #374151;
}
.page-header h1 {
font-size: 1.75rem;
font-weight: 600;
margin: 0;
}
.loading {
text-align: center;
padding: 3rem;
color: #6b7280;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.stat-card {
background: white;
border-radius: 12px;
padding: 1.25rem;
display: flex;
align-items: center;
gap: 1rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.stat-icon {
width: 48px;
height: 48px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
}
.stat-icon.scans {
background: #dbeafe;
color: #2563eb;
}
.stat-icon.visitors {
background: #d1fae5;
color: #059669;
}
.stat-value {
font-size: 1.5rem;
font-weight: 700;
margin: 0;
}
.stat-label {
font-size: 0.875rem;
color: #6b7280;
margin: 0;
}
.period-selector {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.period-selector button {
padding: 0.5rem 1rem;
border: 1px solid #e5e7eb;
background: white;
border-radius: 8px;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s;
}
.period-selector button:hover {
background: #f9fafb;
}
.period-selector button.active {
background: #3b82f6;
color: white;
border-color: #3b82f6;
}
.card {
background: white;
border-radius: 12px;
padding: 1.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
margin-bottom: 1.5rem;
}
.card h2 {
font-size: 1rem;
font-weight: 600;
margin: 0 0 1rem;
}
.chart-container {
height: 200px;
display: flex;
align-items: flex-end;
}
.chart {
display: flex;
align-items: flex-end;
gap: 4px;
width: 100%;
height: 100%;
}
.chart-bar {
flex: 1;
background: #3b82f6;
border-radius: 4px 4px 0 0;
min-height: 4px;
transition: height 0.3s ease;
}
.breakdown-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1.5rem;
}
.breakdown-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.breakdown-item {
display: flex;
align-items: center;
gap: 1rem;
}
.breakdown-label {
width: 120px;
font-size: 0.875rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.breakdown-bar-container {
flex: 1;
height: 8px;
background: #e5e7eb;
border-radius: 4px;
overflow: hidden;
}
.breakdown-bar {
height: 100%;
background: #3b82f6;
border-radius: 4px;
}
.breakdown-count {
width: 50px;
text-align: right;
font-size: 0.875rem;
font-weight: 500;
}
.empty-state {
text-align: center;
padding: 2rem;
color: #6b7280;
}
.empty-state p {
margin: 0;
}
.btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1rem;
border-radius: 8px;
font-weight: 500;
font-size: 0.875rem;
text-decoration: none;
cursor: pointer;
transition: all 0.2s;
border: none;
}
.btn-secondary {
background: white;
color: #374151;
border: 1px solid #d1d5db;
}
.btn-secondary:hover {
background: #f9fafb;
}
.error-message {
background: #fee2e2;
border: 1px solid #fecaca;
border-radius: 8px;
padding: 1rem;
color: #dc2626;
margin-top: 1rem;
}
@media (max-width: 768px) {
.page-header {
flex-direction: column;
gap: 1rem;
}
.breakdown-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,991 @@
<template>
<AppLayout>
<div class="settings">
<header class="page-header">
<h1>Account Settings</h1>
<p class="subtitle">Manage your account and preferences</p>
</header>
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<!-- Profile Section -->
<section class="settings-section">
<div class="section-header">
<h2>Profile</h2>
</div>
<div class="section-content">
<form @submit.prevent="updateProfile">
<div class="form-group">
<label for="email">Email Address</label>
<input
id="email"
v-model="profile.email"
type="email"
required
/>
<p v-if="!profile.isVerified" class="form-warning">
Your email is not verified.
<button type="button" class="link-btn" @click="resendVerification" :disabled="resendingVerification">
{{ resendingVerification ? 'Sending...' : 'Resend verification email' }}
</button>
</p>
</div>
<div v-if="profileError" class="error-message">{{ profileError }}</div>
<div v-if="profileSuccess" class="success-message">{{ profileSuccess }}</div>
<button type="submit" class="btn btn-primary" :disabled="savingProfile">
{{ savingProfile ? 'Saving...' : 'Save Changes' }}
</button>
</form>
</div>
</section>
<!-- Password Section -->
<section class="settings-section">
<div class="section-header">
<h2>Change Password</h2>
</div>
<div class="section-content">
<form @submit.prevent="changePassword">
<div class="form-group">
<label for="currentPassword">Current Password</label>
<input
id="currentPassword"
v-model="passwordForm.currentPassword"
type="password"
required
/>
</div>
<div class="form-group">
<label for="newPassword">New Password</label>
<input
id="newPassword"
v-model="passwordForm.newPassword"
type="password"
required
minlength="8"
/>
<p class="form-hint">At least 8 characters</p>
</div>
<div class="form-group">
<label for="confirmPassword">Confirm New Password</label>
<input
id="confirmPassword"
v-model="passwordForm.confirmPassword"
type="password"
required
/>
</div>
<div v-if="passwordError" class="error-message">{{ passwordError }}</div>
<div v-if="passwordSuccess" class="success-message">{{ passwordSuccess }}</div>
<button type="submit" class="btn btn-primary" :disabled="changingPassword">
{{ changingPassword ? 'Changing...' : 'Change Password' }}
</button>
</form>
</div>
</section>
<!-- API Keys Section -->
<section class="settings-section">
<div class="section-header">
<h2>API Keys</h2>
<button class="btn btn-primary btn-sm" @click="showCreateKeyModal = true">
Create Key
</button>
</div>
<div class="section-content">
<p class="section-description">Generate API keys to access TrakQR programmatically. Keys are scoped to your current workspace.</p>
<div v-if="loadingKeys" class="loading-inline">Loading API keys...</div>
<div v-else-if="apiKeys.length === 0" class="empty-state-inline">
<p>No API keys yet. Create one to get started.</p>
</div>
<div v-else class="api-keys-list">
<div v-for="key in apiKeys" :key="key.id" class="api-key-item">
<div class="api-key-info">
<span class="api-key-name">{{ key.name }}</span>
<code class="api-key-prefix">{{ key.keyPrefix }}</code>
</div>
<div class="api-key-meta">
<span v-if="key.lastUsedAt" class="api-key-used">
Last used {{ formatDate(key.lastUsedAt) }}
</span>
<span v-else class="api-key-used">Never used</span>
<span v-if="key.expiresAt" :class="['api-key-expiry', { expired: isExpired(key.expiresAt) }]">
{{ isExpired(key.expiresAt) ? 'Expired' : `Expires ${formatDate(key.expiresAt)}` }}
</span>
</div>
<button class="btn btn-icon btn-danger-icon" @click="confirmDeleteKey(key)" title="Delete">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
</button>
</div>
</div>
</div>
</section>
<!-- Danger Zone -->
<section class="settings-section danger-zone">
<div class="section-header">
<h2>Danger Zone</h2>
</div>
<div class="section-content">
<div class="danger-item">
<div>
<h4>Delete Account</h4>
<p>Permanently delete your account and all associated data. This action cannot be undone.</p>
</div>
<button class="btn btn-danger" @click="showDeleteModal = true">
Delete Account
</button>
</div>
</div>
</section>
</template>
<!-- Create API Key Modal -->
<div v-if="showCreateKeyModal" class="modal-overlay" @click.self="showCreateKeyModal = false">
<div class="modal modal-sm">
<div class="modal-header">
<h2 class="modal-title-normal">Create API Key</h2>
<button class="close-btn" @click="showCreateKeyModal = false">&times;</button>
</div>
<form @submit.prevent="createApiKey">
<div class="modal-body">
<div class="form-group">
<label for="keyName">Key Name</label>
<input
id="keyName"
v-model="newKeyName"
type="text"
required
placeholder="e.g., Production Server"
/>
</div>
<div class="form-group">
<label for="keyExpiry">Expiration (optional)</label>
<select id="keyExpiry" v-model="newKeyExpiry">
<option value="">Never expires</option>
<option value="30">30 days</option>
<option value="90">90 days</option>
<option value="365">1 year</option>
</select>
</div>
<div v-if="createKeyError" class="error-message">{{ createKeyError }}</div>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" @click="showCreateKeyModal = false">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="creatingKey">
{{ creatingKey ? 'Creating...' : 'Create Key' }}
</button>
</div>
</form>
</div>
</div>
<!-- Show New Key Modal -->
<div v-if="newlyCreatedKey" class="modal-overlay">
<div class="modal modal-sm">
<div class="modal-header">
<h2 class="modal-title-normal">API Key Created</h2>
</div>
<div class="modal-body">
<p class="key-warning">
Copy your API key now. You won't be able to see it again!
</p>
<div class="key-display">
<code>{{ newlyCreatedKey }}</code>
<button type="button" class="btn btn-icon" @click="copyKey" :title="keyCopied ? 'Copied!' : 'Copy'">
<svg v-if="!keyCopied" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="20 6 9 17 4 12"/>
</svg>
</button>
</div>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-primary" @click="newlyCreatedKey = null">Done</button>
</div>
</div>
</div>
<!-- Delete API Key Modal -->
<div v-if="keyToDelete" class="modal-overlay" @click.self="keyToDelete = null">
<div class="modal modal-sm">
<div class="modal-header">
<h2>Delete API Key?</h2>
<button class="close-btn" @click="keyToDelete = null">&times;</button>
</div>
<div class="modal-body">
<p>Are you sure you want to delete the API key "{{ keyToDelete.name }}"? Any applications using this key will stop working.</p>
<div v-if="deleteKeyError" class="error-message">{{ deleteKeyError }}</div>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" @click="keyToDelete = null">Cancel</button>
<button type="button" class="btn btn-danger" @click="deleteApiKey" :disabled="deletingKey">
{{ deletingKey ? 'Deleting...' : 'Delete Key' }}
</button>
</div>
</div>
</div>
<!-- Delete Account Modal -->
<div v-if="showDeleteModal" class="modal-overlay" @click.self="showDeleteModal = false">
<div class="modal modal-sm">
<div class="modal-header">
<h2>Delete Account</h2>
<button class="close-btn" @click="showDeleteModal = false">&times;</button>
</div>
<form @submit.prevent="deleteAccount">
<div class="modal-body">
<p class="delete-warning">
This will permanently delete your account, all workspaces, links, QR codes, and analytics data.
This action <strong>cannot be undone</strong>.
</p>
<div class="form-group">
<label for="deletePassword">Enter your password to confirm</label>
<input
id="deletePassword"
v-model="deletePassword"
type="password"
required
placeholder="Your password"
/>
</div>
<div v-if="deleteError" class="error-message">{{ deleteError }}</div>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" @click="showDeleteModal = false">Cancel</button>
<button type="submit" class="btn btn-danger" :disabled="deleting">
{{ deleting ? 'Deleting...' : 'Delete My Account' }}
</button>
</div>
</form>
</div>
</div>
</div>
</AppLayout>
</template>
<script setup>
import { ref, onMounted, watch } from 'vue';
import { useRouter } from 'vue-router';
import { api } from '../../api/client';
import { useAuthStore } from '../../stores/auth';
import { useWorkspaceStore } from '../../stores/workspace';
import AppLayout from '../../components/layout/AppLayout.vue';
const router = useRouter();
const authStore = useAuthStore();
const workspaceStore = useWorkspaceStore();
const loading = ref(true);
const profile = ref({ email: '', isVerified: false });
const savingProfile = ref(false);
const profileError = ref('');
const profileSuccess = ref('');
const passwordForm = ref({
currentPassword: '',
newPassword: '',
confirmPassword: ''
});
const changingPassword = ref(false);
const passwordError = ref('');
const passwordSuccess = ref('');
const showDeleteModal = ref(false);
const deletePassword = ref('');
const deleting = ref(false);
const deleteError = ref('');
// API Keys state
const apiKeys = ref([]);
const loadingKeys = ref(false);
const showCreateKeyModal = ref(false);
const newKeyName = ref('');
const newKeyExpiry = ref('');
const creatingKey = ref(false);
const createKeyError = ref('');
const newlyCreatedKey = ref(null);
const keyCopied = ref(false);
const keyToDelete = ref(null);
const deletingKey = ref(false);
const deleteKeyError = ref('');
onMounted(async () => {
try {
const data = await api.getProfile();
profile.value = data;
await loadApiKeys();
} catch (err) {
profileError.value = err.message;
} finally {
loading.value = false;
}
});
watch(() => workspaceStore.currentWorkspaceId, async () => {
if (workspaceStore.currentWorkspaceId) {
await loadApiKeys();
}
});
async function loadApiKeys() {
const workspaceId = workspaceStore.currentWorkspaceId;
if (!workspaceId) return;
loadingKeys.value = true;
try {
const result = await api.listApiKeys(workspaceId);
apiKeys.value = result.apiKeys;
} catch (err) {
console.error('Failed to load API keys:', err);
} finally {
loadingKeys.value = false;
}
}
async function updateProfile() {
savingProfile.value = true;
profileError.value = '';
profileSuccess.value = '';
try {
const data = await api.updateProfile({ email: profile.value.email });
profile.value = data;
profileSuccess.value = 'Profile updated successfully';
} catch (err) {
profileError.value = err.message;
} finally {
savingProfile.value = false;
}
}
async function changePassword() {
if (passwordForm.value.newPassword !== passwordForm.value.confirmPassword) {
passwordError.value = 'Passwords do not match';
return;
}
changingPassword.value = true;
passwordError.value = '';
passwordSuccess.value = '';
try {
await api.changePassword(
passwordForm.value.currentPassword,
passwordForm.value.newPassword
);
passwordSuccess.value = 'Password changed successfully';
passwordForm.value = { currentPassword: '', newPassword: '', confirmPassword: '' };
} catch (err) {
passwordError.value = err.message;
} finally {
changingPassword.value = false;
}
}
const resendingVerification = ref(false);
async function resendVerification() {
resendingVerification.value = true;
profileError.value = '';
try {
await api.resendVerification();
profileSuccess.value = 'Verification email sent! Check your inbox.';
} catch (err) {
profileError.value = err.message;
} finally {
resendingVerification.value = false;
}
}
async function deleteAccount() {
deleting.value = true;
deleteError.value = '';
try {
await api.deleteAccount(deletePassword.value);
authStore.logout();
router.push('/');
} catch (err) {
deleteError.value = err.message;
} finally {
deleting.value = false;
}
}
// API Key functions
async function createApiKey() {
const workspaceId = workspaceStore.currentWorkspaceId;
if (!workspaceId) return;
creatingKey.value = true;
createKeyError.value = '';
try {
let expiresAt = null;
if (newKeyExpiry.value) {
const date = new Date();
date.setDate(date.getDate() + parseInt(newKeyExpiry.value));
expiresAt = date.toISOString();
}
const result = await api.createApiKey(workspaceId, newKeyName.value, expiresAt);
newlyCreatedKey.value = result.key;
apiKeys.value.unshift({
id: result.id,
name: result.name,
keyPrefix: result.keyPrefix,
scopes: result.scopes,
expiresAt: result.expiresAt,
createdAt: result.createdAt,
isActive: true,
});
showCreateKeyModal.value = false;
newKeyName.value = '';
newKeyExpiry.value = '';
} catch (err) {
createKeyError.value = err.message;
} finally {
creatingKey.value = false;
}
}
async function copyKey() {
try {
await navigator.clipboard.writeText(newlyCreatedKey.value);
keyCopied.value = true;
setTimeout(() => {
keyCopied.value = false;
}, 2000);
} catch (err) {
console.error('Failed to copy:', err);
}
}
function confirmDeleteKey(key) {
keyToDelete.value = key;
deleteKeyError.value = '';
}
async function deleteApiKey() {
const workspaceId = workspaceStore.currentWorkspaceId;
if (!workspaceId || !keyToDelete.value) return;
deletingKey.value = true;
deleteKeyError.value = '';
try {
await api.deleteApiKey(workspaceId, keyToDelete.value.id);
apiKeys.value = apiKeys.value.filter(k => k.id !== keyToDelete.value.id);
keyToDelete.value = null;
} catch (err) {
deleteKeyError.value = err.message;
} finally {
deletingKey.value = false;
}
}
function formatDate(date) {
if (!date) return '';
return new Date(date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
}
function isExpired(date) {
if (!date) return false;
return new Date(date) < new Date();
}
</script>
<style scoped>
.settings {
max-width: 800px;
margin: 0 auto;
}
.page-header {
margin-bottom: 2rem;
}
.page-header h1 {
font-size: 1.75rem;
font-weight: 600;
margin: 0 0 0.5rem;
}
.subtitle {
color: #6b7280;
margin: 0;
}
.loading {
text-align: center;
padding: 3rem;
color: #6b7280;
}
.settings-section {
background: white;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
margin-bottom: 1.5rem;
overflow: hidden;
}
.section-header {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid #e5e7eb;
}
.section-header h2 {
font-size: 1rem;
font-weight: 600;
margin: 0;
}
.badge {
font-size: 0.75rem;
font-weight: 500;
padding: 0.25rem 0.5rem;
background: #e5e7eb;
color: #6b7280;
border-radius: 4px;
}
.section-content {
padding: 1.5rem;
}
.section-content.muted {
color: #6b7280;
}
.section-content.muted p {
margin: 0;
}
.form-group {
margin-bottom: 1.25rem;
}
.form-group:last-of-type {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 0.5rem;
}
.form-group input {
width: 100%;
padding: 0.625rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 0.875rem;
}
.form-group input:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.form-hint {
font-size: 0.75rem;
color: #6b7280;
margin: 0.5rem 0 0;
}
.form-warning {
font-size: 0.875rem;
color: #d97706;
margin: 0.5rem 0 0;
}
.link-btn {
background: none;
border: none;
color: #3b82f6;
text-decoration: underline;
cursor: pointer;
font-size: inherit;
}
.link-btn:hover {
color: #2563eb;
}
/* Danger Zone */
.danger-zone .section-header {
background: #fef2f2;
border-bottom-color: #fecaca;
}
.danger-zone .section-header h2 {
color: #dc2626;
}
.danger-item {
display: flex;
justify-content: space-between;
align-items: center;
gap: 2rem;
}
.danger-item h4 {
font-size: 0.9rem;
font-weight: 600;
margin: 0 0 0.25rem;
}
.danger-item p {
font-size: 0.875rem;
color: #6b7280;
margin: 0;
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1rem;
border-radius: 8px;
font-weight: 500;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s;
border: none;
}
.btn-primary {
background: #3b82f6;
color: white;
}
.btn-primary:hover:not(:disabled) {
background: #2563eb;
}
.btn-secondary {
background: white;
color: #374151;
border: 1px solid #d1d5db;
}
.btn-secondary:hover:not(:disabled) {
background: #f9fafb;
}
.btn-danger {
background: #dc2626;
color: white;
}
.btn-danger:hover:not(:disabled) {
background: #b91c1c;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Messages */
.error-message {
background: #fee2e2;
border: 1px solid #fecaca;
border-radius: 8px;
padding: 0.75rem;
color: #dc2626;
font-size: 0.875rem;
margin-bottom: 1rem;
}
.success-message {
background: #d1fae5;
border: 1px solid #a7f3d0;
border-radius: 8px;
padding: 0.75rem;
color: #065f46;
font-size: 0.875rem;
margin-bottom: 1rem;
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: white;
border-radius: 12px;
width: 100%;
max-width: 480px;
max-height: 90vh;
overflow-y: auto;
}
.modal-sm {
max-width: 420px;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid #e5e7eb;
}
.modal-header h2 {
font-size: 1.125rem;
font-weight: 600;
margin: 0;
color: #dc2626;
}
.close-btn {
width: 32px;
height: 32px;
border: none;
background: transparent;
font-size: 1.5rem;
color: #6b7280;
cursor: pointer;
border-radius: 6px;
}
.close-btn:hover {
background: #f3f4f6;
}
.modal-body {
padding: 1.5rem;
}
.delete-warning {
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 8px;
padding: 1rem;
color: #991b1b;
margin-bottom: 1.5rem;
line-height: 1.5;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding: 1rem 1.5rem;
border-top: 1px solid #e5e7eb;
}
/* API Keys */
.btn-sm {
padding: 0.375rem 0.75rem;
font-size: 0.8rem;
}
.section-description {
color: #6b7280;
font-size: 0.875rem;
margin: 0 0 1rem;
}
.loading-inline {
color: #6b7280;
font-size: 0.875rem;
padding: 1rem 0;
}
.empty-state-inline {
color: #6b7280;
font-size: 0.875rem;
padding: 1rem;
background: #f9fafb;
border-radius: 8px;
text-align: center;
}
.empty-state-inline p {
margin: 0;
}
.api-keys-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.api-key-item {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
background: #f9fafb;
border-radius: 8px;
}
.api-key-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.api-key-name {
font-weight: 500;
font-size: 0.9rem;
}
.api-key-prefix {
font-family: monospace;
font-size: 0.8rem;
color: #6b7280;
background: #e5e7eb;
padding: 0.125rem 0.375rem;
border-radius: 4px;
width: fit-content;
}
.api-key-meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.25rem;
font-size: 0.75rem;
color: #6b7280;
}
.api-key-expiry.expired {
color: #dc2626;
}
.btn-icon {
width: 32px;
height: 32px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
background: transparent;
border: none;
cursor: pointer;
color: #6b7280;
}
.btn-icon:hover {
background: #e5e7eb;
}
.btn-danger-icon:hover {
background: #fee2e2;
color: #dc2626;
}
.modal-title-normal {
color: #111827 !important;
}
.key-warning {
background: #fef3c7;
border: 1px solid #fcd34d;
border-radius: 8px;
padding: 0.75rem;
color: #92400e;
font-size: 0.875rem;
margin-bottom: 1rem;
}
.key-display {
display: flex;
align-items: center;
gap: 0.5rem;
background: #1f2937;
border-radius: 8px;
padding: 0.75rem;
}
.key-display code {
flex: 1;
color: #10b981;
font-family: monospace;
font-size: 0.8rem;
word-break: break-all;
}
.key-display .btn-icon {
color: #9ca3af;
flex-shrink: 0;
}
.key-display .btn-icon:hover {
color: white;
background: transparent;
}
.form-group select {
width: 100%;
padding: 0.625rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 0.875rem;
background: white;
}
.form-group select:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
@media (max-width: 640px) {
.danger-item {
flex-direction: column;
align-items: flex-start;
}
.api-key-item {
flex-direction: column;
align-items: flex-start;
}
.api-key-meta {
flex-direction: row;
align-items: center;
gap: 1rem;
width: 100%;
justify-content: space-between;
}
}
</style>