update:char limits

This commit is contained in:
2026-03-10 18:08:22 +09:00
parent 774a79eef3
commit 0f602a48c6
6 changed files with 161 additions and 63 deletions

View File

@@ -1,6 +1,18 @@
# Personal Support Ticket System
For demo purposes.
For demo purposes. A support ticket system that lets the user experience both the user and admin side features.
# Features
- Auth and Non-Auth'd User States
- Auth Users
- Tickets and replies are stored in database
- Able to see all tickets created by all users in the Admin tab
- Tickets, can create 10 max
- Ticket replies, can reply 20 max per ticket
- Non-Auth Users
- Tickets and replies are stored in localstorage (browser)
- Admin tab only shows localstorage tickets
- Tickets and replies have no creation limit
# Dev
`bun run dev`

View File

@@ -1,6 +1,12 @@
import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from "fastify";
import type { Ticket, TicketType } from "../types.ts";
import { TICKET_LIMIT, REPLY_LIMIT } from "../types.ts";
import {
TICKET_LIMIT,
REPLY_LIMIT,
SUBJECT_MAX_LENGTH,
DESCRIPTION_MAX_LENGTH,
REPLY_MAX_LENGTH,
} from "../types.ts";
import { filterContent, filterBody } from "../middleware/contentFilter.ts";
const PAGE_SIZE = 10;
@@ -70,6 +76,22 @@ export const ticketsRouter: FastifyPluginAsync = async (app) => {
return reply.status(400).send({ error: "subject is required" });
}
if (subject.trim().length > SUBJECT_MAX_LENGTH) {
return reply.status(400).send({
error: "subject_too_long",
message: `Subject must be ${SUBJECT_MAX_LENGTH} characters or fewer.`,
max: SUBJECT_MAX_LENGTH,
});
}
if (description.length > DESCRIPTION_MAX_LENGTH) {
return reply.status(400).send({
error: "description_too_long",
message: `Description must be ${DESCRIPTION_MAX_LENGTH} characters or fewer.`,
max: DESCRIPTION_MAX_LENGTH,
});
}
// Enforce per-user ticket limit
if (req.user?.id) {
const userTicketCount = await req.storage.countTicketsByUser(req.user.id);
@@ -138,6 +160,14 @@ export const ticketsRouter: FastifyPluginAsync = async (app) => {
return reply.status(400).send({ error: "body is required" });
}
if (body.trim().length > REPLY_MAX_LENGTH) {
return reply.status(400).send({
error: "reply_too_long",
message: `Reply must be ${REPLY_MAX_LENGTH} characters or fewer.`,
max: REPLY_MAX_LENGTH,
});
}
const ticket = await req.storage.getTicket(req.params.id);
if (!ticket) return reply.status(404).send({ error: "Not found" });

View File

@@ -1,9 +1,9 @@
export interface User {
id: string
googleId: string
username: string
avatarUrl: string | null
createdAt: string
id: string;
googleId: string;
username: string;
avatarUrl: string | null;
createdAt: string;
}
export type TicketType =
@@ -15,50 +15,67 @@ export type TicketType =
| "other";
export interface Ticket {
id: string
userId: string | null
username: string | null
subject: string
description: string
type: TicketType
status: 'open' | 'in-progress' | 'resolved' | 'closed'
createdAt: string
id: string;
userId: string | null;
username: string | null;
subject: string;
description: string;
type: TicketType;
status: "open" | "in-progress" | "resolved" | "closed";
createdAt: string;
}
export const TICKET_LIMIT = 10
export const REPLY_LIMIT = 20
export const TICKET_LIMIT = 10;
export const REPLY_LIMIT = 20;
export const SUBJECT_MAX_LENGTH = 128;
export const DESCRIPTION_MAX_LENGTH = 2000;
export const REPLY_MAX_LENGTH = 1000;
export interface TicketFilters {
status?: Ticket['status']
type?: TicketType
userId?: string
status?: Ticket["status"];
type?: TicketType;
userId?: string;
}
export interface PaginatedTickets {
data: Ticket[]
total: number
data: Ticket[];
total: number;
}
export interface Reply {
id: string
ticketId: string
userId: string | null
username: string | null
body: string
authorRole: 'user' | 'support'
createdAt: string
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[]>
getTicketsPaginated(limit: number, offset: number, filters?: TicketFilters): Promise<PaginatedTickets>
getTicket(id: string): Promise<Ticket | null>
countTicketsByUser(userId: string): Promise<number>
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[]>
countRepliesByTicket(ticketId: string): Promise<number>
createReply(data: { ticketId: string; body: string; userId?: string; authorRole: Reply['authorRole'] }): Promise<Reply>
getTickets(): Promise<Ticket[]>;
getTicketsByUser(userId: string): Promise<Ticket[]>;
getTicketsPaginated(
limit: number,
offset: number,
filters?: TicketFilters,
): Promise<PaginatedTickets>;
getTicket(id: string): Promise<Ticket | null>;
countTicketsByUser(userId: string): Promise<number>;
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[]>;
countRepliesByTicket(ticketId: string): Promise<number>;
createReply(data: {
ticketId: string;
body: string;
userId?: string;
authorRole: Reply["authorRole"];
}): Promise<Reply>;
}

View File

@@ -2,6 +2,20 @@ import { useState } from 'react'
import { Button } from '../ui/Button.tsx'
import type { Ticket, TicketType } from '../../lib/types.ts'
const SUBJECT_MAX = 128
const DESCRIPTION_MAX = 2000
function CharCount({ current, max }: { current: number; max: number }) {
const remaining = max - current
const isWarning = remaining <= max * 0.1 // warn in the last 10%
const isOver = remaining < 0
return (
<span className={`text-xs tabular-nums ${isOver ? 'text-red-400' : isWarning ? 'text-amber-400' : 'text-fg-300'}`}>
{current}/{max}
</span>
)
}
// ─── Fake transactions ────────────────────────────────────────────────────────
export interface FakeTransaction {
@@ -12,9 +26,9 @@ export interface FakeTransaction {
}
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' },
{ 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' },
]
// ─── Step definitions ─────────────────────────────────────────────────────────
@@ -96,9 +110,8 @@ function StepIndicator({ step, total }: { step: number; total: number }) {
<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'
}`}
className={`h-0.5 flex-1 rounded-full ${i < step ? 'bg-fg-100' : i === step ? 'bg-fg-300' : 'bg-border-100'
}`}
/>
))}
</div>
@@ -128,7 +141,7 @@ function OptionCard({ icon, label, description, onClick }: OptionCardProps) {
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"/>
<path d="M5 3l4 4-4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
)
@@ -269,14 +282,18 @@ export function NewTicketForm({ onSubmit }: NewTicketFormProps) {
</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>
<div className="flex items-center justify-between">
<label className="text-xs font-medium text-fg-200">
Subject <span className="text-fg-300 font-normal">(required)</span>
</label>
<CharCount current={form.subject.length} max={SUBJECT_MAX} />
</div>
<input
className={inputClass}
placeholder="Brief summary of the issue"
value={form.subject}
onChange={e => setForm(f => ({ ...f, subject: e.target.value }))}
maxLength={SUBJECT_MAX}
autoFocus
required
/>
@@ -304,7 +321,7 @@ export function NewTicketForm({ onSubmit }: NewTicketFormProps) {
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"/>
<path d="M2 4l4 4 4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
{selectedTxn && (() => {
@@ -323,14 +340,18 @@ export function NewTicketForm({ onSubmit }: NewTicketFormProps) {
)}
<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>
<div className="flex items-center justify-between">
<label className="text-xs font-medium text-fg-200">
Description <span className="text-fg-300 font-normal">(optional)</span>
</label>
<CharCount current={form.description.length} max={DESCRIPTION_MAX} />
</div>
<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 }))}
maxLength={DESCRIPTION_MAX}
rows={4}
/>
</div>

View File

@@ -9,6 +9,19 @@ import { CloseIcon } from '../icons/close.tsx'
import { TrashIcon } from '../icons/trash.tsx'
import { CircleArrowIcon } from '../icons/circleArrow.tsx'
const REPLY_MAX = 1000
function CharCount({ current, max }: { current: number; max: number }) {
const remaining = max - current
const isWarning = remaining <= max * 0.1
const isOver = remaining < 0
return (
<span className={`text-xs tabular-nums ${isOver ? 'text-red-400' : isWarning ? 'text-amber-400' : 'text-fg-300'}`}>
{current}/{max}
</span>
)
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('en-US', {
month: 'long', day: 'numeric', year: 'numeric',
@@ -147,8 +160,10 @@ function ReplyComposer({ onSend, disabled }: ReplyComposerProps) {
const [error, setError] = useState<string | null>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const isOverLimit = body.length > REPLY_MAX
const handleSend = async () => {
if (!body.trim() || sending) return
if (!body.trim() || sending || isOverLimit) return
setSending(true)
setError(null)
try {
@@ -179,10 +194,12 @@ function ReplyComposer({ onSend, disabled }: ReplyComposerProps) {
disabled={disabled || sending}
placeholder="Write a reply… (⌘Enter to send)"
rows={3}
maxLength={REPLY_MAX}
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}>
<div className="flex items-center justify-between">
<CharCount current={body.length} max={REPLY_MAX} />
<Button onClick={handleSend} disabled={!body.trim() || sending || disabled || isOverLimit}>
{sending ? 'Sending…' : 'Send Reply'}
</Button>
</div>

View File

@@ -55,7 +55,7 @@ export function InfoBar({ user }: InfoBarProps) {
<div className="max-w-4xl px-6 mx-auto">
{isGuest ? (
<div className="flex flex-col sm:flex-row gap-3 sm:gap-5 px-2">
<div>
<div className="w-full">
<p className="text-xs font-medium text-fg-200">Guest mode</p>
<p className="text-xs text-fg-300">
Tickets are stored in your browser (localStorage) only.
@@ -63,7 +63,7 @@ export function InfoBar({ user }: InfoBarProps) {
Admin panel manages only your tickets.
</p>
</div>
<div>
<div className="w-full">
<p className="text-xs font-medium text-fg-200">After signing in</p>
<p className="text-xs text-fg-300">
Tickets are saved to the server persistently.
@@ -74,13 +74,14 @@ export function InfoBar({ user }: InfoBarProps) {
</div>
) : (
<div className="flex flex-col sm:flex-row gap-3 sm:gap-5 px-2">
<div>
<p className="text-xs font-medium text-fg-200">Admin Tab</p>
<div className="w-full">
<p className="text-xs font-medium text-fg-200">Tickets and Admin Tab</p>
<p className="text-xs text-fg-300">
You can view all users' tickets in the Admin tab. Useful for monitoring support requests.
Can create 10 tickets max, each ticket can have 20 replies max.
You can view all user tickets in the Admin tab. Useful for monitoring support requests.
</p>
</div>
<div>
<div className="w-full">
<p className="text-xs font-medium text-fg-200 pr-1">Editing</p>
<p className="text-xs text-fg-300">
You can only edit or manage your own tickets. Other users' tickets are read-only for you.