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({
MergeFund Hackathon Kit
-
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"], + }, +];