add:type assigner
This commit is contained in:
1
backend/drizzle/0004_orange_katie_power.sql
Normal file
1
backend/drizzle/0004_orange_katie_power.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `tickets` ADD `userId` text REFERENCES users(id);
|
||||||
185
backend/drizzle/meta/0004_snapshot.json
Normal file
185
backend/drizzle/meta/0004_snapshot.json
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "e6fe4296-6abf-4980-88a2-604afe8a49ba",
|
||||||
|
"prevId": "16b0dd57-a0de-4237-813b-455b4b8c0157",
|
||||||
|
"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": {}
|
||||||
|
},
|
||||||
|
"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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,13 @@
|
|||||||
"when": 1773034525409,
|
"when": 1773034525409,
|
||||||
"tag": "0003_bitter_ink",
|
"tag": "0003_bitter_ink",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 4,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1773040371683,
|
||||||
|
"tag": "0004_orange_katie_power",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { eq } from 'drizzle-orm'
|
import { eq, count } from 'drizzle-orm'
|
||||||
import { db } from '../db/index.ts'
|
import { db } from '../db/index.ts'
|
||||||
import { tickets } from '../db/schema.ts'
|
import { tickets } from '../db/schema.ts'
|
||||||
import type { StorageAdapter, Ticket, TicketType } from '../types.ts'
|
import type { StorageAdapter, Ticket, TicketType } from '../types.ts'
|
||||||
@@ -14,6 +14,14 @@ export class SQLiteAdapter implements StorageAdapter {
|
|||||||
return rows[0] ? toTicket(rows[0]) : null
|
return rows[0] ? toTicket(rows[0]) : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async countTicketsByUser(userId: string): Promise<number> {
|
||||||
|
const result = await db
|
||||||
|
.select({ count: count() })
|
||||||
|
.from(tickets)
|
||||||
|
.where(eq(tickets.userId, userId))
|
||||||
|
return result[0]?.count ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
async createTicket(
|
async createTicket(
|
||||||
data: Pick<Ticket, 'subject' | 'description' | 'type'> & { userId?: string }
|
data: Pick<Ticket, 'subject' | 'description' | 'type'> & { userId?: string }
|
||||||
): Promise<Ticket> {
|
): Promise<Ticket> {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Database } from 'bun:sqlite'
|
import { Database } from 'bun:sqlite'
|
||||||
import { drizzle } from 'drizzle-orm/bun-sqlite'
|
import { drizzle } from 'drizzle-orm/bun-sqlite'
|
||||||
import { tickets } from './schema.ts'
|
import { tickets, users, sessions } from './schema.ts'
|
||||||
|
|
||||||
const sqlite = new Database('app.db')
|
const sqlite = new Database('app.db')
|
||||||
export const db = drizzle(sqlite, { schema: { tickets } })
|
export const db = drizzle(sqlite, { schema: { tickets, users, sessions } })
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
import { int, text, sqliteTable } from "drizzle-orm/sqlite-core";
|
import { int, text, sqliteTable } from "drizzle-orm/sqlite-core";
|
||||||
|
|
||||||
|
export const users = sqliteTable("users", {
|
||||||
|
id: text("id").primaryKey(), // internal UUID
|
||||||
|
googleId: text("googleId").notNull().unique(), // Google's `sub` claim
|
||||||
|
username: text("username").notNull().unique(), // generated: "silent-crimson-falcon"
|
||||||
|
avatarUrl: text("avatarUrl"), // Google profile picture
|
||||||
|
createdAt: text("createdAt").notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
export const tickets = sqliteTable("tickets", {
|
export const tickets = sqliteTable("tickets", {
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
|
userId: text("userId").references(() => users.id, { onDelete: "set null" }),
|
||||||
subject: text("subject").notNull(),
|
subject: text("subject").notNull(),
|
||||||
description: text("description").notNull().default(""),
|
description: text("description").notNull().default(""),
|
||||||
type: text("type", {
|
type: text("type", {
|
||||||
enum: [
|
enum: ["bug", "billing", "account", "feature-request", "feedback", "other"],
|
||||||
"bug",
|
|
||||||
"billing",
|
|
||||||
"account",
|
|
||||||
"feature-request",
|
|
||||||
"feedback",
|
|
||||||
"other",
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
.notNull()
|
.notNull()
|
||||||
.default("other"),
|
.default("other"),
|
||||||
@@ -24,16 +26,8 @@ export const tickets = sqliteTable("tickets", {
|
|||||||
createdAt: text("createdAt").notNull(),
|
createdAt: text("createdAt").notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const users = sqliteTable('users', {
|
export const sessions = sqliteTable("sessions", {
|
||||||
id: text('id').primaryKey(), // internal UUID
|
id: text("id").primaryKey(),
|
||||||
googleId: text('googleId').notNull().unique(), // Google's `sub` claim
|
data: text("data").notNull(),
|
||||||
username: text('username').notNull().unique(), // generated: "silent-crimson-falcon"
|
expiresAt: int("expires_at").notNull(), // unix ms
|
||||||
avatarUrl: text('avatarUrl'), // Google profile picture
|
});
|
||||||
createdAt: text('createdAt').notNull(),
|
|
||||||
})
|
|
||||||
|
|
||||||
export const sessions = sqliteTable('sessions', {
|
|
||||||
id: text('id').primaryKey(),
|
|
||||||
data: text('data').notNull(),
|
|
||||||
expiresAt: int('expires_at').notNull(), // unix ms
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import fp from "fastify-plugin";
|
|
||||||
import type { FastifyPluginAsync } from "fastify";
|
import type { FastifyPluginAsync } from "fastify";
|
||||||
import { OAuth2Client } from "google-auth-library";
|
import { OAuth2Client } from "google-auth-library";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from "fastify";
|
import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from "fastify";
|
||||||
import type { Ticket, TicketType } from "../types.ts";
|
import type { Ticket, TicketType } from "../types.ts";
|
||||||
|
import { TICKET_LIMIT } from "../types.ts";
|
||||||
|
|
||||||
async function requireAuth(req: FastifyRequest, reply: FastifyReply) {
|
async function requireAuth(req: FastifyRequest, reply: FastifyReply) {
|
||||||
if (!req.isAuthenticated) {
|
if (!req.isAuthenticated) {
|
||||||
@@ -8,6 +9,11 @@ async function requireAuth(req: FastifyRequest, reply: FastifyReply) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ticketsRouter: FastifyPluginAsync = async (app) => {
|
export const ticketsRouter: FastifyPluginAsync = async (app) => {
|
||||||
|
// GET /api/tickets/all — admin view, returns all tickets in the system
|
||||||
|
app.get("/all", { preHandler: requireAuth }, async (req) => {
|
||||||
|
return req.storage.getTickets();
|
||||||
|
});
|
||||||
|
|
||||||
// GET /api/tickets
|
// GET /api/tickets
|
||||||
app.get("/", { preHandler: requireAuth }, async (req) => {
|
app.get("/", { preHandler: requireAuth }, async (req) => {
|
||||||
return req.storage.getTickets();
|
return req.storage.getTickets();
|
||||||
@@ -32,6 +38,20 @@ export const ticketsRouter: FastifyPluginAsync = async (app) => {
|
|||||||
if (!subject?.trim()) {
|
if (!subject?.trim()) {
|
||||||
return reply.status(400).send({ error: "subject is required" });
|
return reply.status(400).send({ error: "subject is required" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enforce per-user ticket limit
|
||||||
|
if (req.user?.id) {
|
||||||
|
const userTicketCount = await req.storage.countTicketsByUser(req.user.id);
|
||||||
|
if (userTicketCount >= TICKET_LIMIT) {
|
||||||
|
return reply.status(429).send({
|
||||||
|
error: "ticket_limit_reached",
|
||||||
|
message: `You have reached the maximum of ${TICKET_LIMIT} support tickets. Please delete some from the admin view before creating new ones.`,
|
||||||
|
limit: TICKET_LIMIT,
|
||||||
|
current: userTicketCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const ticket = await req.storage.createTicket({
|
const ticket = await req.storage.createTicket({
|
||||||
subject: subject.trim(),
|
subject: subject.trim(),
|
||||||
description,
|
description,
|
||||||
|
|||||||
@@ -24,9 +24,12 @@ export interface Ticket {
|
|||||||
createdAt: string
|
createdAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const TICKET_LIMIT = 3
|
||||||
|
|
||||||
export interface StorageAdapter {
|
export interface StorageAdapter {
|
||||||
getTickets(): Promise<Ticket[]>
|
getTickets(): Promise<Ticket[]>
|
||||||
getTicket(id: string): Promise<Ticket | null>
|
getTicket(id: string): Promise<Ticket | null>
|
||||||
|
countTicketsByUser(userId: string): Promise<number>
|
||||||
createTicket(data: Pick<Ticket, 'subject' | 'description' | 'type'> & { userId?: string }): Promise<Ticket>
|
createTicket(data: Pick<Ticket, 'subject' | 'description' | 'type'> & { userId?: string }): Promise<Ticket>
|
||||||
updateTicket(id: string, patch: Partial<Ticket>): Promise<Ticket | null>
|
updateTicket(id: string, patch: Partial<Ticket>): Promise<Ticket | null>
|
||||||
deleteTicket(id: string): Promise<void>
|
deleteTicket(id: string): Promise<void>
|
||||||
|
|||||||
@@ -7,6 +7,14 @@ function formatDate(iso: string): string {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse transaction reference encoded by NewTicketForm into the description
|
||||||
|
// Format: "[Transaction: TXN-XXXXX — Label $X.XX on Date]\n\n..."
|
||||||
|
function parseTransaction(description: string): { txnLine: string; body: string } | null {
|
||||||
|
const match = description.match(/^\[Transaction: ([^\]]+)\]\n?\n?(.*)$/s)
|
||||||
|
if (!match) return null
|
||||||
|
return { txnLine: match[1].trim(), body: match[2].trim() }
|
||||||
|
}
|
||||||
|
|
||||||
interface AdminTableProps {
|
interface AdminTableProps {
|
||||||
tickets: Ticket[]
|
tickets: Ticket[]
|
||||||
}
|
}
|
||||||
@@ -20,12 +28,14 @@ export function AdminTable({ tickets }: AdminTableProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasBilling = tickets.some(t => t.type === 'billing')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-hidden rounded-lg border border-border-100">
|
<div className="overflow-hidden rounded-lg border border-border-100">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-border-100 bg-bg-200">
|
<tr className="border-b border-border-100 bg-bg-200">
|
||||||
{(['Subject', 'Type', 'Status', 'Description', 'Created'] as const).map(col => (
|
{(['Subject', 'Type', 'Status', ...(hasBilling ? ['Transaction'] : []), 'Description', 'Created'] as const).map(col => (
|
||||||
<th
|
<th
|
||||||
key={col}
|
key={col}
|
||||||
className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-fg-300"
|
className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-fg-300"
|
||||||
@@ -36,27 +46,43 @@ export function AdminTable({ tickets }: AdminTableProps) {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-border-100 bg-bg-100">
|
<tbody className="divide-y divide-border-100 bg-bg-100">
|
||||||
{tickets.map(ticket => (
|
{tickets.map(ticket => {
|
||||||
<tr key={ticket.id} className="transition-colors hover:bg-bg-200">
|
const txn = ticket.type === 'billing' ? parseTransaction(ticket.description) : null
|
||||||
<td className="px-4 py-3 font-medium text-fg-100">
|
const displayDescription = txn ? txn.body : ticket.description
|
||||||
{ticket.subject}
|
|
||||||
</td>
|
return (
|
||||||
<td className="px-4 py-3 text-xs capitalize text-fg-200">
|
<tr key={ticket.id} className="transition-colors hover:bg-bg-200">
|
||||||
{ticket.type.replace('-', ' ')}
|
<td className="px-4 py-3 font-medium text-fg-100">
|
||||||
</td>
|
{ticket.subject}
|
||||||
<td className="px-4 py-3">
|
</td>
|
||||||
<Badge status={ticket.status} />
|
<td className="px-4 py-3 text-xs capitalize text-fg-200">
|
||||||
</td>
|
{ticket.type.replace('-', ' ')}
|
||||||
<td className="max-w-xs px-4 py-3 text-xs text-fg-300">
|
</td>
|
||||||
<span className="line-clamp-2">
|
<td className="px-4 py-3">
|
||||||
{ticket.description || <span className="italic">No description</span>}
|
<Badge status={ticket.status} />
|
||||||
</span>
|
</td>
|
||||||
</td>
|
{hasBilling && (
|
||||||
<td className="whitespace-nowrap px-4 py-3 text-xs text-fg-300">
|
<td className="px-4 py-3 text-xs text-fg-200 whitespace-nowrap">
|
||||||
{formatDate(ticket.createdAt)}
|
{txn ? (
|
||||||
</td>
|
<span className="inline-flex items-center gap-1.5 rounded-md border border-border-100 bg-bg-300 px-2 py-1 font-mono text-fg-200">
|
||||||
</tr>
|
{txn.txnLine.split(' — ')[0]}
|
||||||
))}
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-fg-300 italic">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
<td className="max-w-xs px-4 py-3 text-xs text-fg-300">
|
||||||
|
<span className="line-clamp-2">
|
||||||
|
{displayDescription || <span className="italic">No description</span>}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-4 py-3 text-xs text-fg-300">
|
||||||
|
{formatDate(ticket.createdAt)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,20 +2,139 @@ import { useState } from 'react'
|
|||||||
import { Button } from '../ui/Button.tsx'
|
import { Button } from '../ui/Button.tsx'
|
||||||
import type { Ticket, TicketType } from '../../lib/types.ts'
|
import type { Ticket, TicketType } from '../../lib/types.ts'
|
||||||
|
|
||||||
const TICKET_TYPES: { value: TicketType; label: string }[] = [
|
// ─── Fake transactions ────────────────────────────────────────────────────────
|
||||||
{ value: 'bug', label: 'Bug' },
|
|
||||||
{ value: 'billing', label: 'Billing' },
|
export interface FakeTransaction {
|
||||||
{ value: 'account', label: 'Account' },
|
id: string
|
||||||
{ value: 'feature-request', label: 'Feature Request' },
|
label: string
|
||||||
{ value: 'feedback', label: 'Feedback' },
|
amount: string
|
||||||
{ value: 'other', label: 'Other' },
|
date: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FAKE_TRANSACTIONS: FakeTransaction[] = [
|
||||||
|
{ id: 'TXN-48291', label: 'Pro Plan — Monthly', amount: '$12.00', date: 'Mar 1, 2026' },
|
||||||
|
{ id: 'TXN-47103', label: 'Add-on: Extra Storage', amount: '$4.99', date: 'Feb 15, 2026' },
|
||||||
|
{ id: 'TXN-45882', label: 'Pro Plan — Monthly', amount: '$12.00', date: 'Feb 1, 2026' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const inputClass = `
|
// ─── Step definitions ─────────────────────────────────────────────────────────
|
||||||
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
|
interface Option {
|
||||||
focus:border-border-200 focus:ring-1 focus:ring-ring-100
|
label: string
|
||||||
`
|
description: string
|
||||||
|
icon: string
|
||||||
|
type: TicketType
|
||||||
|
subOptions?: SubOption[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SubOption {
|
||||||
|
label: string
|
||||||
|
description: string
|
||||||
|
icon: string
|
||||||
|
type: TicketType
|
||||||
|
}
|
||||||
|
|
||||||
|
const CATEGORIES: Option[] = [
|
||||||
|
{
|
||||||
|
label: 'Something broke',
|
||||||
|
description: 'Unexpected behavior or errors',
|
||||||
|
icon: '⚠️',
|
||||||
|
type: 'bug',
|
||||||
|
subOptions: [
|
||||||
|
{ label: 'Page or feature not loading', description: 'Blank screens, crashes, or freezes', icon: '🖥️', type: 'bug' },
|
||||||
|
{ label: 'Error message appeared', description: 'Something went wrong unexpectedly', icon: '🔴', type: 'bug' },
|
||||||
|
{ label: 'Data looks wrong', description: 'Missing or incorrect information', icon: '📊', type: 'bug' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Billing or payment',
|
||||||
|
description: 'Charges, invoices, or subscriptions',
|
||||||
|
icon: '💳',
|
||||||
|
type: 'billing',
|
||||||
|
subOptions: [
|
||||||
|
{ label: 'Unexpected charge', description: "A charge I didn't expect or recognise", icon: '❓', type: 'billing' },
|
||||||
|
{ label: 'Refund request', description: "I'd like money returned", icon: '↩️', type: 'billing' },
|
||||||
|
{ label: 'Subscription issue', description: 'Plan, renewal, or upgrade problem', icon: '🔄', type: 'billing' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'My account',
|
||||||
|
description: 'Login, settings, or profile',
|
||||||
|
icon: '👤',
|
||||||
|
type: 'account',
|
||||||
|
subOptions: [
|
||||||
|
{ label: "Can't sign in", description: 'Login or password issues', icon: '🔐', type: 'account' },
|
||||||
|
{ label: 'Profile or settings', description: 'Change name, email, or preferences', icon: '⚙️', type: 'account' },
|
||||||
|
{ label: 'Account access or security', description: 'Suspicious activity or locked out', icon: '🛡️', type: 'account' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Suggest an idea',
|
||||||
|
description: 'Feature requests or improvements',
|
||||||
|
icon: '💡',
|
||||||
|
type: 'feature-request',
|
||||||
|
subOptions: [
|
||||||
|
{ label: 'New feature idea', description: "Something you'd love to see added", icon: '✨', type: 'feature-request' },
|
||||||
|
{ label: 'Improve something existing', description: 'Make a current feature better', icon: '🔧', type: 'feature-request' },
|
||||||
|
{ label: 'Share general feedback', description: 'Thoughts on your experience', icon: '💬', type: 'feedback' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Something else',
|
||||||
|
description: 'Anything not listed above',
|
||||||
|
icon: '📝',
|
||||||
|
type: 'other',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// ─── Sub-components ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function StepIndicator({ step, total }: { step: number; total: number }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5 mb-5">
|
||||||
|
{Array.from({ length: total }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{ transition: 'background-color 0.4s ease' }}
|
||||||
|
className={`h-0.5 flex-1 rounded-full ${
|
||||||
|
i < step ? 'bg-fg-100' : i === step ? 'bg-fg-300' : 'bg-border-100'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OptionCardProps {
|
||||||
|
icon: string
|
||||||
|
label: string
|
||||||
|
description: string
|
||||||
|
onClick: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function OptionCard({ icon, label, description, onClick }: OptionCardProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className="group w-full text-left rounded-lg border border-border-100 bg-bg-200 px-4 py-3.5 transition-all duration-150 cursor-pointer hover:border-border-200 hover:bg-bg-300 flex items-start gap-3"
|
||||||
|
>
|
||||||
|
<span className="mt-0.5 text-lg leading-none select-none">{icon}</span>
|
||||||
|
<div className="flex flex-col gap-0.5 min-w-0">
|
||||||
|
<span className="text-sm font-medium text-fg-100">{label}</span>
|
||||||
|
<span className="text-xs text-fg-300 leading-relaxed">{description}</span>
|
||||||
|
</div>
|
||||||
|
<svg
|
||||||
|
className="ml-auto mt-1 shrink-0 text-fg-300 opacity-0 group-hover:opacity-100 transition-opacity duration-150"
|
||||||
|
width="14" height="14" viewBox="0 0 14 14" fill="none"
|
||||||
|
>
|
||||||
|
<path d="M5 3l4 4-4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main form ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type FormData = Pick<Ticket, 'subject' | 'description' | 'type'>
|
type FormData = Pick<Ticket, 'subject' | 'description' | 'type'>
|
||||||
|
|
||||||
@@ -23,45 +142,213 @@ interface NewTicketFormProps {
|
|||||||
onSubmit: (data: FormData) => void
|
onSubmit: (data: FormData) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Step = 'category' | 'sub' | 'details'
|
||||||
|
|
||||||
|
const inputClass = `
|
||||||
|
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
|
||||||
|
`
|
||||||
|
|
||||||
export function NewTicketForm({ onSubmit }: NewTicketFormProps) {
|
export function NewTicketForm({ onSubmit }: NewTicketFormProps) {
|
||||||
|
const [step, setStep] = useState<Step>('category')
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState<Option | null>(null)
|
||||||
|
const [selectedTxn, setSelectedTxn] = useState<string>('')
|
||||||
const [form, setForm] = useState<FormData>({ subject: '', description: '', type: 'other' })
|
const [form, setForm] = useState<FormData>({ subject: '', description: '', type: 'other' })
|
||||||
|
|
||||||
const set = (field: keyof FormData) =>
|
const isBilling = form.type === 'billing'
|
||||||
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
|
const stepIndex = step === 'category' ? 0 : step === 'sub' ? 1 : selectedCategory?.subOptions ? 2 : 1
|
||||||
setForm(f => ({ ...f, [field]: e.target.value }))
|
const totalSteps = selectedCategory?.subOptions ? 3 : 2
|
||||||
|
|
||||||
|
const handleCategorySelect = (cat: Option) => {
|
||||||
|
setSelectedCategory(cat)
|
||||||
|
if (cat.subOptions) {
|
||||||
|
setStep('sub')
|
||||||
|
} else {
|
||||||
|
setForm(f => ({ ...f, type: cat.type }))
|
||||||
|
setStep('details')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubSelect = (sub: SubOption) => {
|
||||||
|
setForm(f => ({ ...f, type: sub.type }))
|
||||||
|
setStep('details')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
if (step === 'details' && selectedCategory?.subOptions) {
|
||||||
|
setSelectedTxn('')
|
||||||
|
setStep('sub')
|
||||||
|
} else {
|
||||||
|
setSelectedTxn('')
|
||||||
|
setStep('category')
|
||||||
|
setSelectedCategory(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!form.subject.trim()) return
|
if (!form.subject.trim()) return
|
||||||
onSubmit(form)
|
// Encode transaction reference into description if one was selected
|
||||||
|
const txn = FAKE_TRANSACTIONS.find(t => t.id === selectedTxn)
|
||||||
|
const descriptionWithTxn = txn
|
||||||
|
? `[Transaction: ${txn.id} — ${txn.label} ${txn.amount} on ${txn.date}]\n\n${form.description}`.trim()
|
||||||
|
: form.description
|
||||||
|
onSubmit({ ...form, description: descriptionWithTxn })
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
<div className="flex flex-col">
|
||||||
<div className="flex flex-col gap-1.5">
|
<StepIndicator step={stepIndex} total={totalSteps} />
|
||||||
<label className="text-xs font-medium text-fg-200">Subject</label>
|
|
||||||
<input
|
{/* Step 1 — Category */}
|
||||||
className={inputClass}
|
{step === 'category' && (
|
||||||
placeholder="Brief summary of the issue"
|
<div className="flex flex-col gap-2">
|
||||||
value={form.subject}
|
<div className="mb-3">
|
||||||
onChange={set('subject')}
|
<p className="text-sm font-semibold text-fg-100">What can we help you with?</p>
|
||||||
autoFocus
|
<p className="text-xs text-fg-300 mt-0.5">Choose the option that best fits your situation.</p>
|
||||||
required
|
</div>
|
||||||
/>
|
{CATEGORIES.map(cat => (
|
||||||
</div>
|
<OptionCard
|
||||||
<div className="flex flex-col gap-1.5">
|
key={cat.type}
|
||||||
<label className="text-xs font-medium text-fg-200">Description</label>
|
icon={cat.icon}
|
||||||
<textarea
|
label={cat.label}
|
||||||
className={`${inputClass} min-h-24 resize-y`}
|
description={cat.description}
|
||||||
placeholder="Describe the issue in detail..."
|
onClick={() => handleCategorySelect(cat)}
|
||||||
value={form.description}
|
/>
|
||||||
onChange={set('description')}
|
))}
|
||||||
rows={4}
|
</div>
|
||||||
/>
|
)}
|
||||||
</div>
|
|
||||||
<div className="flex justify-end gap-2 pt-1">
|
{/* Step 2 — Sub-options */}
|
||||||
<Button type="submit">Create Ticket</Button>
|
{step === 'sub' && selectedCategory?.subOptions && (
|
||||||
</div>
|
<div className="flex flex-col gap-2">
|
||||||
</form>
|
<div className="mb-3">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="text-base">{selectedCategory.icon}</span>
|
||||||
|
<span className="text-xs font-medium text-fg-300">{selectedCategory.label}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-semibold text-fg-100">What's the issue specifically?</p>
|
||||||
|
<p className="text-xs text-fg-300 mt-0.5">Pick the closest match — we'll route it to the right team.</p>
|
||||||
|
</div>
|
||||||
|
{selectedCategory.subOptions.map(sub => (
|
||||||
|
<OptionCard
|
||||||
|
key={sub.type + sub.label}
|
||||||
|
icon={sub.icon}
|
||||||
|
label={sub.label}
|
||||||
|
description={sub.description}
|
||||||
|
onClick={() => handleSubSelect(sub)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleBack}
|
||||||
|
className="mt-1 text-xs text-fg-300 hover:text-fg-200 transition-colors text-left cursor-pointer"
|
||||||
|
>
|
||||||
|
← Back
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 3 — Details */}
|
||||||
|
{step === 'details' && (
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||||
|
<div className="mb-1">
|
||||||
|
{selectedCategory && (
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="text-base">{selectedCategory.icon}</span>
|
||||||
|
<span className="text-xs font-medium text-fg-300">{selectedCategory.label}</span>
|
||||||
|
<span className="text-xs text-fg-300">·</span>
|
||||||
|
<span className="inline-flex items-center rounded-full border border-border-100 bg-bg-300 px-2 py-0.5 text-xs text-fg-300 capitalize">
|
||||||
|
{form.type.replace('-', ' ')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-sm font-semibold text-fg-100">Tell us what's going on</p>
|
||||||
|
<p className="text-xs text-fg-300 mt-0.5">The more detail you share, the faster we can help.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label className="text-xs font-medium text-fg-200">
|
||||||
|
Subject <span className="text-fg-300 font-normal">(required)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
placeholder="Brief summary of the issue"
|
||||||
|
value={form.subject}
|
||||||
|
onChange={e => setForm(f => ({ ...f, subject: e.target.value }))}
|
||||||
|
autoFocus
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isBilling && (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label className="text-xs font-medium text-fg-200">
|
||||||
|
Related transaction <span className="text-fg-300 font-normal">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<select
|
||||||
|
className={`${inputClass} appearance-none pr-8 cursor-pointer`}
|
||||||
|
value={selectedTxn}
|
||||||
|
onChange={e => setSelectedTxn(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">— Not related to a specific charge</option>
|
||||||
|
{FAKE_TRANSACTIONS.map(txn => (
|
||||||
|
<option key={txn.id} value={txn.id}>
|
||||||
|
{txn.id} · {txn.label} · {txn.amount} · {txn.date}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<svg
|
||||||
|
className="pointer-events-none absolute right-2.5 top-1/2 -translate-y-1/2 text-fg-300"
|
||||||
|
width="12" height="12" viewBox="0 0 12 12" fill="none"
|
||||||
|
>
|
||||||
|
<path d="M2 4l4 4 4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{selectedTxn && (() => {
|
||||||
|
const txn = FAKE_TRANSACTIONS.find(t => t.id === selectedTxn)!
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-border-100 bg-bg-300 px-3 py-2.5 flex items-center justify-between gap-3">
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<span className="text-xs font-medium text-fg-100">{txn.label}</span>
|
||||||
|
<span className="text-xs text-fg-300">{txn.date}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-semibold text-fg-100 shrink-0">{txn.amount}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label className="text-xs font-medium text-fg-200">
|
||||||
|
Description <span className="text-fg-300 font-normal">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
className={`${inputClass} min-h-24 resize-y`}
|
||||||
|
placeholder="Describe what happened, what you expected, and any steps to reproduce..."
|
||||||
|
value={form.description}
|
||||||
|
onChange={e => setForm(f => ({ ...f, description: e.target.value }))}
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between pt-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleBack}
|
||||||
|
className="text-xs text-fg-300 hover:text-fg-200 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
← Back
|
||||||
|
</button>
|
||||||
|
<Button type="submit" disabled={!form.subject.trim()}>
|
||||||
|
Submit Ticket
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export function Modal({ isOpen, onClose, title, children }: ModalProps) {
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-5 py-4">{children}</div>
|
<div className="px-5 py-4 max-h-[70vh] overflow-y-auto">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
document.body
|
document.body
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import type { StorageMode } from '../lib/storage.ts'
|
|
||||||
|
|
||||||
|
export type StorageMode = 'local' | 'api'
|
||||||
export type StorageResolution = StorageMode | 'pending'
|
export type StorageResolution = StorageMode | 'pending'
|
||||||
|
|
||||||
export function useStorageMode(): StorageResolution {
|
export function useStorageMode(): StorageResolution {
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export function LoginPage({ onBack, error }: LoginPageProps) {
|
|||||||
|
|
||||||
<div className="border-t border-border-100 px-6 py-4">
|
<div className="border-t border-border-100 px-6 py-4">
|
||||||
<p className="text-center text-xs text-fg-300">
|
<p className="text-center text-xs text-fg-300">
|
||||||
We only request your public profile — no email address is stored.
|
We only request your public profile — <span className="text-fg-100">no email address is stored.</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,22 +8,92 @@ import { storage } from '../lib/storage.ts'
|
|||||||
import type { Ticket } from '../lib/types.ts'
|
import type { Ticket } from '../lib/types.ts'
|
||||||
import { PlusIcon } from '../components/icons/plus.tsx'
|
import { PlusIcon } from '../components/icons/plus.tsx'
|
||||||
|
|
||||||
|
const TICKET_LIMIT = 3
|
||||||
|
|
||||||
interface UserPageProps {
|
interface UserPageProps {
|
||||||
isAuthenticated: boolean
|
isAuthenticated: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function TicketLimitReached({ onClose, fromServer }: { onClose: () => void; fromServer?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-4 py-4 text-center">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-full border border-border-100 bg-bg-300 text-2xl">
|
||||||
|
🗂️
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<p className="text-sm font-semibold text-fg-100">Ticket limit reached</p>
|
||||||
|
<p className="text-xs leading-relaxed text-fg-300 max-w-xs">
|
||||||
|
{fromServer ? (
|
||||||
|
<>
|
||||||
|
The server rejected your request — you already have{' '}
|
||||||
|
<span className="text-fg-200 font-medium">{TICKET_LIMIT} active support tickets</span>.
|
||||||
|
Your ticket was not created.
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
You can have a maximum of{' '}
|
||||||
|
<span className="text-fg-200 font-medium">{TICKET_LIMIT} active support tickets</span>{' '}
|
||||||
|
at a time.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-full rounded-lg border border-border-100 bg-bg-300 px-4 py-3 text-left">
|
||||||
|
<p className="text-xs font-medium text-fg-200 mb-2">How to free up a slot</p>
|
||||||
|
<ol className="flex flex-col gap-1.5">
|
||||||
|
{[
|
||||||
|
'Switch to the Admin tab',
|
||||||
|
'Find a resolved or closed ticket',
|
||||||
|
'Delete it to make room',
|
||||||
|
].map((step, i) => (
|
||||||
|
<li key={i} className="flex items-center gap-2.5 text-xs text-fg-300">
|
||||||
|
<span className="flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-bg-400 text-[10px] font-medium text-fg-200">
|
||||||
|
{i + 1}
|
||||||
|
</span>
|
||||||
|
{step}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" onClick={onClose} className="mt-1">
|
||||||
|
Got it
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function UserPage({ isAuthenticated }: UserPageProps) {
|
export function UserPage({ isAuthenticated }: UserPageProps) {
|
||||||
const [tickets, setTickets] = useState<Ticket[]>([])
|
const [tickets, setTickets] = useState<Ticket[]>([])
|
||||||
|
const [serverLimitHit, setServerLimitHit] = useState(false)
|
||||||
const newTicketModal = useModal()
|
const newTicketModal = useModal()
|
||||||
|
|
||||||
|
const atLimit = isAuthenticated && tickets.length >= TICKET_LIMIT
|
||||||
|
const showLimitScreen = atLimit || serverLimitHit
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
storage.getTickets().then(setTickets)
|
storage.getTickets().then(setTickets)
|
||||||
}, [isAuthenticated])
|
}, [isAuthenticated])
|
||||||
|
|
||||||
const handleCreate = async (form: Pick<Ticket, 'subject' | 'description' | 'type'>) => {
|
// Reset server limit flag whenever the modal closes
|
||||||
const ticket = await storage.createTicket(form)
|
const handleClose = () => {
|
||||||
setTickets(prev => [ticket, ...prev])
|
|
||||||
newTicketModal.close()
|
newTicketModal.close()
|
||||||
|
setServerLimitHit(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreate = async (form: Pick<Ticket, 'subject' | 'description' | 'type'>) => {
|
||||||
|
if (atLimit) return
|
||||||
|
try {
|
||||||
|
const ticket = await storage.createTicket(form)
|
||||||
|
setTickets(prev => [ticket, ...prev])
|
||||||
|
newTicketModal.close()
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.code === 'ticket_limit_reached') {
|
||||||
|
// Backend rejected — switch the open modal to the limit screen immediately
|
||||||
|
// and re-sync the ticket list so atLimit also becomes true
|
||||||
|
setServerLimitHit(true)
|
||||||
|
storage.getTickets().then(setTickets)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
@@ -37,7 +107,17 @@ export function UserPage({ isAuthenticated }: UserPageProps) {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-lg font-semibold text-fg-100">My Tickets</h1>
|
<h1 className="text-lg font-semibold text-fg-100">My Tickets</h1>
|
||||||
<p className="mt-0.5 text-sm text-fg-300">
|
<p className="mt-0.5 text-sm text-fg-300">
|
||||||
{tickets.length} {tickets.length === 1 ? 'ticket' : 'tickets'}
|
{isAuthenticated ? (
|
||||||
|
<>
|
||||||
|
{tickets.length}{' '}
|
||||||
|
<span className={atLimit ? 'text-amber-400' : 'text-fg-300'}>
|
||||||
|
/ {TICKET_LIMIT}
|
||||||
|
</span>{' '}
|
||||||
|
tickets
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>{tickets.length} {tickets.length === 1 ? 'ticket' : 'tickets'}</>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={newTicketModal.open}>
|
<Button onClick={newTicketModal.open}>
|
||||||
@@ -48,8 +128,15 @@ export function UserPage({ isAuthenticated }: UserPageProps) {
|
|||||||
|
|
||||||
<TicketTable tickets={tickets} onDelete={handleDelete} />
|
<TicketTable tickets={tickets} onDelete={handleDelete} />
|
||||||
|
|
||||||
<Modal isOpen={newTicketModal.isOpen} onClose={newTicketModal.close} title="New Ticket">
|
<Modal
|
||||||
<NewTicketForm onSubmit={handleCreate} />
|
isOpen={newTicketModal.isOpen}
|
||||||
|
onClose={handleClose}
|
||||||
|
title={showLimitScreen ? 'Ticket Limit Reached' : 'New Ticket'}
|
||||||
|
>
|
||||||
|
{showLimitScreen
|
||||||
|
? <TicketLimitReached onClose={handleClose} fromServer={serverLimitHit && !atLimit} />
|
||||||
|
: <NewTicketForm onSubmit={handleCreate} />
|
||||||
|
}
|
||||||
</Modal>
|
</Modal>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user