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

@@ -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.