252 lines
8.9 KiB
TypeScript
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>
|
|
);
|
|
}
|