mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
added feedback system with admin management
This commit is contained in:
@@ -1 +1 @@
|
||||
489
|
||||
490
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Extensions;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Azure;
|
||||
using Azure.Data.Tables;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Functions.Feedback
|
||||
{
|
||||
public class FeedbackFunction
|
||||
{
|
||||
private readonly ILogger<FeedbackFunction> _logger;
|
||||
private readonly TenantConfigurationService _tenantConfigService;
|
||||
private readonly AdminConfigurationService _adminConfigService;
|
||||
private readonly TenantAdminsService _tenantAdminsService;
|
||||
private readonly TelegramNotificationService _telegramNotificationService;
|
||||
private readonly TableStorageService _storageService;
|
||||
private readonly TableClient _feedbackTableClient;
|
||||
|
||||
public FeedbackFunction(
|
||||
ILogger<FeedbackFunction> logger,
|
||||
TenantConfigurationService tenantConfigService,
|
||||
AdminConfigurationService adminConfigService,
|
||||
TenantAdminsService tenantAdminsService,
|
||||
TelegramNotificationService telegramNotificationService,
|
||||
TableStorageService storageService,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
_logger = logger;
|
||||
_tenantConfigService = tenantConfigService;
|
||||
_adminConfigService = adminConfigService;
|
||||
_tenantAdminsService = tenantAdminsService;
|
||||
_telegramNotificationService = telegramNotificationService;
|
||||
_storageService = storageService;
|
||||
|
||||
var connectionString = configuration["AzureTableStorageConnectionString"];
|
||||
var serviceClient = new TableServiceClient(connectionString);
|
||||
_feedbackTableClient = serviceClient.GetTableClient(Constants.TableNames.PreviewConfig);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether the current user is eligible to see the feedback bubble.
|
||||
/// Evaluates: kill-switch, role, tenant age, session existence, cooldown.
|
||||
/// </summary>
|
||||
[Function("GetFeedbackStatus")]
|
||||
public async Task<HttpResponseData> GetStatus(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "feedback/status")] HttpRequestData req)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 1. Kill-switch
|
||||
var adminConfig = await _adminConfigService.GetConfigurationAsync();
|
||||
if (!adminConfig.FeedbackEnabled)
|
||||
return await WriteJson(req, new { eligible = false });
|
||||
|
||||
// 2. User identity
|
||||
string tenantId = TenantHelper.GetTenantId(req);
|
||||
string upn = TenantHelper.GetUserIdentifier(req);
|
||||
|
||||
// 3. Role check — only Admin + Operator
|
||||
var roleInfo = await _tenantAdminsService.GetMemberRoleAsync(tenantId, upn);
|
||||
if (roleInfo == null || roleInfo.Role == Constants.TenantRoles.Viewer)
|
||||
return await WriteJson(req, new { eligible = false });
|
||||
|
||||
// 4. Tenant age check
|
||||
var tenantConfig = await _tenantConfigService.GetConfigurationAsync(tenantId);
|
||||
if (tenantConfig.OnboardedAt == null ||
|
||||
(DateTime.UtcNow - tenantConfig.OnboardedAt.Value).TotalDays < adminConfig.FeedbackMinTenantAgeDays)
|
||||
return await WriteJson(req, new { eligible = false });
|
||||
|
||||
// 5. Sessions check — at least 1 session exists
|
||||
var sessionPage = await _storageService.GetSessionsAsync(tenantId, maxResults: 1);
|
||||
if (sessionPage.Sessions.Count == 0)
|
||||
return await WriteJson(req, new { eligible = false });
|
||||
|
||||
// 6. Cooldown check
|
||||
try
|
||||
{
|
||||
var entity = await _feedbackTableClient.GetEntityAsync<TableEntity>("Feedback", upn.ToLowerInvariant());
|
||||
var interactedAt = entity.Value.GetDateTime("InteractedAt");
|
||||
|
||||
if (interactedAt.HasValue)
|
||||
{
|
||||
// Cooldown = 0 means single wave only
|
||||
if (adminConfig.FeedbackCooldownDays == 0)
|
||||
return await WriteJson(req, new { eligible = false });
|
||||
|
||||
var daysSinceInteraction = (DateTime.UtcNow - interactedAt.Value).TotalDays;
|
||||
if (daysSinceInteraction < adminConfig.FeedbackCooldownDays)
|
||||
return await WriteJson(req, new { eligible = false });
|
||||
}
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 404)
|
||||
{
|
||||
// No feedback record yet — user is eligible
|
||||
}
|
||||
|
||||
return await WriteJson(req, new { eligible = true });
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
var response = req.CreateResponse(HttpStatusCode.Unauthorized);
|
||||
return response;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error checking feedback status");
|
||||
// Fail closed — don't show bubble on errors
|
||||
return await WriteJson(req, new { eligible = false });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Submits user feedback or records a dismissal.
|
||||
/// On submit: stores feedback and sends Telegram notification.
|
||||
/// On dismiss: stores dismissal so the bubble is not shown again (until cooldown).
|
||||
/// </summary>
|
||||
[Function("SubmitFeedback")]
|
||||
public async Task<HttpResponseData> Submit(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "feedback")] HttpRequestData req)
|
||||
{
|
||||
try
|
||||
{
|
||||
string tenantId = TenantHelper.GetTenantId(req);
|
||||
string upn = TenantHelper.GetUserIdentifier(req);
|
||||
var principal = req.FunctionContext.GetUser();
|
||||
string displayName = principal?.GetDisplayName() ?? upn;
|
||||
|
||||
var body = await req.ReadFromJsonAsync<FeedbackRequest>();
|
||||
if (body == null)
|
||||
{
|
||||
var badRequest = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await badRequest.WriteAsJsonAsync(new { success = false, message = "Invalid request body" });
|
||||
return badRequest;
|
||||
}
|
||||
|
||||
// Validate rating
|
||||
if (!body.Dismissed && (body.Rating < 1 || body.Rating > 5))
|
||||
{
|
||||
var badRequest = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await badRequest.WriteAsJsonAsync(new { success = false, message = "Rating must be between 1 and 5" });
|
||||
return badRequest;
|
||||
}
|
||||
|
||||
// Trim comment
|
||||
var comment = body.Comment?.Trim();
|
||||
if (comment?.Length > 500)
|
||||
comment = comment.Substring(0, 500);
|
||||
|
||||
// Upsert feedback record
|
||||
var entity = new TableEntity("Feedback", upn.ToLowerInvariant())
|
||||
{
|
||||
{ "TenantId", tenantId },
|
||||
{ "DisplayName", displayName },
|
||||
{ "Rating", body.Dismissed ? null : (int?)body.Rating },
|
||||
{ "Comment", body.Dismissed ? null : comment },
|
||||
{ "Dismissed", body.Dismissed },
|
||||
{ "Submitted", !body.Dismissed },
|
||||
{ "InteractedAt", DateTime.UtcNow }
|
||||
};
|
||||
|
||||
await _feedbackTableClient.UpsertEntityAsync(entity, TableUpdateMode.Replace);
|
||||
|
||||
// Telegram notification — only for actual submissions, fire-and-forget
|
||||
if (!body.Dismissed && body.Rating > 0)
|
||||
{
|
||||
_ = _telegramNotificationService.SendFeedbackAsync(tenantId, upn, displayName, body.Rating, comment);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Feedback {Action} by {Upn} (tenant {TenantId})",
|
||||
body.Dismissed ? "dismissed" : "submitted", upn, tenantId);
|
||||
|
||||
return await WriteJson(req, new { success = true });
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return req.CreateResponse(HttpStatusCode.Unauthorized);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error submitting feedback");
|
||||
var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError);
|
||||
await errorResponse.WriteAsJsonAsync(new { success = false, message = "Internal server error" });
|
||||
return errorResponse;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns all feedback entries for the Galactic Admin dashboard.
|
||||
/// </summary>
|
||||
[Function("GetAllFeedback")]
|
||||
public async Task<HttpResponseData> GetAll(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "feedback/all")] HttpRequestData req)
|
||||
{
|
||||
try
|
||||
{
|
||||
var entries = new System.Collections.Generic.List<object>();
|
||||
|
||||
await foreach (var entity in _feedbackTableClient.QueryAsync<TableEntity>(
|
||||
filter: "PartitionKey eq 'Feedback'"))
|
||||
{
|
||||
entries.Add(new
|
||||
{
|
||||
upn = entity.RowKey,
|
||||
tenantId = entity.GetString("TenantId"),
|
||||
displayName = entity.GetString("DisplayName"),
|
||||
rating = entity.GetInt32("Rating"),
|
||||
comment = entity.GetString("Comment"),
|
||||
dismissed = entity.GetBoolean("Dismissed") ?? false,
|
||||
submitted = entity.GetBoolean("Submitted") ?? false,
|
||||
interactedAt = entity.GetDateTime("InteractedAt")?.ToString("o")
|
||||
});
|
||||
}
|
||||
|
||||
return await WriteJson(req, new { feedback = entries });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error fetching all feedback");
|
||||
var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError);
|
||||
await errorResponse.WriteAsJsonAsync(new { success = false, message = "Internal server error" });
|
||||
return errorResponse;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<HttpResponseData> WriteJson(HttpRequestData req, object data)
|
||||
{
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(data);
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
public class FeedbackRequest
|
||||
{
|
||||
public int Rating { get; set; }
|
||||
public string? Comment { get; set; }
|
||||
public bool Dismissed { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -97,6 +97,8 @@ public static class EndpointAccessPolicyCatalog
|
||||
new("GET", "progress/sessions", EndpointPolicy.AuthenticatedUser),
|
||||
new("GET", "progress/sessions/{sessionId}/events", EndpointPolicy.AuthenticatedUser),
|
||||
new("PUT", "preview/notification-email", EndpointPolicy.AuthenticatedUser),
|
||||
new("GET", "feedback/status", EndpointPolicy.AuthenticatedUser),
|
||||
new("POST", "feedback", EndpointPolicy.AuthenticatedUser),
|
||||
|
||||
// ── MemberRead (Admin + Operator, later + Viewer) ───────────────
|
||||
new("GET", "sessions", EndpointPolicy.MemberRead),
|
||||
@@ -179,6 +181,7 @@ public static class EndpointAccessPolicyCatalog
|
||||
new("GET", "health/detailed", EndpointPolicy.AuthenticatedUser),
|
||||
new("POST", "rules/reseed-from-github", EndpointPolicy.GalacticAdminOnly),
|
||||
new("POST", "rules/ime-log-patterns/reseed", EndpointPolicy.GalacticAdminOnly),
|
||||
new("GET", "feedback/all", EndpointPolicy.GalacticAdminOnly),
|
||||
new("GET", "galactic/notifications", EndpointPolicy.GalacticAdminOnly),
|
||||
new("POST", "galactic/notifications/dismiss-all", EndpointPolicy.GalacticAdminOnly),
|
||||
new("POST", "galactic/notifications/{notificationId}/dismiss", EndpointPolicy.GalacticAdminOnly),
|
||||
|
||||
@@ -45,6 +45,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
private readonly UsageMetricsService _usageMetricsService;
|
||||
private readonly AdminConfigurationService _adminConfigurationService;
|
||||
private readonly BlockedDeviceService _blockedDeviceService;
|
||||
private readonly TenantAdminsService _tenantAdminsService;
|
||||
private readonly ILogger<MaintenanceService> _logger;
|
||||
|
||||
private const string PlatformStatsAliasFileName = "platform-stats.json";
|
||||
@@ -57,6 +58,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
UsageMetricsService usageMetricsService,
|
||||
AdminConfigurationService adminConfigurationService,
|
||||
BlockedDeviceService blockedDeviceService,
|
||||
TenantAdminsService tenantAdminsService,
|
||||
ILogger<MaintenanceService> logger)
|
||||
{
|
||||
_storageService = storageService;
|
||||
@@ -64,6 +66,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
_usageMetricsService = usageMetricsService;
|
||||
_adminConfigurationService = adminConfigurationService;
|
||||
_blockedDeviceService = blockedDeviceService;
|
||||
_tenantAdminsService = tenantAdminsService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -86,6 +89,9 @@ namespace AutopilotMonitor.Functions.Services
|
||||
// Safety net: backfill any sessions missing from SessionsIndex
|
||||
await _storageService.BackfillSessionIndexAsync();
|
||||
|
||||
// Backfill OnboardedAt for tenants that don't have it yet
|
||||
await BackfillTenantOnboardedAtAsync();
|
||||
|
||||
maintenanceStart.Stop();
|
||||
_logger.LogInformation($"Daily maintenance completed in {maintenanceStart.ElapsedMilliseconds}ms");
|
||||
}
|
||||
@@ -317,6 +323,61 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Backfills OnboardedAt for tenants that don't have it set yet.
|
||||
/// Derives the value from the earliest TenantAdmin AddedDate for each tenant.
|
||||
/// Self-healing: runs every maintenance cycle, no-ops once all tenants are backfilled.
|
||||
/// </summary>
|
||||
private async Task BackfillTenantOnboardedAtAsync()
|
||||
{
|
||||
_logger.LogInformation("Backfilling OnboardedAt for tenants...");
|
||||
|
||||
try
|
||||
{
|
||||
var allConfigs = await _tenantConfigService.GetAllConfigurationsAsync();
|
||||
var configsWithoutOnboardedAt = allConfigs.Where(c => c.OnboardedAt == null).ToList();
|
||||
|
||||
if (configsWithoutOnboardedAt.Count == 0)
|
||||
{
|
||||
_logger.LogInformation("All tenants already have OnboardedAt set");
|
||||
return;
|
||||
}
|
||||
|
||||
int backfilledCount = 0;
|
||||
|
||||
foreach (var config in configsWithoutOnboardedAt)
|
||||
{
|
||||
try
|
||||
{
|
||||
var admins = await _tenantAdminsService.GetTenantAdminsAsync(config.TenantId);
|
||||
|
||||
if (admins.Count == 0)
|
||||
{
|
||||
// No admins yet — use LastUpdated as fallback (config creation date)
|
||||
config.OnboardedAt = config.LastUpdated;
|
||||
}
|
||||
else
|
||||
{
|
||||
config.OnboardedAt = admins.Min(a => a.AddedDate);
|
||||
}
|
||||
|
||||
await _tenantConfigService.SaveConfigurationAsync(config);
|
||||
backfilledCount++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to backfill OnboardedAt for tenant {TenantId}", config.TenantId);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("OnboardedAt backfill completed: {Count} tenants updated", backfilledCount);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to backfill OnboardedAt for tenants");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggregates metrics for any missed days in the last 7 days, plus yesterday.
|
||||
/// Checks the UsageMetrics table for existing snapshots to avoid re-aggregation.
|
||||
|
||||
@@ -155,6 +155,44 @@ namespace AutopilotMonitor.Functions.Services
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a Telegram notification when a user submits feedback via the in-app feedback bubble.
|
||||
/// Best-effort — silently no-ops if the webhook URL is not configured.
|
||||
/// </summary>
|
||||
public async Task SendFeedbackAsync(string tenantId, string upn, string displayName, int rating, string? comment)
|
||||
{
|
||||
try
|
||||
{
|
||||
var webhookUrl = await GetWebhookUrlAsync();
|
||||
if (string.IsNullOrWhiteSpace(webhookUrl))
|
||||
{
|
||||
_logger.LogDebug("Telegram webhook URL not configured — skipping feedback notification");
|
||||
return;
|
||||
}
|
||||
|
||||
var stars = new string('\u2605', rating) + new string('\u2606', 5 - rating);
|
||||
var commentLine = string.IsNullOrWhiteSpace(comment) ? "" : $"\nComment: {comment}";
|
||||
var payload = new
|
||||
{
|
||||
chat_id = "-1003632442830",
|
||||
text = $"New Feedback!\nRating: {stars} ({rating}/5)\nUser: {displayName} ({upn})\nTenant: {tenantId}{commentLine}"
|
||||
};
|
||||
|
||||
var json = JsonConvert.SerializeObject(payload);
|
||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
var sent = await PostWithRetryAsync(webhookUrl, content, $"feedback:{upn}");
|
||||
|
||||
if (sent)
|
||||
{
|
||||
_logger.LogInformation("Telegram feedback notification sent for user {Upn}", upn);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to send Telegram feedback notification for {Upn}", upn);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string?> GetWebhookUrlAsync()
|
||||
{
|
||||
try
|
||||
|
||||
@@ -242,7 +242,8 @@ namespace AutopilotMonitor.Functions.Services
|
||||
{ "LocalAdminAllowedAccountsJson", config.LocalAdminAllowedAccountsJson },
|
||||
{ "BootstrapTokenEnabled", config.BootstrapTokenEnabled },
|
||||
{ "UnrestrictedModeEnabled", config.UnrestrictedModeEnabled },
|
||||
{ "UnrestrictedMode", config.UnrestrictedMode }
|
||||
{ "UnrestrictedMode", config.UnrestrictedMode },
|
||||
{ "OnboardedAt", config.OnboardedAt }
|
||||
};
|
||||
|
||||
return entity;
|
||||
@@ -303,7 +304,8 @@ namespace AutopilotMonitor.Functions.Services
|
||||
LocalAdminAllowedAccountsJson = entity.GetString("LocalAdminAllowedAccountsJson"),
|
||||
BootstrapTokenEnabled = entity.GetBoolean("BootstrapTokenEnabled") ?? false,
|
||||
UnrestrictedModeEnabled = entity.GetBoolean("UnrestrictedModeEnabled") ?? false,
|
||||
UnrestrictedMode = entity.GetBoolean("UnrestrictedMode") ?? false
|
||||
UnrestrictedMode = entity.GetBoolean("UnrestrictedMode") ?? false,
|
||||
OnboardedAt = entity.GetDateTime("OnboardedAt")
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +72,29 @@ namespace AutopilotMonitor.Shared.Models
|
||||
/// </summary>
|
||||
public int MaintenanceBlockDurationHours { get; set; } = 12;
|
||||
|
||||
// ===== FEEDBACK SETTINGS =====
|
||||
|
||||
/// <summary>
|
||||
/// Global kill-switch for the in-app feedback prompt.
|
||||
/// When false, no user sees the feedback bubble regardless of other settings.
|
||||
/// Default: true.
|
||||
/// </summary>
|
||||
public bool FeedbackEnabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum tenant age in days before users are prompted for feedback.
|
||||
/// Prevents asking brand-new tenants who haven't had meaningful experience yet.
|
||||
/// Default: 14 days.
|
||||
/// </summary>
|
||||
public int FeedbackMinTenantAgeDays { get; set; } = 14;
|
||||
|
||||
/// <summary>
|
||||
/// Cooldown in days after a user interacts with the feedback prompt
|
||||
/// before they are prompted again. 0 = never re-prompt (single wave only).
|
||||
/// Default: 60 days.
|
||||
/// </summary>
|
||||
public int FeedbackCooldownDays { get; set; } = 60;
|
||||
|
||||
// ===== DIAGNOSTICS LOG PATHS =====
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -35,6 +35,13 @@ namespace AutopilotMonitor.Shared.Models
|
||||
|
||||
// ===== TENANT STATUS =====
|
||||
|
||||
/// <summary>
|
||||
/// When this tenant was first onboarded (derived from earliest TenantAdmin AddedDate).
|
||||
/// Used for feedback eligibility checks (tenant must be old enough before prompting).
|
||||
/// Backfilled by the maintenance job for existing tenants; set to UtcNow for new tenants.
|
||||
/// </summary>
|
||||
public DateTime? OnboardedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this tenant is disabled/suspended
|
||||
/// If true, users from this tenant cannot log in
|
||||
@@ -511,7 +518,8 @@ namespace AutopilotMonitor.Shared.Models
|
||||
PerformanceCollectorIntervalSeconds = 30,
|
||||
SelfDestructOnComplete = true,
|
||||
KeepLogFile = false,
|
||||
ShowScriptOutput = true
|
||||
ShowScriptOutput = true,
|
||||
OnboardedAt = DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { API_BASE_URL } from "@/lib/config";
|
||||
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
|
||||
|
||||
interface FeedbackEntry {
|
||||
upn: string;
|
||||
tenantId: string;
|
||||
displayName: string;
|
||||
rating: number | null;
|
||||
comment: string | null;
|
||||
dismissed: boolean;
|
||||
submitted: boolean;
|
||||
interactedAt: string | null;
|
||||
}
|
||||
|
||||
interface FeedbackSectionProps {
|
||||
getAccessToken: () => Promise<string | null>;
|
||||
setError: (error: string | null) => void;
|
||||
}
|
||||
|
||||
export function FeedbackSection({ getAccessToken, setError }: FeedbackSectionProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [entries, setEntries] = useState<FeedbackEntry[]>([]);
|
||||
const [currentPage, setCurrentPage] = useState(0);
|
||||
const entriesPerPage = 5;
|
||||
|
||||
const fetchFeedback = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await authenticatedFetch(`${API_BASE_URL}/api/feedback/all`, getAccessToken);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load feedback: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
// Sort by interactedAt descending (newest first)
|
||||
const sorted = (data.feedback || []).sort((a: FeedbackEntry, b: FeedbackEntry) => {
|
||||
const dateA = a.interactedAt ? new Date(a.interactedAt).getTime() : 0;
|
||||
const dateB = b.interactedAt ? new Date(b.interactedAt).getTime() : 0;
|
||||
return dateB - dateA;
|
||||
});
|
||||
setEntries(sorted);
|
||||
} catch (err) {
|
||||
if (err instanceof TokenExpiredError) {
|
||||
console.error("Session expired while fetching feedback");
|
||||
} else {
|
||||
console.error("Error fetching feedback:", err);
|
||||
}
|
||||
setError(err instanceof Error ? err.message : "Failed to load feedback");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [getAccessToken, setError]);
|
||||
|
||||
useEffect(() => {
|
||||
if (expanded && entries.length === 0) {
|
||||
fetchFeedback();
|
||||
}
|
||||
}, [expanded, entries.length, fetchFeedback]);
|
||||
|
||||
// Stats
|
||||
const submittedEntries = entries.filter(e => e.submitted);
|
||||
const dismissedEntries = entries.filter(e => e.dismissed && !e.submitted);
|
||||
const avgRating = submittedEntries.length > 0
|
||||
? (submittedEntries.reduce((sum, e) => sum + (e.rating || 0), 0) / submittedEntries.length).toFixed(1)
|
||||
: "—";
|
||||
|
||||
// Pagination
|
||||
const totalPages = Math.ceil(entries.length / entriesPerPage);
|
||||
const paginatedEntries = entries.slice(
|
||||
currentPage * entriesPerPage,
|
||||
(currentPage + 1) * entriesPerPage
|
||||
);
|
||||
|
||||
const formatTimeAgo = (dateStr: string | null): string => {
|
||||
if (!dateStr) return "unknown";
|
||||
const diff = Date.now() - new Date(dateStr).getTime();
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days < 7) return `${days}d ago`;
|
||||
const weeks = Math.floor(days / 7);
|
||||
return `${weeks}w ago`;
|
||||
};
|
||||
|
||||
const renderStars = (rating: number | null) => {
|
||||
if (rating == null) return null;
|
||||
return (
|
||||
<span className="inline-flex gap-0.5">
|
||||
{[1, 2, 3, 4, 5].map(s => (
|
||||
<svg
|
||||
key={s}
|
||||
className={`w-4 h-4 ${s <= rating ? "text-yellow-400" : "text-gray-300 dark:text-gray-600"}`}
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
|
||||
</svg>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden border border-purple-200 dark:border-purple-800">
|
||||
{/* Header */}
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="w-full px-6 py-4 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-purple-100 dark:bg-purple-900 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-4 h-4 text-purple-600 dark:text-purple-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">User Feedback</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">In-app feedback from tenant admins and operators</p>
|
||||
</div>
|
||||
</div>
|
||||
<svg
|
||||
className={`w-5 h-5 text-gray-400 transition-transform ${expanded ? "rotate-180" : ""}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Content */}
|
||||
{expanded && (
|
||||
<div className="px-6 pb-6 border-t border-gray-200 dark:border-gray-700">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
|
||||
</div>
|
||||
) : entries.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
No feedback received yet
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Stats */}
|
||||
<div className="flex flex-wrap items-center gap-4 py-4 text-sm">
|
||||
<span className="text-gray-600 dark:text-gray-300">
|
||||
<span className="font-semibold text-purple-600 dark:text-purple-400">{submittedEntries.length}</span> Submitted
|
||||
</span>
|
||||
<span className="text-gray-400">|</span>
|
||||
<span className="text-gray-600 dark:text-gray-300">
|
||||
<span className="font-semibold text-gray-500">{dismissedEntries.length}</span> Dismissed
|
||||
</span>
|
||||
<span className="text-gray-400">|</span>
|
||||
<span className="text-gray-600 dark:text-gray-300">
|
||||
Avg <span className="font-semibold text-yellow-500">{avgRating}</span>
|
||||
</span>
|
||||
<button
|
||||
onClick={fetchFeedback}
|
||||
className="ml-auto text-sm text-purple-600 hover:text-purple-700 dark:text-purple-400 dark:hover:text-purple-300"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Entries */}
|
||||
<div className="space-y-2">
|
||||
{paginatedEntries.map((entry) => (
|
||||
<div
|
||||
key={entry.upn}
|
||||
className={`border rounded-lg p-3 transition-all ${
|
||||
entry.dismissed && !entry.submitted
|
||||
? "bg-gray-50 dark:bg-gray-750 border-gray-200 dark:border-gray-700 opacity-60"
|
||||
: "bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700"
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate max-w-[200px]">
|
||||
{entry.upn}
|
||||
</span>
|
||||
{entry.submitted ? renderStars(entry.rating) : (
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500 italic">dismissed</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span>{formatTimeAgo(entry.interactedAt)}</span>
|
||||
<span className="hidden sm:inline">·</span>
|
||||
<span className="hidden sm:inline truncate max-w-[120px]">
|
||||
{entry.tenantId.substring(0, 8)}...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{entry.comment && (
|
||||
<p className="mt-1 text-sm text-gray-600 dark:text-gray-300 italic">
|
||||
"{entry.comment}"
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between pt-4 border-t border-gray-200 dark:border-gray-700 mt-4">
|
||||
<button
|
||||
onClick={() => setCurrentPage(p => Math.max(0, p - 1))}
|
||||
disabled={currentPage === 0}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-650 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Page {currentPage + 1} of {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setCurrentPage(p => Math.min(totalPages - 1, p + 1))}
|
||||
disabled={currentPage >= totalPages - 1}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-650 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,9 +14,10 @@ import { VersionBlockSection } from "./components/VersionBlockSection";
|
||||
import { SessionExportSection } from "./components/SessionExportSection";
|
||||
import { AdminConfigSettingsSection } from "./components/AdminConfigSettingsSection";
|
||||
import { SessionReportsSection } from "./components/SessionReportsSection";
|
||||
import { FeedbackSection } from "./components/FeedbackSection";
|
||||
import { usePageSections } from "../../hooks/usePageSections";
|
||||
import { PageSectionItem } from "../../contexts/SidebarContext";
|
||||
import { BuildingOfficeIcon, DocumentTextIcon, ArrowDownTrayIcon, GearIcon, FolderIcon, WrenchScrewdriverIcon, NoSymbolIcon, ArrowPathIcon } from "../../lib/sidebarIcons";
|
||||
import { BuildingOfficeIcon, DocumentTextIcon, ArrowDownTrayIcon, GearIcon, FolderIcon, WrenchScrewdriverIcon, NoSymbolIcon, ArrowPathIcon, SparklesIcon } from "../../lib/sidebarIcons";
|
||||
|
||||
interface AdminConfiguration {
|
||||
partitionKey: string;
|
||||
@@ -253,6 +254,7 @@ export default function AdminConfigurationPage() {
|
||||
const adminSections: PageSectionItem[] = useMemo(() => [
|
||||
{ id: "tenant-management", label: "Tenant Management", icon: <BuildingOfficeIcon /> },
|
||||
{ id: "session-reports", label: "Session Reports", icon: <DocumentTextIcon /> },
|
||||
{ id: "user-feedback", label: "User Feedback", icon: <SparklesIcon /> },
|
||||
{ id: "session-export", label: "Session Export", icon: <ArrowDownTrayIcon /> },
|
||||
{ id: "global-settings", label: "Global Settings", icon: <GearIcon /> },
|
||||
{ id: "diagnostics-log-paths", label: "Diagnostics Log Paths", icon: <FolderIcon /> },
|
||||
@@ -331,6 +333,14 @@ export default function AdminConfigurationPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* User Feedback */}
|
||||
<div id="user-feedback">
|
||||
<FeedbackSection
|
||||
getAccessToken={getAccessToken}
|
||||
setError={setError}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Session Event Export */}
|
||||
<div id="session-export">
|
||||
<SessionExportSection
|
||||
|
||||
@@ -633,3 +633,19 @@ html:hover ::-webkit-scrollbar-thumb {
|
||||
.landing-page .dot-pulse {
|
||||
animation: dotPulse 12.6s linear infinite;
|
||||
}
|
||||
|
||||
/* ===== FEEDBACK BUBBLE ANIMATION ===== */
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in-up {
|
||||
animation: fadeInUp 0.3s ease-out both;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { GalacticNotificationProvider } from "../contexts/GalacticNotificationCo
|
||||
import { ThemeProvider } from "../contexts/ThemeContext";
|
||||
import Navbar from "../components/Navbar";
|
||||
import ScrollToTopButton from "../components/ScrollToTopButton";
|
||||
import FeedbackBubble from "../components/FeedbackBubble";
|
||||
import { SidebarProvider } from "../contexts/SidebarContext";
|
||||
import { GlobalSidebar } from "../components/GlobalSidebar";
|
||||
|
||||
@@ -125,6 +126,7 @@ export default function RootLayout({
|
||||
{children}
|
||||
</GlobalSidebar>
|
||||
<ScrollToTopButton />
|
||||
<FeedbackBubble />
|
||||
</SidebarProvider>
|
||||
</SignalRProvider>
|
||||
</TenantProvider>
|
||||
|
||||
@@ -349,33 +349,32 @@ export default function AgentSettingsSection({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Save / Reset */}
|
||||
<div className="flex items-center justify-end space-x-3 pt-4 border-t border-gray-200">
|
||||
<button
|
||||
onClick={onReset}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 border border-gray-300 rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-sm"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
<button
|
||||
onClick={onSave}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 bg-indigo-600 text-white rounded-md text-sm hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center space-x-2"
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
<span>Saving...</span>
|
||||
</>
|
||||
) : (
|
||||
<span>Save</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save / Reset */}
|
||||
<div className="flex items-center justify-end space-x-3">
|
||||
<button
|
||||
onClick={onReset}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 border border-gray-300 rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-sm"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
<button
|
||||
onClick={onSave}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 bg-indigo-600 text-white rounded-md text-sm hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center space-x-2"
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
<span>Saving...</span>
|
||||
</>
|
||||
) : (
|
||||
<span>Save</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { API_BASE_URL } from "@/lib/config";
|
||||
import { authenticatedFetch } from "@/lib/authenticatedFetch";
|
||||
|
||||
type Phase = "loading" | "bubble" | "form" | "thankyou" | "hidden";
|
||||
|
||||
export default function FeedbackBubble() {
|
||||
const { isAuthenticated, user, getAccessToken } = useAuth();
|
||||
const [phase, setPhase] = useState<Phase>("loading");
|
||||
const [rating, setRating] = useState<number>(0);
|
||||
const [hoveredStar, setHoveredStar] = useState<number>(0);
|
||||
const [comment, setComment] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// Check eligibility on mount
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated || !user) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const checkEligibility = async () => {
|
||||
try {
|
||||
const response = await authenticatedFetch(
|
||||
`${API_BASE_URL}/api/feedback/status`,
|
||||
getAccessToken
|
||||
);
|
||||
if (cancelled) return;
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.eligible) {
|
||||
// Show bubble after 3s delay
|
||||
setTimeout(() => {
|
||||
if (!cancelled) setPhase("bubble");
|
||||
}, 3000);
|
||||
} else {
|
||||
setPhase("hidden");
|
||||
}
|
||||
} else {
|
||||
setPhase("hidden");
|
||||
}
|
||||
} catch {
|
||||
setPhase("hidden");
|
||||
}
|
||||
};
|
||||
|
||||
checkEligibility();
|
||||
return () => { cancelled = true; };
|
||||
}, [isAuthenticated, user, getAccessToken]);
|
||||
|
||||
const handleDismiss = useCallback(async () => {
|
||||
setPhase("hidden");
|
||||
try {
|
||||
await authenticatedFetch(`${API_BASE_URL}/api/feedback`, getAccessToken, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ dismissed: true }),
|
||||
});
|
||||
} catch {
|
||||
// Best effort
|
||||
}
|
||||
}, [getAccessToken]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (rating === 0 || submitting) return;
|
||||
setSubmitting(true);
|
||||
|
||||
try {
|
||||
await authenticatedFetch(`${API_BASE_URL}/api/feedback`, getAccessToken, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ rating, comment: comment.trim() || null, dismissed: false }),
|
||||
});
|
||||
|
||||
setPhase("thankyou");
|
||||
setTimeout(() => setPhase("hidden"), 2000);
|
||||
} catch {
|
||||
// Still show thank you — feedback may have been recorded
|
||||
setPhase("thankyou");
|
||||
setTimeout(() => setPhase("hidden"), 2000);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, [rating, comment, submitting, getAccessToken]);
|
||||
|
||||
// Don't render anything while loading or hidden
|
||||
if (phase === "loading" || phase === "hidden") return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-20 right-6 z-40 sm:bottom-20 sm:right-6">
|
||||
{/* Collapsed Bubble */}
|
||||
{phase === "bubble" && (
|
||||
<div
|
||||
className="flex items-center gap-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-full px-4 py-2.5 shadow-lg cursor-pointer hover:shadow-xl transition-all duration-300 animate-fade-in-up"
|
||||
onClick={() => setPhase("form")}
|
||||
>
|
||||
<svg className="w-4 h-4 text-yellow-400 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-200">Feedback</span>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleDismiss(); }}
|
||||
className="ml-1 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 transition-colors"
|
||||
aria-label="Dismiss feedback"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Expanded Form */}
|
||||
{phase === "form" && (
|
||||
<div className="w-80 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl shadow-xl animate-fade-in-up">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 pt-4 pb-2">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
How's your experience?
|
||||
</h3>
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 transition-colors"
|
||||
aria-label="Dismiss feedback"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-4 pb-4 space-y-3">
|
||||
{/* Star Rating */}
|
||||
<div className="flex gap-1">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<button
|
||||
key={star}
|
||||
onClick={() => setRating(star)}
|
||||
onMouseEnter={() => setHoveredStar(star)}
|
||||
onMouseLeave={() => setHoveredStar(0)}
|
||||
className="min-h-[44px] min-w-[44px] flex items-center justify-center transition-transform hover:scale-110"
|
||||
aria-label={`Rate ${star} star${star > 1 ? "s" : ""}`}
|
||||
>
|
||||
<svg
|
||||
className={`w-7 h-7 transition-colors ${
|
||||
star <= (hoveredStar || rating)
|
||||
? "text-yellow-400"
|
||||
: "text-gray-300 dark:text-gray-600"
|
||||
}`}
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
|
||||
</svg>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Comment */}
|
||||
<div>
|
||||
<textarea
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value.slice(0, 500))}
|
||||
placeholder="Any feedback? (optional)"
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 text-sm bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg resize-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none placeholder-gray-400 dark:placeholder-gray-500"
|
||||
/>
|
||||
{comment.length > 0 && (
|
||||
<p className="text-xs text-gray-400 text-right mt-0.5">{comment.length}/500</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={rating === 0 || submitting}
|
||||
className="w-full px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{submitting ? "Sending..." : "Send Feedback"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Thank You */}
|
||||
{phase === "thankyou" && (
|
||||
<div className="flex items-center gap-2 bg-white dark:bg-gray-800 border border-green-200 dark:border-green-700 rounded-full px-4 py-2.5 shadow-lg animate-fade-in-up">
|
||||
<svg className="w-4 h-4 text-green-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-200">
|
||||
Thanks for your feedback!
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user