diff --git a/PlanTempus.Application/Features/Dashboard/Components/StatCard/Default.cshtml b/PlanTempus.Application/Features/Dashboard/Components/StatCard/Default.cshtml new file mode 100644 index 0000000..dbbe501 --- /dev/null +++ b/PlanTempus.Application/Features/Dashboard/Components/StatCard/Default.cshtml @@ -0,0 +1,16 @@ +@model PlanTempus.Application.Features.Dashboard.Components.StatCardViewModel + + + @Model.Value + @Model.Label + @if (Model.HasTrend) + { + + @if (!string.IsNullOrEmpty(Model.TrendIcon)) + { + + } + @Model.TrendText + + } + diff --git a/PlanTempus.Application/Features/Dashboard/Components/StatCardViewComponent.cs b/PlanTempus.Application/Features/Dashboard/Components/StatCardViewComponent.cs new file mode 100644 index 0000000..ccac82f --- /dev/null +++ b/PlanTempus.Application/Features/Dashboard/Components/StatCardViewComponent.cs @@ -0,0 +1,90 @@ +using Microsoft.AspNetCore.Mvc; + +namespace PlanTempus.Application.Features.Dashboard.Components; + +/// +/// ViewComponent for rendering a stat card on the dashboard. +/// +public class StatCardViewComponent : ViewComponent +{ + public IViewComponentResult Invoke(string key) + { + var model = StatCardCatalog.Get(key); + return View(model); + } +} + +/// +/// ViewModel for the StatCard component. +/// +public class StatCardViewModel +{ + public required string Key { get; init; } + public required string Value { get; init; } + public required string Label { get; init; } + public string? TrendText { get; init; } + public string? TrendIcon { get; init; } + public string? TrendDirection { get; init; } + public string? Variant { get; init; } + public bool HasTrend => !string.IsNullOrEmpty(TrendText); +} + +/// +/// Catalog of available stat cards with their data. +/// +public static class StatCardCatalog +{ + private static readonly Dictionary Cards = new() + { + ["bookings-today"] = new StatCardViewModel + { + Key = "bookings-today", + Value = "12", + Label = "Bookinger i dag", + TrendText = "4 gennemført, 2 i gang", + TrendIcon = "ph-check-circle", + TrendDirection = "up", + Variant = "highlight" + }, + ["expected-revenue"] = new StatCardViewModel + { + Key = "expected-revenue", + Value = "8.450 kr", + Label = "Forventet omsætning", + TrendText = "+12% vs. gennemsnit", + TrendIcon = "ph-trend-up", + TrendDirection = "up", + Variant = "success" + }, + ["occupancy-rate"] = new StatCardViewModel + { + Key = "occupancy-rate", + Value = "78%", + Label = "Belægningsgrad", + TrendText = "God kapacitet", + TrendIcon = "ph-trend-up", + TrendDirection = "up", + Variant = null + }, + ["needs-attention"] = new StatCardViewModel + { + Key = "needs-attention", + Value = "4", + Label = "Kræver opmærksomhed", + TrendText = null, + TrendIcon = null, + TrendDirection = null, + Variant = "warning" + } + }; + + public static StatCardViewModel Get(string key) + { + if (Cards.TryGetValue(key, out var card)) + return card; + + throw new KeyNotFoundException($"StatCard with key '{key}' not found"); + } + + public static IEnumerable AllKeys => Cards.Keys; +} diff --git a/PlanTempus.Application/Features/Dashboard/Pages/Index.cshtml b/PlanTempus.Application/Features/Dashboard/Pages/Index.cshtml index 9e3b36c..92c24f0 100644 --- a/PlanTempus.Application/Features/Dashboard/Pages/Index.cshtml +++ b/PlanTempus.Application/Features/Dashboard/Pages/Index.cshtml @@ -1,5 +1,5 @@ @page "/" -@using PlanTempus.Application.Features.Dashboard.Pages +@using PlanTempus.Application.Features.Dashboard.Pages @model PlanTempus.Application.Features.Dashboard.Pages.IndexModel @{ ViewData["Title"] = "Dashboard"; @@ -8,34 +8,10 @@ - - 12 - Bookinger i dag - - - 4 gennemført, 2 i gang - - - - 8.450 kr - Forventet omsætning - - - +12% vs. gennemsnit - - - - 78% - Belægningsgrad - - - God kapacitet - - - - 4 - Kræver opmærksomhed - + @await Component.InvokeAsync("StatCard", "bookings-today") + @await Component.InvokeAsync("StatCard", "expected-revenue") + @await Component.InvokeAsync("StatCard", "occupancy-rate") + @await Component.InvokeAsync("StatCard", "needs-attention") diff --git a/PlanTempus.Application/Features/_Shared/Pages/_Layout.cshtml b/PlanTempus.Application/Features/_Shared/Pages/_Layout.cshtml index e8bb2e3..bc1b7d0 100644 --- a/PlanTempus.Application/Features/_Shared/Pages/_Layout.cshtml +++ b/PlanTempus.Application/Features/_Shared/Pages/_Layout.cshtml @@ -14,12 +14,27 @@ + @await RenderSectionAsync("Styles", required: false) - + + + + + + Du ser en demo af PlanTempus. + + + + Opret konto + + + + + @await Component.InvokeAsync("SideMenu") diff --git a/PlanTempus.Application/wwwroot/css/demo-banner.css b/PlanTempus.Application/wwwroot/css/demo-banner.css new file mode 100644 index 0000000..39b9179 --- /dev/null +++ b/PlanTempus.Application/wwwroot/css/demo-banner.css @@ -0,0 +1,145 @@ +/** + * Demo Banner + * + * Persistent banner shown at the top of pages when in demo mode. + * Provides CTA to subscription selection. + */ + +/* =========================================== + DEMO BANNER + =========================================== */ +swp-demo-banner { + /* TODO: Remove display:none when demo mode is ready */ + display: none; + align-items: center; + justify-content: center; + gap: var(--spacing-4); + padding: var(--spacing-3) var(--spacing-6); + background: linear-gradient(135deg, var(--color-teal) 0%, #00796b 100%); + color: white; + font-size: var(--font-size-sm); + position: relative; + z-index: 1000; +} + +swp-demo-banner-text { + display: flex; + align-items: center; + gap: var(--spacing-2); +} + +swp-demo-banner-text i { + font-size: 18px; + opacity: 0.9; +} + +swp-demo-banner-text span { + opacity: 0.95; +} + +swp-demo-banner-text strong { + font-weight: var(--font-weight-semibold); + opacity: 1; +} + +swp-demo-banner-cta { + display: inline-flex; +} + +swp-demo-banner-cta a { + display: inline-flex; + align-items: center; + gap: var(--spacing-2); + padding: var(--spacing-2) var(--spacing-4); + background: white; + color: var(--color-teal); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-semibold); + border-radius: var(--radius-pill); + text-decoration: none; + transition: all var(--transition-fast); + white-space: nowrap; +} + +swp-demo-banner-cta a:hover { + background: rgba(255, 255, 255, 0.95); + color: #00695c; + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); +} + +swp-demo-banner-cta i { + font-size: 16px; +} + +/* Close button (optional) */ +swp-demo-banner-close { + position: absolute; + right: var(--spacing-4); + top: 50%; + transform: translateY(-50%); + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + background: rgba(255, 255, 255, 0.15); + border-radius: 50%; + color: white; + cursor: pointer; + opacity: 0.7; + transition: all var(--transition-fast); +} + +swp-demo-banner-close:hover { + opacity: 1; + background: rgba(255, 255, 255, 0.25); +} + +swp-demo-banner-close i { + font-size: 16px; +} + +/* =========================================== + BODY OFFSET (when banner is visible) + =========================================== */ +body.has-demo-banner swp-app-layout { + /* Adjust if banner needs to push content down */ +} + +/* =========================================== + RESPONSIVE + =========================================== */ +@media (max-width: 768px) { + swp-demo-banner { + flex-wrap: wrap; + gap: var(--spacing-2); + padding: var(--spacing-3) var(--spacing-4); + } + + swp-demo-banner-text { + flex: 1 1 100%; + justify-content: center; + text-align: center; + } + + swp-demo-banner-cta { + flex: 1 1 100%; + justify-content: center; + } + + swp-demo-banner-cta a { + width: 100%; + justify-content: center; + } +} + +@media (max-width: 480px) { + swp-demo-banner-text span { + display: none; + } + + swp-demo-banner-text strong { + display: inline; + } +}