diff --git a/screenshot/support-ticket-dashboard-desktop.png b/screenshot/support-ticket-dashboard-desktop.png
new file mode 100644
index 0000000..c6c0aaa
Binary files /dev/null and b/screenshot/support-ticket-dashboard-desktop.png differ
diff --git a/screenshot/support-ticket-dashboard-mobile.png b/screenshot/support-ticket-dashboard-mobile.png
new file mode 100644
index 0000000..1f2bff5
Binary files /dev/null and b/screenshot/support-ticket-dashboard-mobile.png differ
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index cb8f1d8..e4a328f 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -29,10 +29,11 @@ export default function RootLayout({
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 354508f..94c1f87 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -31,6 +31,13 @@ export default function HomePage() {
Open
+
+
Support Tickets
+
+ Review a production-style support queue with SLA, priority, and customer context.
+
+
Open
+
Bug Fix Challenges
diff --git a/src/app/support-tickets/page.tsx b/src/app/support-tickets/page.tsx
new file mode 100644
index 0000000..927f012
--- /dev/null
+++ b/src/app/support-tickets/page.tsx
@@ -0,0 +1,334 @@
+"use client";
+
+import { useMemo, useState } from "react";
+import { mockSupportTickets, type SupportTicket } from "@/data/mock-support-tickets";
+
+const priorityOrder = {
+ Critical: 0,
+ High: 1,
+ Medium: 2,
+ Low: 3,
+} satisfies Record;
+
+const statusFilters = ["All", "Open", "Waiting", "Resolved"] as const;
+const priorityFilters = ["All", "Critical", "High", "Medium", "Low"] as const;
+
+type StatusFilter = typeof statusFilters[number];
+type PriorityFilter = typeof priorityFilters[number];
+
+function formatAge(hours: number) {
+ if (hours < 1) return "Just now";
+ if (hours < 24) return `${hours}h ago`;
+ return `${Math.round(hours / 24)}d ago`;
+}
+
+function formatMinutes(minutes: number) {
+ if (minutes < 60) return `${minutes}m`;
+ return `${Math.round(minutes / 60)}h`;
+}
+
+function isAtRisk(ticket: SupportTicket) {
+ return ticket.status !== "Resolved" && ticket.lastReplyMinutesAgo > ticket.responseTargetMinutes;
+}
+
+function priorityClass(priority: SupportTicket["priority"]) {
+ switch (priority) {
+ case "Critical":
+ return "border-red-200 bg-red-50 text-red-700 dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-200";
+ case "High":
+ return "border-orange-200 bg-orange-50 text-orange-700 dark:border-orange-500/30 dark:bg-orange-500/10 dark:text-orange-200";
+ case "Medium":
+ return "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-500/30 dark:bg-blue-500/10 dark:text-blue-200";
+ case "Low":
+ return "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-200";
+ }
+}
+
+function statusClass(status: SupportTicket["status"]) {
+ switch (status) {
+ case "Open":
+ return "border-violet-200 bg-violet-50 text-violet-700 dark:border-violet-500/30 dark:bg-violet-500/10 dark:text-violet-200";
+ case "Waiting":
+ return "border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-200";
+ case "Resolved":
+ return "border-slate-200 bg-slate-100 text-slate-700 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-200";
+ }
+}
+
+function scoreTicket(ticket: SupportTicket) {
+ const slaPenalty = isAtRisk(ticket) ? 60 : (ticket.lastReplyMinutesAgo / ticket.responseTargetMinutes) * 20;
+ const valueBoost = Math.min(ticket.accountValue / 1000, 40);
+ const priorityBoost = (4 - priorityOrder[ticket.priority]) * 15;
+ return Math.round(slaPenalty + valueBoost + priorityBoost);
+}
+
+export default function SupportTicketsPage() {
+ const [statusFilter, setStatusFilter] = useState("All");
+ const [priorityFilter, setPriorityFilter] = useState("All");
+ const [sortMode, setSortMode] = useState("risk");
+ const [selectedId, setSelectedId] = useState(mockSupportTickets[0].id);
+
+ const tickets = useMemo(() => {
+ return mockSupportTickets
+ .filter((ticket) => statusFilter === "All" || ticket.status === statusFilter)
+ .filter((ticket) => priorityFilter === "All" || ticket.priority === priorityFilter)
+ .sort((a, b) => {
+ if (sortMode === "priority") return priorityOrder[a.priority] - priorityOrder[b.priority];
+ if (sortMode === "value") return b.accountValue - a.accountValue;
+ if (sortMode === "newest") return a.createdHoursAgo - b.createdHoursAgo;
+ return scoreTicket(b) - scoreTicket(a);
+ });
+ }, [priorityFilter, sortMode, statusFilter]);
+
+ const selectedTicket = tickets.find((ticket) => ticket.id === selectedId) ?? tickets[0] ?? mockSupportTickets[0];
+ const openTickets = mockSupportTickets.filter((ticket) => ticket.status !== "Resolved");
+ const atRiskTickets = openTickets.filter(isAtRisk);
+ const avgResponse = Math.round(openTickets.reduce((sum, ticket) => sum + ticket.lastReplyMinutesAgo, 0) / openTickets.length);
+ const highValueQueue = openTickets.filter((ticket) => ticket.accountValue >= 7000).length;
+ const channelCounts = mockSupportTickets.reduce>((acc, ticket) => {
+ acc[ticket.channel] = (acc[ticket.channel] ?? 0) + 1;
+ return acc;
+ }, {});
+
+ return (
+
+
+
+
+
+ Support operations
+
+
+
Support Ticket Dashboard
+
+ Prioritize customer issues by SLA risk, account value, owner, and support channel.
+
+
+
+
+
Open Queue
+
{openTickets.length}
+
+
+
SLA Risk
+
{atRiskTickets.length}
+
+
+
Avg Reply
+
{formatMinutes(avgResponse)}
+
+
+
Key Accounts
+
{highValueQueue}
+
+
+
+
+
+
+
Today
+
Risk-weighted routing
+
+
Live mock
+
+
+ {atRiskTickets.slice(0, 3).map((ticket) => (
+
+
+ {ticket.id}
+ {formatMinutes(ticket.lastReplyMinutesAgo)} waiting
+
+
{ticket.subject}
+
+ ))}
+
+
+
+
+
+
+
+
+ {statusFilters.map((status) => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+ {tickets.map((ticket) => (
+
+ ))}
+
+
+
+
+
+
+
+
Workload by owner
+
+ {["Maya", "Ken", "Lina", "Owen"].map((owner) => {
+ const count = openTickets.filter((ticket) => ticket.owner === owner).length;
+ const width = `${Math.max(12, count * 26)}%`;
+ return (
+
+
+ {owner}
+ {count} open
+
+
+
+ );
+ })}
+
+
+
+
Channel mix
+
+ {Object.entries(channelCounts).map(([channel, count]) => (
+
+ {channel}
+ {count}
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/src/components/create-bounty-form.tsx b/src/components/create-bounty-form.tsx
index 9fc27ac..893d81e 100644
--- a/src/components/create-bounty-form.tsx
+++ b/src/components/create-bounty-form.tsx
@@ -14,6 +14,7 @@ export function CreateBountyForm({ onSubmit }: CreateBountyFormProps) {
const [difficulty, setDifficulty] = useState("Easy");
const [submitting, setSubmitting] = useState(false);
const [submissions, setSubmissions] = useState([]);
+ const [errors, setErrors] = useState({ title: "", reward: "" });
const isSubmittingRef = useRef(false);
const handleSubmit = async (e: React.FormEvent) => {
@@ -25,21 +26,17 @@ export function CreateBountyForm({ onSubmit }: CreateBountyFormProps) {
setSubmitting(true);
try {
- // Validation: check for empty title and negative reward
- if (!title.trim()) {
- alert("Title is required");
- isSubmittingRef.current = false;
- setSubmitting(false);
- return;
- }
const rewardNum = Number(reward);
- if (isNaN(rewardNum) || rewardNum <= 0) {
- alert("Reward must be a positive number");
+ const nextErrors = {
+ title: title.trim() ? "" : "Title is required",
+ reward: rewardNum > 0 ? "" : "Reward must be a positive number",
+ };
+ setErrors(nextErrors);
+ if (nextErrors.title || nextErrors.reward) {
isSubmittingRef.current = false;
setSubmitting(false);
return;
}
-
// Simulate API delay
await new Promise((resolve) => setTimeout(resolve, 1000));
@@ -48,12 +45,13 @@ export function CreateBountyForm({ onSubmit }: CreateBountyFormProps) {
onSubmit({
title,
- reward: Number(reward),
+ reward: rewardNum,
difficulty,
});
setTitle("");
setReward("");
+ setErrors({ title: "", reward: "" });
} finally {
isSubmittingRef.current = false;
setSubmitting(false);
diff --git a/src/data/mock-support-tickets.ts b/src/data/mock-support-tickets.ts
new file mode 100644
index 0000000..7f0eeee
--- /dev/null
+++ b/src/data/mock-support-tickets.ts
@@ -0,0 +1,122 @@
+export type SupportTicket = {
+ id: string;
+ subject: string;
+ customer: string;
+ company: string;
+ channel: "Email" | "Chat" | "Portal" | "Slack";
+ status: "Open" | "Waiting" | "Resolved";
+ priority: "Critical" | "High" | "Medium" | "Low";
+ owner: "Maya" | "Ken" | "Lina" | "Owen";
+ createdHoursAgo: number;
+ lastReplyMinutesAgo: number;
+ responseTargetMinutes: number;
+ accountValue: number;
+ summary: string;
+ nextAction: string;
+ tags: string[];
+};
+
+export const mockSupportTickets: SupportTicket[] = [
+ {
+ id: "TCK-1048",
+ subject: "Enterprise SSO callback fails after domain migration",
+ customer: "Ari Morgan",
+ company: "Northstar Labs",
+ channel: "Slack",
+ status: "Open",
+ priority: "Critical",
+ owner: "Maya",
+ createdHoursAgo: 3,
+ lastReplyMinutesAgo: 93,
+ responseTargetMinutes: 45,
+ accountValue: 18000,
+ summary: "The customer moved to a new identity domain and cannot complete the SAML callback in production.",
+ nextAction: "Confirm the ACS URL in the admin panel, then send the customer a tenant-specific metadata file and ask for one fresh login attempt.",
+ tags: ["SSO", "Enterprise", "Authentication"],
+ },
+ {
+ id: "TCK-1047",
+ subject: "Usage export shows duplicate rows for archived workspaces",
+ customer: "Jules Chen",
+ company: "Pioneer Data",
+ channel: "Portal",
+ status: "Open",
+ priority: "High",
+ owner: "Ken",
+ createdHoursAgo: 7,
+ lastReplyMinutesAgo: 72,
+ responseTargetMinutes: 90,
+ accountValue: 9200,
+ summary: "CSV exports include archived workspace events twice when the report covers more than one billing cycle.",
+ nextAction: "Run the billing report against the latest export job and attach the deduped preview before escalating to engineering.",
+ tags: ["Billing", "Exports", "Workspace"],
+ },
+ {
+ id: "TCK-1046",
+ subject: "Webhook retry queue stopped after endpoint timeout",
+ customer: "Priya Nair",
+ company: "Atlas Retail",
+ channel: "Chat",
+ status: "Waiting",
+ priority: "Critical",
+ owner: "Lina",
+ createdHoursAgo: 11,
+ lastReplyMinutesAgo: 138,
+ responseTargetMinutes: 60,
+ accountValue: 12500,
+ summary: "Retry jobs paused after the customer's endpoint returned repeated 504 responses during a flash sale.",
+ nextAction: "Share the retry window, confirm the new endpoint health check, and restart delivery from the failed event cursor.",
+ tags: ["Webhooks", "Reliability", "Retail"],
+ },
+ {
+ id: "TCK-1045",
+ subject: "Dashboard cards load slowly for region-level managers",
+ customer: "Mateo Silva",
+ company: "BrightOps",
+ channel: "Email",
+ status: "Open",
+ priority: "Medium",
+ owner: "Owen",
+ createdHoursAgo: 19,
+ lastReplyMinutesAgo: 41,
+ responseTargetMinutes: 120,
+ accountValue: 4700,
+ summary: "Managers with access to more than 80 locations see the dashboard skeleton for roughly seven seconds.",
+ nextAction: "Ask for the region slug and test the new cached location rollup against the same permission set.",
+ tags: ["Performance", "Dashboard", "Permissions"],
+ },
+ {
+ id: "TCK-1044",
+ subject: "Invoice contact cannot update tax ID from account settings",
+ customer: "Elliot Park",
+ company: "Lumen Grove",
+ channel: "Portal",
+ status: "Resolved",
+ priority: "Low",
+ owner: "Ken",
+ createdHoursAgo: 34,
+ lastReplyMinutesAgo: 18,
+ responseTargetMinutes: 180,
+ accountValue: 1800,
+ summary: "A validation rule blocked tax IDs with a country prefix. The billing profile was updated manually.",
+ nextAction: "No customer action pending. Watch the next invoice sync for the corrected tax ID.",
+ tags: ["Billing", "Settings"],
+ },
+ {
+ id: "TCK-1043",
+ subject: "Mobile upload progress freezes on large PDF attachments",
+ customer: "Sam Rivera",
+ company: "Kite Legal",
+ channel: "Email",
+ status: "Waiting",
+ priority: "High",
+ owner: "Maya",
+ createdHoursAgo: 28,
+ lastReplyMinutesAgo: 166,
+ responseTargetMinutes: 90,
+ accountValue: 7600,
+ summary: "Legal reviewers can upload small files, but large PDF attachments appear stuck at 99 percent on iOS Safari.",
+ nextAction: "Request the affected file size range and confirm whether the signed upload URL expires before the final commit call.",
+ tags: ["Mobile", "Uploads", "Legal"],
+ },
+];