Files
cozsweet-frontend-nextjs/src/app/feedback/feedback-screen.tsx
T

252 lines
8.9 KiB
TypeScript

"use client";
import type { ChangeEvent, ComponentType } from "react";
import Image from "next/image";
import {
Bug,
Check,
CheckCircle2,
CreditCard,
ImagePlus,
Lightbulb,
MessageCircleMore,
Send,
Trash2,
} from "lucide-react";
import { BackButton } from "@/app/_components";
import { MobileShell } from "@/app/_components/core";
import type { FeedbackCategory } from "@/data/schemas/feedback";
import {
resolveGlobalRouteContext,
type GlobalReturnToValue,
} from "@/router/global-route-context";
import { useGlobalAppNavigator } from "@/router/use-global-app-navigator";
import {
FEEDBACK_IMAGE_ACCEPT,
FEEDBACK_IMAGE_LIMIT,
} from "./feedback-image";
import {
FEEDBACK_CONTENT_MIN_LENGTH,
FEEDBACK_CONTENT_MAX_LENGTH,
useFeedbackSubmission,
} from "./use-feedback-submission";
import styles from "./feedback-screen.module.css";
interface CategoryOption {
value: FeedbackCategory;
label: string;
icon: ComponentType<{ size?: number; strokeWidth?: number }>;
}
const CATEGORY_OPTIONS: readonly CategoryOption[] = [
{ value: "problem", label: "Problem", icon: Bug },
{ value: "suggestion", label: "Suggestion", icon: Lightbulb },
{ value: "payment", label: "Payment", icon: CreditCard },
{ value: "other", label: "Other", icon: MessageCircleMore },
];
export interface FeedbackScreenProps {
returnTo?: GlobalReturnToValue;
}
export function FeedbackScreen({ returnTo }: FeedbackScreenProps) {
const navigator = useGlobalAppNavigator();
const navigation = resolveGlobalRouteContext(returnTo);
const form = useFeedbackSubmission();
if (form.feedbackId) {
return (
<MobileShell background="#f8f6f4">
<main className={styles.successShell}>
<div className={styles.successGlow} aria-hidden="true" />
<section className={styles.successCard} aria-live="polite">
<span className={styles.successIcon} aria-hidden="true">
<CheckCircle2 size={36} strokeWidth={1.9} />
</span>
<p className={styles.successEyebrow}>Feedback received</p>
<h1 className={styles.successTitle}>Thank you for helping us.</h1>
<p className={styles.successCopy}>
Your report is safely on its way to the CozSweet team.
</p>
<div className={styles.feedbackIdBlock}>
<span>Feedback ID</span>
<strong>{form.feedbackId}</strong>
</div>
<button
type="button"
data-analytics-key="feedback.back_to_sidebar"
className={styles.primaryButton}
onClick={() => navigator.replace(navigation.sidebarUrl)}
>
Back to Sidebar
</button>
</section>
</main>
</MobileShell>
);
}
const handleImageChange = (event: ChangeEvent<HTMLInputElement>) => {
if (event.target.files) void form.addImages(event.target.files);
event.target.value = "";
};
return (
<MobileShell background="#f8f6f4">
<main className={styles.shell}>
<div className={styles.glowOne} aria-hidden="true" />
<div className={styles.glowTwo} aria-hidden="true" />
<header className={styles.header}>
<BackButton
href={navigation.sidebarUrl}
variant="soft"
ariaLabel="Back to sidebar"
analyticsKey="feedback.back_to_sidebar"
/>
<div className={styles.headingBlock}>
<h1 className={styles.title}>Help us improve CozSweet</h1>
<p className={styles.subtitle}>
Tell us what happened. Screenshots help us understand it faster.
</p>
</div>
</header>
<form className={styles.form} onSubmit={form.submit} noValidate>
<fieldset className={styles.fieldset}>
<legend className={styles.fieldLabel}>What is this about?</legend>
<div className={styles.categoryGrid}>
{CATEGORY_OPTIONS.map((option) => {
const Icon = option.icon;
const selected = form.category === option.value;
return (
<label
key={option.value}
className={`${styles.categoryCard} ${selected ? styles.categoryCardSelected : ""}`}
>
<input
type="radio"
name="feedback-category"
value={option.value}
checked={selected}
disabled={form.isSubmitting}
className={styles.visuallyHidden}
onChange={() => form.setCategory(option.value)}
/>
<Icon size={18} strokeWidth={2} />
<span>{option.label}</span>
{selected ? (
<Check className={styles.categoryCheck} size={14} />
) : null}
</label>
);
})}
</div>
</fieldset>
<div className={styles.fieldGroup}>
<label className={styles.fieldLabel} htmlFor="feedback-content">
Describe your feedback
</label>
<p id="feedback-content-hint" className={styles.contentHint}>
Minimum {FEEDBACK_CONTENT_MIN_LENGTH} characters required.
</p>
<div className={styles.textareaFrame}>
<textarea
id="feedback-content"
aria-describedby="feedback-content-hint"
value={form.content}
maxLength={FEEDBACK_CONTENT_MAX_LENGTH}
disabled={form.isSubmitting}
className={styles.textarea}
placeholder="What happened, and what did you expect?"
onChange={(event) => form.setContent(event.target.value)}
/>
<span className={styles.characterCount}>
{form.content.length}/{FEEDBACK_CONTENT_MAX_LENGTH}
</span>
</div>
</div>
<section className={styles.fieldGroup} aria-labelledby="feedback-images-label">
<div className={styles.imageHeading}>
<div>
<h2 id="feedback-images-label" className={styles.fieldLabel}>
Add screenshots <span>Optional</span>
</h2>
<p className={styles.fieldHint}>JPEG, PNG or WebP · up to 5 MB each</p>
</div>
<span className={styles.imageCount}>
{form.images.length}/{FEEDBACK_IMAGE_LIMIT}
</span>
</div>
<div className={styles.imageGrid}>
{form.images.map((image, index) => (
<div className={styles.imagePreview} key={image.id}>
<Image
src={image.previewUrl}
alt={`Feedback screenshot ${index + 1}`}
fill
unoptimized
sizes="120px"
className={styles.previewImage}
/>
<button
type="button"
className={styles.removeImageButton}
disabled={form.isSubmitting}
aria-label={`Remove screenshot ${index + 1}`}
onClick={() => form.removeImage(image.id)}
>
<Trash2 size={15} />
</button>
</div>
))}
{form.images.length < FEEDBACK_IMAGE_LIMIT ? (
<label
className={`${styles.addImageButton} ${form.isPreparingImages ? styles.addImageButtonBusy : ""}`}
>
<input
type="file"
multiple
accept={FEEDBACK_IMAGE_ACCEPT}
disabled={form.isPreparingImages || form.isSubmitting}
className={styles.visuallyHidden}
onChange={handleImageChange}
/>
<ImagePlus size={23} strokeWidth={1.8} />
<span>{form.isPreparingImages ? "Preparing..." : "Add image"}</span>
</label>
) : null}
</div>
</section>
<div className={styles.statusSlot} aria-live="polite">
{form.errorMessage ? (
<p className={styles.errorMessage} role="alert">
{form.errorMessage}
</p>
) : null}
</div>
<button
type="submit"
data-analytics-key="feedback.submit"
data-analytics-label="Submit feedback"
className={styles.primaryButton}
disabled={!form.canSubmit}
>
<Send size={18} strokeWidth={2.1} aria-hidden="true" />
<span>{form.isSubmitting ? "Sending feedback..." : "Send feedback"}</span>
</button>
</form>
</main>
</MobileShell>
);
}