feat(feedback): add problem reporting flow
This commit is contained in:
@@ -0,0 +1,239 @@
|
||||
"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/dto/feedback";
|
||||
import { ROUTES } from "@/router/routes";
|
||||
import { useAppNavigator } from "@/router/use-app-navigator";
|
||||
|
||||
import {
|
||||
FEEDBACK_IMAGE_ACCEPT,
|
||||
FEEDBACK_IMAGE_LIMIT,
|
||||
} from "./feedback-image";
|
||||
import {
|
||||
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 function FeedbackScreen() {
|
||||
const navigator = useAppNavigator();
|
||||
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.eyebrow}>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_chat"
|
||||
className={styles.primaryButton}
|
||||
onClick={() => navigator.replace(ROUTES.chat)}
|
||||
>
|
||||
Back to Chat
|
||||
</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={ROUTES.chat}
|
||||
variant="soft"
|
||||
ariaLabel="Back to chat"
|
||||
analyticsKey="feedback.back_to_chat"
|
||||
/>
|
||||
<div className={styles.headingBlock}>
|
||||
<p className={styles.eyebrow}>Support</p>
|
||||
<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>
|
||||
<div className={styles.textareaFrame}>
|
||||
<textarea
|
||||
id="feedback-content"
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user