add:reply system
This commit is contained in:
10
backend/drizzle/0005_flowery_northstar.sql
Normal file
10
backend/drizzle/0005_flowery_northstar.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
CREATE TABLE `ticket_replies` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`ticketId` text NOT NULL,
|
||||
`userId` text,
|
||||
`body` text NOT NULL,
|
||||
`authorRole` text DEFAULT 'user' NOT NULL,
|
||||
`createdAt` text NOT NULL,
|
||||
FOREIGN KEY (`ticketId`) REFERENCES `tickets`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`userId`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null
|
||||
);
|
||||
265
backend/drizzle/meta/0005_snapshot.json
Normal file
265
backend/drizzle/meta/0005_snapshot.json
Normal file
@@ -0,0 +1,265 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "02c5b400-fb4d-49b1-b83f-11db254a496e",
|
||||
"prevId": "e6fe4296-6abf-4980-88a2-604afe8a49ba",
|
||||
"tables": {
|
||||
"sessions": {
|
||||
"name": "sessions",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"data": {
|
||||
"name": "data",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"ticket_replies": {
|
||||
"name": "ticket_replies",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"ticketId": {
|
||||
"name": "ticketId",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"userId": {
|
||||
"name": "userId",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"body": {
|
||||
"name": "body",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"authorRole": {
|
||||
"name": "authorRole",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'user'"
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"ticket_replies_ticketId_tickets_id_fk": {
|
||||
"name": "ticket_replies_ticketId_tickets_id_fk",
|
||||
"tableFrom": "ticket_replies",
|
||||
"tableTo": "tickets",
|
||||
"columnsFrom": [
|
||||
"ticketId"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"ticket_replies_userId_users_id_fk": {
|
||||
"name": "ticket_replies_userId_users_id_fk",
|
||||
"tableFrom": "ticket_replies",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"userId"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"tickets": {
|
||||
"name": "tickets",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"userId": {
|
||||
"name": "userId",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"subject": {
|
||||
"name": "subject",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "''"
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'other'"
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'open'"
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"tickets_userId_users_id_fk": {
|
||||
"name": "tickets_userId_users_id_fk",
|
||||
"tableFrom": "tickets",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"userId"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"users": {
|
||||
"name": "users",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"googleId": {
|
||||
"name": "googleId",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"username": {
|
||||
"name": "username",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"avatarUrl": {
|
||||
"name": "avatarUrl",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"users_googleId_unique": {
|
||||
"name": "users_googleId_unique",
|
||||
"columns": [
|
||||
"googleId"
|
||||
],
|
||||
"isUnique": true
|
||||
},
|
||||
"users_username_unique": {
|
||||
"name": "users_username_unique",
|
||||
"columns": [
|
||||
"username"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,13 @@
|
||||
"when": 1773040371683,
|
||||
"tag": "0004_orange_katie_power",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "6",
|
||||
"when": 1773063585033,
|
||||
"tag": "0005_flowery_northstar",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,15 +1,15 @@
|
||||
import { eq, count, desc, and, type SQL } from "drizzle-orm";
|
||||
import { db } from "../db/index.ts";
|
||||
import { tickets, users } from "../db/schema.ts";
|
||||
import { tickets, users, ticketReplies } from "../db/schema.ts";
|
||||
import type {
|
||||
StorageAdapter,
|
||||
Ticket,
|
||||
TicketType,
|
||||
PaginatedTickets,
|
||||
TicketFilters,
|
||||
Reply,
|
||||
} from "../types.ts";
|
||||
|
||||
// Explicit column selection shared by all ticket queries
|
||||
const ticketSelect = {
|
||||
id: tickets.id,
|
||||
userId: tickets.userId,
|
||||
@@ -21,7 +21,6 @@ const ticketSelect = {
|
||||
username: users.username,
|
||||
};
|
||||
|
||||
// Let TypeScript infer the row type directly from the select shape
|
||||
type TicketRow = {
|
||||
id: string;
|
||||
userId: string | null;
|
||||
@@ -131,6 +130,61 @@ export class SQLiteAdapter implements StorageAdapter {
|
||||
async deleteTicket(id: string): Promise<void> {
|
||||
await db.delete(tickets).where(eq(tickets.id, id));
|
||||
}
|
||||
|
||||
async getReplies(ticketId: string): Promise<Reply[]> {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: ticketReplies.id,
|
||||
ticketId: ticketReplies.ticketId,
|
||||
userId: ticketReplies.userId,
|
||||
body: ticketReplies.body,
|
||||
authorRole: ticketReplies.authorRole,
|
||||
createdAt: ticketReplies.createdAt,
|
||||
username: users.username,
|
||||
})
|
||||
.from(ticketReplies)
|
||||
.leftJoin(users, eq(ticketReplies.userId, users.id))
|
||||
.where(eq(ticketReplies.ticketId, ticketId))
|
||||
.orderBy(ticketReplies.createdAt);
|
||||
return rows.map(r => ({
|
||||
...r,
|
||||
authorRole: r.authorRole as Reply['authorRole'],
|
||||
username: r.username ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
async createReply(data: {
|
||||
ticketId: string;
|
||||
body: string;
|
||||
userId?: string;
|
||||
authorRole: Reply['authorRole'];
|
||||
}): Promise<Reply> {
|
||||
const id = crypto.randomUUID();
|
||||
const now = new Date().toISOString();
|
||||
await db.insert(ticketReplies).values({
|
||||
id,
|
||||
ticketId: data.ticketId,
|
||||
userId: data.userId ?? null,
|
||||
body: data.body,
|
||||
authorRole: data.authorRole,
|
||||
createdAt: now,
|
||||
});
|
||||
const rows = await db
|
||||
.select({
|
||||
id: ticketReplies.id,
|
||||
ticketId: ticketReplies.ticketId,
|
||||
userId: ticketReplies.userId,
|
||||
body: ticketReplies.body,
|
||||
authorRole: ticketReplies.authorRole,
|
||||
createdAt: ticketReplies.createdAt,
|
||||
username: users.username,
|
||||
})
|
||||
.from(ticketReplies)
|
||||
.leftJoin(users, eq(ticketReplies.userId, users.id))
|
||||
.where(eq(ticketReplies.id, id));
|
||||
const row = rows[0]!;
|
||||
return { ...row, authorRole: row.authorRole as Reply['authorRole'], username: row.username ?? null };
|
||||
}
|
||||
}
|
||||
|
||||
function toTicket(row: TicketRow): Ticket {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Database } from 'bun:sqlite'
|
||||
import { drizzle } from 'drizzle-orm/bun-sqlite'
|
||||
import { tickets, users, sessions } from './schema.ts'
|
||||
import { tickets, users, sessions, ticketReplies } from './schema.ts'
|
||||
|
||||
const sqlite = new Database('app.db')
|
||||
export const db = drizzle(sqlite, { schema: { tickets, users, sessions } })
|
||||
export const db = drizzle(sqlite, { schema: { tickets, users, sessions, ticketReplies } })
|
||||
|
||||
@@ -26,6 +26,16 @@ export const tickets = sqliteTable("tickets", {
|
||||
createdAt: text("createdAt").notNull(),
|
||||
});
|
||||
|
||||
export const ticketReplies = sqliteTable("ticket_replies", {
|
||||
id: text("id").primaryKey(),
|
||||
ticketId: text("ticketId").notNull().references(() => tickets.id, { onDelete: "cascade" }),
|
||||
userId: text("userId").references(() => users.id, { onDelete: "set null" }),
|
||||
body: text("body").notNull(),
|
||||
// "user" = ticket owner or guest, "support" = another authenticated user
|
||||
authorRole: text("authorRole", { enum: ["user", "support"] }).notNull().default("user"),
|
||||
createdAt: text("createdAt").notNull(),
|
||||
});
|
||||
|
||||
export const sessions = sqliteTable("sessions", {
|
||||
id: text("id").primaryKey(),
|
||||
data: text("data").notNull(),
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import { Profanity } from "@2toad/profanity";
|
||||
|
||||
// ─── Profanity checker ────────────────────────────────────────────────────────
|
||||
|
||||
// Whole-word mode avoids false positives like "assassin", "classic", "scunthorpe"
|
||||
const profanity = new Profanity({ wholeWord: true });
|
||||
|
||||
// ─── PII redaction patterns ───────────────────────────────────────────────────
|
||||
// Inlined from source inspection of @redactpii/node — pure regex, no dependency needed.
|
||||
// Each entry defines what to match and what label to replace it with.
|
||||
|
||||
const PII_PATTERNS: Array<{ pattern: RegExp; label: string }> = [
|
||||
{
|
||||
// Standard email addresses
|
||||
@@ -67,6 +62,20 @@ export interface ContentFilterRejection {
|
||||
|
||||
export type ContentFilterOutcome = ContentFilterResult | ContentFilterRejection;
|
||||
|
||||
/**
|
||||
* Filters a single body of text (e.g. a reply). Rejects profanity and redacts PII.
|
||||
*/
|
||||
export function filterBody(body: string): ContentFilterOutcome {
|
||||
if (profanity.exists(body)) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "profanity",
|
||||
message: "Your reply contains language that is not allowed. Please revise it before submitting.",
|
||||
};
|
||||
}
|
||||
return { ok: true, subject: "", description: redactPII(body) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs subject and description through the content filter.
|
||||
*
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from "fastify";
|
||||
import type { Ticket, TicketType } from "../types.ts";
|
||||
import { TICKET_LIMIT } from "../types.ts";
|
||||
import { filterContent } from "../middleware/contentFilter.ts";
|
||||
import { filterContent, filterBody } from "../middleware/contentFilter.ts";
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
@@ -116,7 +116,67 @@ export const ticketsRouter: FastifyPluginAsync = async (app) => {
|
||||
return ticket;
|
||||
});
|
||||
|
||||
// DELETE /api/tickets/:id — user may only delete their own tickets
|
||||
// GET /api/tickets/:id/replies
|
||||
app.get<{ Params: { id: string } }>(
|
||||
"/:id/replies",
|
||||
{ preHandler: requireAuth },
|
||||
async (req, reply) => {
|
||||
const ticket = await req.storage.getTicket(req.params.id);
|
||||
if (!ticket) return reply.status(404).send({ error: "Not found" });
|
||||
const replies = await req.storage.getReplies(req.params.id);
|
||||
return replies;
|
||||
},
|
||||
);
|
||||
|
||||
// POST /api/tickets/:id/replies
|
||||
app.post<{
|
||||
Params: { id: string };
|
||||
Body: { body: string; asSupport?: boolean };
|
||||
}>("/:id/replies", { preHandler: requireAuth }, async (req, reply) => {
|
||||
const { body, asSupport = false } = req.body;
|
||||
if (!body?.trim()) {
|
||||
return reply.status(400).send({ error: "body is required" });
|
||||
}
|
||||
|
||||
const ticket = await req.storage.getTicket(req.params.id);
|
||||
if (!ticket) return reply.status(404).send({ error: "Not found" });
|
||||
|
||||
if (ticket.status === "closed") {
|
||||
return reply
|
||||
.status(409)
|
||||
.send({
|
||||
error: "ticket_closed",
|
||||
message: "Cannot reply to a closed ticket.",
|
||||
});
|
||||
}
|
||||
|
||||
// Determine role:
|
||||
// - Guest ticket (userId null): always "support" since only auth'd users can POST
|
||||
// - Owner replying from admin tab with asSupport flag: "support"
|
||||
// - Owner replying normally: "user"
|
||||
// - Non-owner: blocked by canModify on the frontend; defence-in-depth here returns 403
|
||||
const isOwner = ticket.userId === req.user!.id;
|
||||
if (!isOwner && ticket.userId !== null) {
|
||||
return reply.status(403).send({ error: "Forbidden" });
|
||||
}
|
||||
const authorRole: "user" | "support" =
|
||||
ticket.userId === null || asSupport ? "support" : "user";
|
||||
|
||||
const filtered = filterBody(body.trim());
|
||||
if (!filtered.ok) {
|
||||
return reply
|
||||
.status(400)
|
||||
.send({ error: filtered.reason, message: filtered.message });
|
||||
}
|
||||
|
||||
const newReply = await req.storage.createReply({
|
||||
ticketId: req.params.id,
|
||||
body: filtered.description,
|
||||
userId: req.user!.id,
|
||||
authorRole,
|
||||
});
|
||||
return reply.status(201).send(newReply);
|
||||
});
|
||||
app.delete<{ Params: { id: string } }>(
|
||||
"/:id",
|
||||
{ preHandler: requireAuth },
|
||||
|
||||
@@ -38,6 +38,16 @@ export interface PaginatedTickets {
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface Reply {
|
||||
id: string
|
||||
ticketId: string
|
||||
userId: string | null
|
||||
username: string | null
|
||||
body: string
|
||||
authorRole: 'user' | 'support'
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface StorageAdapter {
|
||||
getTickets(): Promise<Ticket[]>
|
||||
getTicketsByUser(userId: string): Promise<Ticket[]>
|
||||
@@ -47,4 +57,6 @@ export interface StorageAdapter {
|
||||
createTicket(data: Pick<Ticket, 'subject' | 'description' | 'type'> & { userId?: string }): Promise<Ticket>
|
||||
updateTicket(id: string, patch: Partial<Ticket>): Promise<Ticket | null>
|
||||
deleteTicket(id: string): Promise<void>
|
||||
getReplies(ticketId: string): Promise<Reply[]>
|
||||
createReply(data: { ticketId: string; body: string; userId?: string; authorRole: Reply['authorRole'] }): Promise<Reply>
|
||||
}
|
||||
|
||||
8
frontend/src/components/icons/circleArrow.tsx
Normal file
8
frontend/src/components/icons/circleArrow.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { IconProps } from "../../lib/types.ts";
|
||||
|
||||
export const CircleArrowIcon = ({ className }: IconProps) => (
|
||||
<svg className={className} viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||
<path d="M1.5 6a4.5 4.5 0 1 0 .9-2.7" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
|
||||
<path d="M1.5 2v2.5H4" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
7
frontend/src/components/icons/close.tsx
Normal file
7
frontend/src/components/icons/close.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { IconProps } from "../../lib/types.ts";
|
||||
|
||||
export const CloseIcon = ({ className }: IconProps) => (
|
||||
<svg viewBox="0 0 16 16" fill="none" className={className}>
|
||||
<path d="M12 4L4 12M4 4l8 8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
7
frontend/src/components/icons/trash.tsx
Normal file
7
frontend/src/components/icons/trash.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { IconProps } from "../../lib/types.ts";
|
||||
|
||||
export const TrashIcon = ({ className }: IconProps) => (
|
||||
<svg className={className} viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||
<path d="M1.5 3h9M4.5 3V2a.5.5 0 0 1 .5-.5h2a.5.5 0 0 1 .5.5v1M5 5.5v3M7 5.5v3M2.5 3l.5 7a.5.5 0 0 0 .5.5h5a.5.5 0 0 0 .5-.5l.5-7" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
@@ -1,8 +1,13 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { Badge } from '../ui/Badge.tsx'
|
||||
import { Button } from '../ui/Button.tsx'
|
||||
import { FAKE_TRANSACTIONS } from './NewTicketForm.tsx'
|
||||
import { parseDescription } from '../../lib/ticket.ts'
|
||||
import type { Ticket } from '../../lib/types.ts'
|
||||
import { storage } from '../../lib/storage.ts'
|
||||
import type { Ticket, Reply } from '../../lib/types.ts'
|
||||
import { CloseIcon } from '../icons/close.tsx'
|
||||
import { TrashIcon } from '../icons/trash.tsx'
|
||||
import { CircleArrowIcon } from '../icons/circleArrow.tsx'
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString('en-US', {
|
||||
@@ -10,16 +15,22 @@ function formatDate(iso: string): string {
|
||||
})
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<Ticket['type'], string> = {
|
||||
'bug': 'Bug',
|
||||
'billing': 'Billing',
|
||||
'account': 'Account',
|
||||
'feature-request':'Feature Request',
|
||||
'feedback': 'Feedback',
|
||||
'other': 'Other',
|
||||
function formatTime(iso: string): string {
|
||||
return new Date(iso).toLocaleString('en-US', {
|
||||
month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
const HOLD_DURATION = 2000 // ms
|
||||
const TYPE_LABELS: Record<Ticket['type'], string> = {
|
||||
'bug': 'Bug',
|
||||
'billing': 'Billing',
|
||||
'account': 'Account',
|
||||
'feature-request': 'Feature Request',
|
||||
'feedback': 'Feedback',
|
||||
'other': 'Other',
|
||||
}
|
||||
|
||||
const HOLD_DURATION = 900 // ms
|
||||
|
||||
interface HoldButtonProps {
|
||||
onComplete: () => Promise<void>
|
||||
@@ -61,16 +72,13 @@ function HoldButton({ onComplete, label, completingLabel, icon, ariaLabel }: Hol
|
||||
rafRef.current = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
// Clean up on unmount
|
||||
useEffect(() => () => { if (rafRef.current) cancelAnimationFrame(rafRef.current) }, [])
|
||||
|
||||
// SVG arc helpers
|
||||
const size = 28
|
||||
const stroke = 2.5
|
||||
const r = (size - stroke) / 2
|
||||
const circ = 2 * Math.PI * r
|
||||
const dash = progress * circ
|
||||
|
||||
const isHolding = progress > 0 && progress < 1
|
||||
|
||||
return (
|
||||
@@ -93,32 +101,13 @@ function HoldButton({ onComplete, label, completingLabel, icon, ariaLabel }: Hol
|
||||
`}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{/* Progress ring */}
|
||||
<span className="relative flex items-center justify-center shrink-0" style={{ width: size, height: size }}>
|
||||
{/* Track */}
|
||||
<svg width={size} height={size} className="absolute inset-0 -rotate-90" aria-hidden="true">
|
||||
<circle
|
||||
cx={size / 2} cy={size / 2} r={r}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeOpacity={0.15}
|
||||
strokeWidth={stroke}
|
||||
/>
|
||||
{/* Fill arc */}
|
||||
<circle
|
||||
cx={size / 2} cy={size / 2} r={r}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={stroke}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={`${dash} ${circ}`}
|
||||
style={{ transition: progress === 0 ? 'stroke-dasharray 0.15s ease' : 'none' }}
|
||||
/>
|
||||
<circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke="currentColor" strokeOpacity={0.15} strokeWidth={stroke} />
|
||||
<circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke="currentColor" strokeWidth={stroke} strokeLinecap="round" strokeDasharray={`${dash} ${circ}`} style={{ transition: progress === 0 ? 'stroke-dasharray 0.15s ease' : 'none' }} />
|
||||
</svg>
|
||||
{/* Icon */}
|
||||
{icon}
|
||||
</span>
|
||||
|
||||
<span className="text-xs font-medium">
|
||||
{completing ? completingLabel : isHolding ? 'Keep holding…' : label}
|
||||
</span>
|
||||
@@ -126,46 +115,131 @@ function HoldButton({ onComplete, label, completingLabel, icon, ariaLabel }: Hol
|
||||
)
|
||||
}
|
||||
|
||||
// Close icon (×)
|
||||
const CloseIcon = (
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||
<path d="M1 1l10 10M11 1L1 11" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
|
||||
</svg>
|
||||
)
|
||||
function ReplyBubble({ reply }: { reply: Reply }) {
|
||||
const isSupport = reply.authorRole === 'support'
|
||||
return (
|
||||
<div className={`flex flex-col gap-1 ${isSupport ? 'items-end' : 'items-start'}`}>
|
||||
<div className={`
|
||||
max-w-[85%] rounded-lg px-3.5 py-2.5
|
||||
${isSupport
|
||||
? 'bg-fg-100/10 border border-fg-100/15'
|
||||
: 'bg-bg-300 border border-border-100'
|
||||
}
|
||||
`}>
|
||||
<p className="text-sm text-fg-100 leading-relaxed whitespace-pre-wrap">{reply.body}</p>
|
||||
</div>
|
||||
<p className="text-[10px] text-fg-300 px-1">
|
||||
{isSupport ? 'Support' : (reply.username ?? 'You')} · {formatTime(reply.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Delete icon (trash)
|
||||
const DeleteIcon = (
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||
<path d="M1.5 3h9M4.5 3V2a.5.5 0 0 1 .5-.5h2a.5.5 0 0 1 .5.5v1M5 5.5v3M7 5.5v3M2.5 3l.5 7a.5.5 0 0 0 .5.5h5a.5.5 0 0 0 .5-.5l.5-7" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
)
|
||||
interface ReplyComposerProps {
|
||||
onSend: (body: string) => Promise<void>
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
function ReplyComposer({ onSend, disabled }: ReplyComposerProps) {
|
||||
const [body, setBody] = useState('')
|
||||
const [sending, setSending] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!body.trim() || sending) return
|
||||
setSending(true)
|
||||
setError(null)
|
||||
try {
|
||||
await onSend(body.trim())
|
||||
setBody('')
|
||||
textareaRef.current?.focus()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to send reply.')
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) handleSend()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 border-t border-border-100 pt-4">
|
||||
{error && (
|
||||
<p className="text-xs text-red-400">{error}</p>
|
||||
)}
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={body}
|
||||
onChange={e => setBody(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={disabled || sending}
|
||||
placeholder="Write a reply… (⌘Enter to send)"
|
||||
rows={3}
|
||||
className="w-full rounded-md border border-border-100 bg-bg-300 px-3 py-2 text-sm text-fg-100 placeholder:text-fg-300 outline-none transition-colors focus:border-border-200 focus:ring-1 focus:ring-ring-100 resize-none disabled:opacity-50"
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleSend} disabled={!body.trim() || sending || disabled}>
|
||||
{sending ? 'Sending…' : 'Send Reply'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Reopen icon (arrow rotating back)
|
||||
const ReopenIcon = (
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||
<path d="M1.5 6a4.5 4.5 0 1 0 .9-2.7" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round"/>
|
||||
<path d="M1.5 2v2.5H4" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
interface TicketDetailProps {
|
||||
ticket: Ticket
|
||||
isAuthenticated: boolean
|
||||
canReply: boolean
|
||||
replyAs?: 'user' | 'support' // defaults to 'user'; Admin tab passes 'support'
|
||||
onCloseTicket?: (id: string) => Promise<void>
|
||||
onDeleteTicket?: (id: string) => Promise<void>
|
||||
onReopenTicket?: (id: string) => Promise<void>
|
||||
}
|
||||
|
||||
export function TicketDetail({ ticket, onCloseTicket, onDeleteTicket, onReopenTicket }: TicketDetailProps) {
|
||||
export function TicketDetail({
|
||||
ticket,
|
||||
isAuthenticated,
|
||||
canReply,
|
||||
replyAs = 'user',
|
||||
onCloseTicket,
|
||||
onDeleteTicket,
|
||||
onReopenTicket,
|
||||
}: TicketDetailProps) {
|
||||
const { txnId, body } = parseDescription(ticket.description)
|
||||
const txn = txnId ? FAKE_TRANSACTIONS.find(t => t.id === txnId) ?? null : null
|
||||
const isClosed = ticket.status === 'closed'
|
||||
const hasAnyAction = onCloseTicket || onReopenTicket || onDeleteTicket
|
||||
|
||||
const [replies, setReplies] = useState<Reply[]>([])
|
||||
const [repliesLoading, setRepliesLoading] = useState(true)
|
||||
const threadEndRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setRepliesLoading(true)
|
||||
storage.getReplies(isAuthenticated, ticket.id)
|
||||
.then(setReplies)
|
||||
.finally(() => setRepliesLoading(false))
|
||||
}, [isAuthenticated, ticket.id])
|
||||
|
||||
// Scroll to bottom when new replies arrive
|
||||
useEffect(() => {
|
||||
threadEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [replies.length])
|
||||
|
||||
const handleSendReply = async (replyBody: string) => {
|
||||
const newReply = await storage.createReply(isAuthenticated, ticket.id, replyBody, replyAs === 'support')
|
||||
setReplies(prev => [...prev, newReply])
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-4 max-h-[75vh]">
|
||||
|
||||
{/* Status + meta row */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<div className="flex items-center gap-2 flex-wrap shrink-0">
|
||||
<Badge status={ticket.status} />
|
||||
<span className="text-xs text-fg-300">·</span>
|
||||
<span className="text-xs text-fg-200 capitalize">{TYPE_LABELS[ticket.type]}</span>
|
||||
@@ -173,9 +247,9 @@ export function TicketDetail({ ticket, onCloseTicket, onDeleteTicket, onReopenTi
|
||||
<span className="text-xs text-fg-300">Opened {formatDate(ticket.createdAt)}</span>
|
||||
</div>
|
||||
|
||||
{/* Transaction card — only for billing tickets with a linked txn */}
|
||||
{/* Transaction card */}
|
||||
{txn && (
|
||||
<div className="rounded-md border border-border-100 bg-bg-300 px-3 py-2.5">
|
||||
<div className="rounded-md border border-border-100 bg-bg-300 px-3 py-2.5 shrink-0">
|
||||
<p className="text-xs font-medium text-fg-300 mb-1.5">Linked transaction</p>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
@@ -187,8 +261,8 @@ export function TicketDetail({ ticket, onCloseTicket, onDeleteTicket, onReopenTi
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{/* Original description */}
|
||||
<div className="flex flex-col gap-1.5 shrink-0">
|
||||
<p className="text-xs font-medium text-fg-200">Description</p>
|
||||
{body ? (
|
||||
<p className="text-sm text-fg-100 leading-relaxed whitespace-pre-wrap">{body}</p>
|
||||
@@ -197,8 +271,39 @@ export function TicketDetail({ ticket, onCloseTicket, onDeleteTicket, onReopenTi
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Reply thread — this section scrolls, nothing else does */}
|
||||
<div className="flex flex-col gap-1.5 min-h-0 flex-1">
|
||||
<p className="text-xs font-medium text-fg-200 shrink-0">
|
||||
Replies {!repliesLoading && replies.length > 0 && (
|
||||
<span className="text-fg-300 font-normal">({replies.length})</span>
|
||||
)}
|
||||
</p>
|
||||
<div className="overflow-y-auto flex-1 min-h-[80px]">
|
||||
{repliesLoading ? (
|
||||
<p className="text-xs text-fg-300 py-2">Loading…</p>
|
||||
) : replies.length === 0 ? (
|
||||
<p className="text-xs italic text-fg-300">No replies yet.</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3 py-1 pr-1">
|
||||
{replies.map(r => <ReplyBubble key={r.id} reply={r} />)}
|
||||
<div ref={threadEndRef} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Compose — hidden for read-only viewers and closed tickets */}
|
||||
{canReply && !isClosed && (
|
||||
<ReplyComposer onSend={handleSendReply} />
|
||||
)}
|
||||
{canReply && isClosed && (
|
||||
<p className="text-xs text-fg-300 italic border-t border-border-100 pt-3 shrink-0">
|
||||
This ticket is closed — replies are disabled.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Footer: ticket ID + actions */}
|
||||
<div className="flex items-center justify-between border-t border-border-100 pt-3">
|
||||
<div className="flex items-center justify-between border-t border-border-100 pt-3 shrink-0">
|
||||
<p className="text-xs text-fg-300 font-mono">ID: {ticket.id}</p>
|
||||
<div className="flex items-center gap-1">
|
||||
{!hasAnyAction && (
|
||||
@@ -210,7 +315,7 @@ export function TicketDetail({ ticket, onCloseTicket, onDeleteTicket, onReopenTi
|
||||
onComplete={() => onReopenTicket(ticket.id)}
|
||||
label="Hold to reopen"
|
||||
completingLabel="Reopening…"
|
||||
icon={ReopenIcon}
|
||||
icon={<CircleArrowIcon className="size-4" />}
|
||||
ariaLabel="Hold to reopen ticket"
|
||||
/>
|
||||
) : onCloseTicket ? (
|
||||
@@ -222,7 +327,7 @@ export function TicketDetail({ ticket, onCloseTicket, onDeleteTicket, onReopenTi
|
||||
onComplete={() => onCloseTicket(ticket.id)}
|
||||
label="Hold to close"
|
||||
completingLabel="Closing…"
|
||||
icon={CloseIcon}
|
||||
icon={<CloseIcon className="size-4" />}
|
||||
ariaLabel="Hold to close ticket"
|
||||
/>
|
||||
)
|
||||
@@ -232,7 +337,7 @@ export function TicketDetail({ ticket, onCloseTicket, onDeleteTicket, onReopenTi
|
||||
onComplete={() => onDeleteTicket(ticket.id)}
|
||||
label="Hold to delete"
|
||||
completingLabel="Deleting…"
|
||||
icon={DeleteIcon}
|
||||
icon={<TrashIcon className="size-4" />}
|
||||
ariaLabel="Hold to delete ticket"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { CloseIcon } from '../icons/close'
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean
|
||||
@@ -31,7 +32,7 @@ export function Modal({ isOpen, onClose, title, children }: ModalProps) {
|
||||
aria-labelledby="modal-title"
|
||||
>
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
|
||||
<div className="relative z-10 w-full max-w-md rounded-lg border border-border-100 bg-bg-200 shadow-2xl">
|
||||
<div className="relative z-10 w-full max-w-2xl rounded-lg border border-border-100 bg-bg-200 shadow-2xl">
|
||||
<div className="flex items-center justify-between border-b border-border-100 px-5 py-4">
|
||||
<h2 id="modal-title" className="text-sm font-semibold text-fg-100">{title}</h2>
|
||||
<button
|
||||
@@ -39,12 +40,10 @@ export function Modal({ isOpen, onClose, title, children }: ModalProps) {
|
||||
className="rounded-md p-1 text-fg-300 transition-colors hover:bg-bg-300 hover:text-fg-100 cursor-pointer"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M12 4L4 12M4 4l8 8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
|
||||
</svg>
|
||||
<CloseIcon className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-5 py-4 max-h-[70vh] overflow-y-auto">{children}</div>
|
||||
<div className="px-5 py-4">{children}</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
|
||||
@@ -1,64 +1,61 @@
|
||||
import type { Ticket, TicketType } from "./types";
|
||||
import { env } from "../env";
|
||||
import type { Ticket, TicketType, Reply } from './types'
|
||||
import { env } from '../env'
|
||||
|
||||
const API = env.apiUrl
|
||||
|
||||
const API = env.apiUrl;
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly status: number;
|
||||
readonly code: string;
|
||||
readonly status: number
|
||||
readonly code: string
|
||||
|
||||
constructor(status: number, code: string, message: string) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
this.status = status
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function apiFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const res = await fetch(`${API}${path}`, {
|
||||
...init,
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json", ...(init.headers ?? {}) },
|
||||
});
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', ...(init.headers ?? {}) },
|
||||
})
|
||||
if (!res.ok) {
|
||||
// Try to parse a structured error body; fall back to a generic message
|
||||
let code = `http_${res.status}`;
|
||||
let message = `API error ${res.status}`;
|
||||
let code = `http_${res.status}`
|
||||
let message = `API error ${res.status}`
|
||||
try {
|
||||
const body = await res.json();
|
||||
if (body?.error) code = body.error;
|
||||
if (body?.message) message = body.message;
|
||||
} catch {
|
||||
/* non-JSON body — keep defaults */
|
||||
}
|
||||
throw new ApiError(res.status, code, message);
|
||||
const body = await res.json()
|
||||
if (body?.error) code = body.error
|
||||
if (body?.message) message = body.message
|
||||
} catch { /* non-JSON body — keep defaults */ }
|
||||
throw new ApiError(res.status, code, message)
|
||||
}
|
||||
return res.json();
|
||||
return res.json()
|
||||
}
|
||||
|
||||
const LOCAL_KEY = "support_tickets";
|
||||
|
||||
const LOCAL_KEY = 'support_tickets'
|
||||
|
||||
function localGet(): Ticket[] {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(LOCAL_KEY) ?? "[]");
|
||||
return JSON.parse(localStorage.getItem(LOCAL_KEY) ?? '[]')
|
||||
} catch {
|
||||
return [];
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function localSet(tickets: Ticket[]) {
|
||||
localStorage.setItem(LOCAL_KEY, JSON.stringify(tickets));
|
||||
localStorage.setItem(LOCAL_KEY, JSON.stringify(tickets))
|
||||
}
|
||||
|
||||
export const localAdapter = {
|
||||
getTickets: (): Ticket[] => localGet(),
|
||||
|
||||
createTicket: (data: {
|
||||
subject: string;
|
||||
description: string;
|
||||
type: TicketType;
|
||||
}): Ticket => {
|
||||
createTicket: (data: { subject: string; description: string; type: TicketType }): Ticket => {
|
||||
const ticket: Ticket = {
|
||||
id: crypto.randomUUID(),
|
||||
userId: null,
|
||||
@@ -66,49 +63,80 @@ export const localAdapter = {
|
||||
subject: data.subject,
|
||||
description: data.description,
|
||||
type: data.type,
|
||||
status: "open",
|
||||
status: 'open',
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
localSet([ticket, ...localGet()]);
|
||||
return ticket;
|
||||
}
|
||||
localSet([ticket, ...localGet()])
|
||||
return ticket
|
||||
},
|
||||
|
||||
updateTicket: (id: string, patch: Partial<Ticket>): Ticket | null => {
|
||||
const tickets = localGet();
|
||||
const idx = tickets.findIndex((t) => t.id === id);
|
||||
if (idx === -1) return null;
|
||||
tickets[idx] = { ...tickets[idx], ...patch };
|
||||
localSet(tickets);
|
||||
return tickets[idx];
|
||||
const tickets = localGet()
|
||||
const idx = tickets.findIndex(t => t.id === id)
|
||||
if (idx === -1) return null
|
||||
tickets[idx] = { ...tickets[idx], ...patch }
|
||||
localSet(tickets)
|
||||
return tickets[idx]
|
||||
},
|
||||
|
||||
deleteTicket: (id: string): boolean => {
|
||||
const before = localGet();
|
||||
const after = before.filter((t) => t.id !== id);
|
||||
localSet(after);
|
||||
return after.length < before.length;
|
||||
const before = localGet()
|
||||
const after = before.filter(t => t.id !== id)
|
||||
localSet(after)
|
||||
return after.length < before.length
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
const LOCAL_REPLIES_KEY = 'support_replies'
|
||||
|
||||
function repliesGet(): Reply[] {
|
||||
try { return JSON.parse(localStorage.getItem(LOCAL_REPLIES_KEY) ?? '[]') } catch { return [] }
|
||||
}
|
||||
function repliesSet(replies: Reply[]) {
|
||||
localStorage.setItem(LOCAL_REPLIES_KEY, JSON.stringify(replies))
|
||||
}
|
||||
|
||||
export const localReplyAdapter = {
|
||||
getReplies: (ticketId: string): Reply[] =>
|
||||
repliesGet().filter(r => r.ticketId === ticketId),
|
||||
|
||||
createReply: (ticketId: string, body: string): Reply => {
|
||||
const reply: Reply = {
|
||||
id: crypto.randomUUID(),
|
||||
ticketId,
|
||||
userId: null,
|
||||
username: null,
|
||||
body,
|
||||
authorRole: 'user',
|
||||
createdAt: new Date().toISOString(),
|
||||
}
|
||||
repliesSet([...repliesGet(), reply])
|
||||
return reply
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
data: T[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
totalPages: number
|
||||
}
|
||||
|
||||
export interface TicketFilters {
|
||||
status?: Ticket["status"];
|
||||
type?: TicketType;
|
||||
mine?: boolean; // restrict to the current user's tickets
|
||||
status?: Ticket['status']
|
||||
type?: TicketType
|
||||
mine?: boolean // restrict to the current user's tickets
|
||||
}
|
||||
|
||||
|
||||
export const storage = {
|
||||
// User's own tickets — API when authenticated, localStorage when guest
|
||||
async getTickets(isAuthenticated: boolean): Promise<Ticket[]> {
|
||||
if (!isAuthenticated) return localAdapter.getTickets();
|
||||
return apiFetch<Ticket[]>("/api/tickets");
|
||||
if (!isAuthenticated) return localAdapter.getTickets()
|
||||
return apiFetch<Ticket[]>('/api/tickets')
|
||||
},
|
||||
|
||||
// Admin view — paginated from API when authenticated, sliced localStorage when guest
|
||||
@@ -119,53 +147,59 @@ export const storage = {
|
||||
filters: TicketFilters = {},
|
||||
): Promise<PaginatedResponse<Ticket>> {
|
||||
if (!isAuthenticated) {
|
||||
let all = localAdapter.getTickets();
|
||||
if (filters.status) all = all.filter((t) => t.status === filters.status);
|
||||
if (filters.type) all = all.filter((t) => t.type === filters.type);
|
||||
const start = (page - 1) * pageSize;
|
||||
let all = localAdapter.getTickets()
|
||||
if (filters.status) all = all.filter(t => t.status === filters.status)
|
||||
if (filters.type) all = all.filter(t => t.type === filters.type)
|
||||
const start = (page - 1) * pageSize
|
||||
return {
|
||||
data: all.slice(start, start + pageSize),
|
||||
total: all.length,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.max(1, Math.ceil(all.length / pageSize)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({ page: String(page) });
|
||||
if (filters.status) params.set("status", filters.status);
|
||||
if (filters.type) params.set("type", filters.type);
|
||||
if (filters.mine) params.set("mine", "true");
|
||||
const params = new URLSearchParams({ page: String(page) })
|
||||
if (filters.status) params.set('status', filters.status)
|
||||
if (filters.type) params.set('type', filters.type)
|
||||
if (filters.mine) params.set('mine', 'true')
|
||||
|
||||
return apiFetch<PaginatedResponse<Ticket>>(`/api/tickets/all?${params}`);
|
||||
return apiFetch<PaginatedResponse<Ticket>>(`/api/tickets/all?${params}`)
|
||||
},
|
||||
|
||||
async createTicket(
|
||||
isAuthenticated: boolean,
|
||||
data: { subject: string; description: string; type: TicketType },
|
||||
): Promise<Ticket> {
|
||||
if (!isAuthenticated) return localAdapter.createTicket(data);
|
||||
return apiFetch<Ticket>("/api/tickets", {
|
||||
method: "POST",
|
||||
async createTicket(isAuthenticated: boolean, data: { subject: string; description: string; type: TicketType }): Promise<Ticket> {
|
||||
if (!isAuthenticated) return localAdapter.createTicket(data)
|
||||
return apiFetch<Ticket>('/api/tickets', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
})
|
||||
},
|
||||
|
||||
async updateTicket(
|
||||
isAuthenticated: boolean,
|
||||
id: string,
|
||||
patch: Partial<Ticket>,
|
||||
): Promise<Ticket | null> {
|
||||
if (!isAuthenticated) return localAdapter.updateTicket(id, patch);
|
||||
async updateTicket(isAuthenticated: boolean, id: string, patch: Partial<Ticket>): Promise<Ticket | null> {
|
||||
if (!isAuthenticated) return localAdapter.updateTicket(id, patch)
|
||||
return apiFetch<Ticket>(`/api/tickets/${id}`, {
|
||||
method: "PATCH",
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
})
|
||||
},
|
||||
|
||||
async deleteTicket(isAuthenticated: boolean, id: string): Promise<boolean> {
|
||||
if (!isAuthenticated) return localAdapter.deleteTicket(id);
|
||||
await apiFetch(`/api/tickets/${id}`, { method: "DELETE" });
|
||||
return true;
|
||||
if (!isAuthenticated) return localAdapter.deleteTicket(id)
|
||||
await apiFetch(`/api/tickets/${id}`, { method: 'DELETE' })
|
||||
return true
|
||||
},
|
||||
};
|
||||
|
||||
async getReplies(isAuthenticated: boolean, ticketId: string): Promise<Reply[]> {
|
||||
if (!isAuthenticated) return localReplyAdapter.getReplies(ticketId)
|
||||
return apiFetch<Reply[]>(`/api/tickets/${ticketId}/replies`)
|
||||
},
|
||||
|
||||
async createReply(isAuthenticated: boolean, ticketId: string, body: string, asSupport = false): Promise<Reply> {
|
||||
if (!isAuthenticated) return localReplyAdapter.createReply(ticketId, body)
|
||||
return apiFetch<Reply>(`/api/tickets/${ticketId}/replies`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ body, asSupport }),
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
@@ -17,6 +17,16 @@ export interface Ticket {
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface Reply {
|
||||
id: string
|
||||
ticketId: string
|
||||
userId: string | null
|
||||
username: string | null
|
||||
body: string
|
||||
authorRole: 'user' | 'support'
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface IconProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -311,6 +311,9 @@ export function AdminPage({ isAuthenticated, user }: AdminPageProps) {
|
||||
)}
|
||||
<TicketDetail
|
||||
ticket={selectedTicket}
|
||||
isAuthenticated={isAuthenticated}
|
||||
canReply={canModify(selectedTicket)}
|
||||
replyAs="support"
|
||||
onCloseTicket={canModify(selectedTicket) ? handleCloseTicket : undefined}
|
||||
onReopenTicket={canModify(selectedTicket) ? handleReopenTicket : undefined}
|
||||
onDeleteTicket={canModify(selectedTicket) ? handleDeleteTicket : undefined}
|
||||
|
||||
@@ -191,7 +191,12 @@ export function UserPage({ isAuthenticated }: UserPageProps) {
|
||||
<p className="text-xs leading-relaxed text-red-400">{actionError}</p>
|
||||
</div>
|
||||
)}
|
||||
<TicketDetail ticket={selectedTicket} onCloseTicket={handleCloseTicket} />
|
||||
<TicketDetail
|
||||
ticket={selectedTicket}
|
||||
isAuthenticated={isAuthenticated}
|
||||
canReply={true}
|
||||
onCloseTicket={handleCloseTicket}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
Reference in New Issue
Block a user