feat(feedback): add problem reporting flow

This commit is contained in:
2026-07-16 18:30:34 +08:00
parent 4981de9b18
commit 37f45f0736
34 changed files with 1866 additions and 4 deletions
@@ -0,0 +1,76 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
FEEDBACK_IMAGE_SOURCE_MAX_BYTES,
fitWithinDimension,
prepareFeedbackImage,
validateFeedbackImage,
} from "../feedback-image";
describe("feedback image preparation", () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it("accepts supported formats and rejects unsupported or oversized files", () => {
expect(
validateFeedbackImage({ type: "image/png", size: 100 } as File),
).toBeNull();
expect(
validateFeedbackImage({ type: "image/gif", size: 100 } as File),
).toBe("Please choose a JPEG, PNG, or WebP image.");
expect(
validateFeedbackImage({
type: "image/jpeg",
size: FEEDBACK_IMAGE_SOURCE_MAX_BYTES + 1,
} as File),
).toBe("Each image must be 5 MB or smaller.");
});
it("fits images within the maximum dimension without stretching", () => {
expect(fitWithinDimension(3200, 1600, 1600)).toEqual({
width: 1600,
height: 800,
});
expect(fitWithinDimension(640, 480, 1600)).toEqual({
width: 640,
height: 480,
});
});
it("resizes and compresses large screenshots", async () => {
const close = vi.fn();
vi.stubGlobal(
"createImageBitmap",
vi.fn(async () => ({ width: 3200, height: 1600, close })),
);
const drawImage = vi.fn();
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({
drawImage,
} as unknown as CanvasRenderingContext2D);
vi.spyOn(HTMLCanvasElement.prototype, "toBlob").mockImplementation(
(callback, type) => {
callback(new Blob(["compressed"], { type: type ?? "image/webp" }));
},
);
const file = new File(
[new Uint8Array(FEEDBACK_IMAGE_SOURCE_MAX_BYTES - 1)],
"screen.png",
{ type: "image/png" },
);
const result = await prepareFeedbackImage(file);
expect(result.name).toBe("screen.webp");
expect(result.type).toBe("image/webp");
expect(drawImage).toHaveBeenCalledWith(
expect.anything(),
0,
0,
1600,
800,
);
expect(close).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,106 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Result } from "@/utils/result";
import { FeedbackScreen } from "../feedback-screen";
const mocks = vi.hoisted(() => ({
replace: vi.fn(),
submitFeedback: vi.fn(),
}));
vi.mock("@/router/use-app-navigator", () => ({
useAppNavigator: () => ({ replace: mocks.replace }),
}));
vi.mock("@/lib/feedback", () => ({
submitFeedback: mocks.submitFeedback,
}));
vi.mock("../feedback-context", () => ({
createFeedbackContext: () => ({
appVersion: "test",
platform: "android",
browser: "chrome",
viewport: "390x844@3",
}),
}));
describe("FeedbackScreen", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
.IS_REACT_ACT_ENVIRONMENT = true;
mocks.replace.mockReset();
mocks.submitFeedback.mockReset();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
it("renders all categories and defaults to Problem", () => {
act(() => root.render(<FeedbackScreen />));
expect(container.textContent).toContain("Help us improve CozSweet");
expect(container.textContent).toContain("Problem");
expect(container.textContent).toContain("Suggestion");
expect(container.textContent).toContain("Payment");
expect(container.textContent).toContain("Other");
expect(
container.querySelector<HTMLInputElement>('input[value="problem"]')
?.checked,
).toBe(true);
});
it("submits valid text and renders the feedback id", async () => {
mocks.submitFeedback.mockResolvedValue(
Result.ok({ feedbackId: "feedback-123" }),
);
act(() => root.render(<FeedbackScreen />));
const textarea = container.querySelector("textarea");
if (!textarea) throw new Error("Expected feedback textarea");
act(() => setTextareaValue(textarea, "The message button stopped working."));
const submit = container.querySelector<HTMLButtonElement>(
'button[type="submit"]',
);
expect(submit?.disabled).toBe(false);
await act(async () => {
submit?.click();
await Promise.resolve();
});
expect(mocks.submitFeedback).toHaveBeenCalledWith({
category: "problem",
content: "The message button stopped working.",
context: {
appVersion: "test",
platform: "android",
browser: "chrome",
viewport: "390x844@3",
},
images: [],
});
expect(container.textContent).toContain("feedback-123");
expect(container.textContent).toContain("Thank you for helping us.");
});
});
function setTextareaValue(element: HTMLTextAreaElement, value: string): void {
const setter = Object.getOwnPropertyDescriptor(
HTMLTextAreaElement.prototype,
"value",
)?.set;
setter?.call(element, value);
element.dispatchEvent(new Event("input", { bubbles: true }));
}
+31
View File
@@ -0,0 +1,31 @@
import packageInfo from "../../../package.json";
import type { FeedbackContext } from "@/data/dto/feedback";
import { BrowserDetector } from "@/utils/browser-detect";
import { PlatformDetector } from "@/utils/platform-detect";
export function createFeedbackContext(): FeedbackContext {
const platform = PlatformDetector.getPlatformInfo();
const browser = BrowserDetector.getBrowserInfo();
const browserName = browser.inAppBrowserName
? `${browser.inAppBrowserName} IAB / ${browser.name}`
: browser.name;
return {
appVersion: packageInfo.version,
platform: joinContextParts([
platform.platform,
platform.osName,
platform.osVersion,
]),
browser: joinContextParts([browserName, browser.version]),
viewport:
typeof window === "undefined"
? "unknown"
: `${window.innerWidth}x${window.innerHeight}@${window.devicePixelRatio}`,
};
}
function joinContextParts(parts: readonly string[]): string {
return parts.filter((part) => part.trim().length > 0).join(" ") || "unknown";
}
+159
View File
@@ -0,0 +1,159 @@
const SUPPORTED_IMAGE_TYPES = new Set([
"image/jpeg",
"image/png",
"image/webp",
]);
export const FEEDBACK_IMAGE_ACCEPT = "image/jpeg,image/png,image/webp";
export const FEEDBACK_IMAGE_LIMIT = 3;
export const FEEDBACK_IMAGE_SOURCE_MAX_BYTES = 5 * 1024 * 1024;
export const FEEDBACK_IMAGE_TARGET_MAX_BYTES = 2 * 1024 * 1024;
export const FEEDBACK_IMAGE_MAX_DIMENSION = 1600;
const OUTPUT_QUALITIES = [0.86, 0.74, 0.62, 0.5] as const;
const DIMENSION_CAPS = [1600, 1400, 1200, 1000, 800] as const;
interface DecodedImage {
width: number;
height: number;
source: CanvasImageSource;
dispose: () => void;
}
export function validateFeedbackImage(file: File): string | null {
if (!SUPPORTED_IMAGE_TYPES.has(file.type)) {
return "Please choose a JPEG, PNG, or WebP image.";
}
if (file.size > FEEDBACK_IMAGE_SOURCE_MAX_BYTES) {
return "Each image must be 5 MB or smaller.";
}
return null;
}
export async function prepareFeedbackImage(file: File): Promise<File> {
const validationError = validateFeedbackImage(file);
if (validationError) throw new Error(validationError);
const decoded = await decodeImage(file);
try {
const longestEdge = Math.max(decoded.width, decoded.height);
if (
longestEdge <= FEEDBACK_IMAGE_MAX_DIMENSION &&
file.size <= FEEDBACK_IMAGE_TARGET_MAX_BYTES
) {
return file;
}
const mimeType = file.type === "image/jpeg" ? "image/jpeg" : "image/webp";
let smallestBlob: Blob | null = null;
const renderedSizes = new Set<string>();
for (const dimensionCap of DIMENSION_CAPS) {
const size = fitWithinDimension(
decoded.width,
decoded.height,
dimensionCap,
);
const sizeKey = `${size.width}x${size.height}`;
if (renderedSizes.has(sizeKey)) continue;
renderedSizes.add(sizeKey);
const canvas = document.createElement("canvas");
canvas.width = size.width;
canvas.height = size.height;
const context = canvas.getContext("2d");
if (!context) throw new Error("This browser cannot process images.");
context.drawImage(decoded.source, 0, 0, size.width, size.height);
for (const quality of OUTPUT_QUALITIES) {
const blob = await canvasToBlob(canvas, mimeType, quality);
if (!smallestBlob || blob.size < smallestBlob.size) smallestBlob = blob;
if (blob.size <= FEEDBACK_IMAGE_TARGET_MAX_BYTES) {
return toFeedbackFile(blob, file.name);
}
}
}
if (smallestBlob && smallestBlob.size <= FEEDBACK_IMAGE_TARGET_MAX_BYTES) {
return toFeedbackFile(smallestBlob, file.name);
}
throw new Error("This image is still too large after compression.");
} finally {
decoded.dispose();
}
}
export function fitWithinDimension(
width: number,
height: number,
maxDimension: number,
): { width: number; height: number } {
const scale = Math.min(1, maxDimension / Math.max(width, height));
return {
width: Math.max(1, Math.round(width * scale)),
height: Math.max(1, Math.round(height * scale)),
};
}
async function decodeImage(file: File): Promise<DecodedImage> {
if (typeof createImageBitmap === "function") {
try {
const bitmap = await createImageBitmap(file, {
imageOrientation: "from-image",
});
return {
width: bitmap.width,
height: bitmap.height,
source: bitmap,
dispose: () => bitmap.close(),
};
} catch {
// Some WebViews expose createImageBitmap but cannot decode every format.
}
}
const objectUrl = URL.createObjectURL(file);
try {
const image = await new Promise<HTMLImageElement>((resolve, reject) => {
const element = new Image();
element.onload = () => resolve(element);
element.onerror = () => reject(new Error("This image could not be read."));
element.src = objectUrl;
});
return {
width: image.naturalWidth,
height: image.naturalHeight,
source: image,
dispose: () => URL.revokeObjectURL(objectUrl),
};
} catch (error) {
URL.revokeObjectURL(objectUrl);
throw error;
}
}
function canvasToBlob(
canvas: HTMLCanvasElement,
mimeType: string,
quality: number,
): Promise<Blob> {
return new Promise((resolve, reject) => {
canvas.toBlob(
(blob) => {
if (blob) resolve(blob);
else reject(new Error("This image could not be compressed."));
},
mimeType,
quality,
);
});
}
function toFeedbackFile(blob: Blob, originalName: string): File {
const extension = blob.type === "image/jpeg" ? "jpg" : "webp";
const baseName = originalName.replace(/\.[^.]+$/, "") || "feedback-image";
return new File([blob], `${baseName}.${extension}`, {
type: blob.type,
lastModified: Date.now(),
});
}
+500
View File
@@ -0,0 +1,500 @@
.shell,
.successShell {
position: relative;
min-height: var(--app-viewport-height, 100dvh);
box-sizing: border-box;
overflow: hidden;
color: #21191c;
background:
radial-gradient(circle at 88% 4%, rgba(246, 87, 160, 0.14), transparent 28%),
radial-gradient(circle at 4% 66%, rgba(219, 181, 137, 0.13), transparent 30%),
linear-gradient(180deg, #f8f6f4 0%, #fffafa 48%, #ffffff 100%);
}
.shell {
padding:
calc(var(--app-safe-top, 0px) + 18px)
calc(var(--app-safe-right, 0px) + clamp(18px, 5vw, 28px))
calc(var(--app-safe-bottom, 0px) + 28px)
calc(var(--app-safe-left, 0px) + clamp(18px, 5vw, 28px));
}
.glowOne,
.glowTwo,
.successGlow {
position: absolute;
pointer-events: none;
border-radius: 999px;
filter: blur(3px);
}
.glowOne {
top: -74px;
right: -88px;
width: 210px;
height: 210px;
background: rgba(246, 87, 160, 0.09);
}
.glowTwo {
bottom: 12%;
left: -110px;
width: 220px;
height: 220px;
background: rgba(208, 164, 112, 0.08);
}
.header,
.form {
position: relative;
z-index: 1;
}
.header {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: start;
gap: 14px;
margin: 4px 0 22px;
}
.headingBlock {
min-width: 0;
padding-top: 2px;
}
.eyebrow {
margin: 0 0 6px;
color: #d74382;
font-size: 11px;
font-weight: 850;
letter-spacing: 0.13em;
text-transform: uppercase;
}
.title,
.successTitle {
margin: 0;
color: #21191c;
font-family: Georgia, "Times New Roman", serif;
font-size: clamp(27px, 7vw, 36px);
font-weight: 650;
line-height: 1.08;
letter-spacing: -0.025em;
}
.subtitle {
max-width: 390px;
margin: 10px 0 0;
color: #75666c;
font-size: 14px;
font-weight: 560;
line-height: 1.5;
}
.form {
display: flex;
flex-direction: column;
gap: 19px;
padding: clamp(18px, 4.8vw, 24px);
border: 1px solid rgba(45, 31, 36, 0.065);
border-radius: 28px;
background: rgba(255, 255, 255, 0.88);
box-shadow: 0 20px 52px rgba(70, 48, 55, 0.09);
backdrop-filter: blur(18px);
}
.fieldset,
.fieldGroup {
min-width: 0;
margin: 0;
padding: 0;
border: 0;
}
.fieldGroup {
display: flex;
flex-direction: column;
gap: 9px;
}
.fieldLabel {
margin: 0 0 10px;
color: #32272b;
font-size: 14px;
font-weight: 790;
line-height: 1.25;
}
h2.fieldLabel {
margin-bottom: 3px;
}
.fieldLabel span {
margin-left: 4px;
color: #9a8b90;
font-size: 11px;
font-weight: 680;
text-transform: uppercase;
}
.categoryGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 9px;
}
.categoryCard {
position: relative;
display: flex;
min-height: 46px;
align-items: center;
gap: 8px;
box-sizing: border-box;
padding: 10px 12px;
border: 1px solid #ebe3e5;
border-radius: 16px;
background: #fbf9f8;
color: #74666b;
cursor: pointer;
font-size: 13px;
font-weight: 720;
transition: border-color 0.16s ease, background 0.16s ease, color 0.16s ease, transform 0.16s ease;
}
.categoryCard:hover {
border-color: rgba(246, 87, 160, 0.28);
transform: translateY(-1px);
}
.categoryCard:has(input:focus-visible) {
outline: 2px solid #f657a0;
outline-offset: 2px;
}
.categoryCardSelected {
border-color: rgba(246, 87, 160, 0.62);
background: linear-gradient(135deg, #fff6fa, #fffaf7);
color: #d74382;
box-shadow: 0 7px 18px rgba(246, 87, 160, 0.1);
}
.categoryCheck {
margin-left: auto;
}
.textareaFrame {
position: relative;
}
.textarea {
display: block;
width: 100%;
min-height: 142px;
resize: vertical;
box-sizing: border-box;
padding: 14px 14px 36px;
border: 1px solid #e9e0e3;
border-radius: 19px;
outline: none;
background: #fbf9f8;
color: #2f2529;
font: inherit;
font-size: 15px;
line-height: 1.52;
transition: border-color 0.16s ease, background 0.16s ease, box-shadow 0.16s ease;
}
.textarea::placeholder {
color: #aaa0a3;
}
.textarea:focus {
border-color: rgba(246, 87, 160, 0.62);
background: #ffffff;
box-shadow: 0 0 0 4px rgba(246, 87, 160, 0.08);
}
.characterCount {
position: absolute;
right: 13px;
bottom: 11px;
color: #9b8d92;
font-size: 11px;
font-weight: 680;
}
.imageHeading {
display: flex;
align-items: end;
justify-content: space-between;
gap: 12px;
}
.fieldHint {
margin: 0;
color: #998b90;
font-size: 11px;
font-weight: 570;
}
.imageCount {
flex: 0 0 auto;
color: #8d7c82;
font-size: 12px;
font-weight: 760;
}
.imageGrid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 9px;
}
.imagePreview,
.addImageButton {
position: relative;
aspect-ratio: 1;
overflow: hidden;
border-radius: 17px;
}
.imagePreview {
background: #eee8e9;
box-shadow: inset 0 0 0 1px rgba(50, 35, 41, 0.06);
}
.previewImage {
object-fit: cover;
}
.removeImageButton {
position: absolute;
top: 6px;
right: 6px;
display: inline-flex;
width: 28px;
height: 28px;
align-items: center;
justify-content: center;
padding: 0;
border: 1px solid rgba(255, 255, 255, 0.32);
border-radius: 999px;
background: rgba(28, 21, 24, 0.72);
color: #ffffff;
cursor: pointer;
backdrop-filter: blur(8px);
}
.removeImageButton:focus-visible,
.addImageButton:has(input:focus-visible) {
outline: 2px solid #f657a0;
outline-offset: 2px;
}
.addImageButton {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 6px;
box-sizing: border-box;
border: 1px dashed rgba(215, 67, 130, 0.35);
background: linear-gradient(145deg, #fff8fb, #faf7f5);
color: #d74382;
cursor: pointer;
font-size: 11px;
font-weight: 760;
transition: border-color 0.16s ease, background 0.16s ease, transform 0.16s ease;
}
.addImageButton:hover {
border-color: rgba(215, 67, 130, 0.68);
background: #fff4f9;
transform: translateY(-1px);
}
.addImageButtonBusy {
cursor: wait;
opacity: 0.68;
}
.statusSlot {
min-height: 18px;
margin: -5px 0 -7px;
}
.errorMessage {
margin: 0;
color: #c43e55;
font-size: 12px;
font-weight: 650;
line-height: 1.4;
}
.primaryButton {
display: inline-flex;
width: 100%;
min-height: 52px;
align-items: center;
justify-content: center;
gap: 9px;
box-sizing: border-box;
padding: 12px 22px;
border: 0;
border-radius: 999px;
background: linear-gradient(100deg, #f84d96 0%, #ff6ea8 55%, #f28b72 120%);
color: #ffffff;
cursor: pointer;
font: inherit;
font-size: 15px;
font-weight: 820;
box-shadow: 0 13px 28px rgba(248, 77, 150, 0.27);
transition: filter 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease;
}
.primaryButton:hover:not(:disabled) {
filter: brightness(1.03);
box-shadow: 0 16px 32px rgba(248, 77, 150, 0.34);
transform: translateY(-1px);
}
.primaryButton:active:not(:disabled) {
transform: translateY(1px) scale(0.99);
}
.primaryButton:focus-visible {
outline: 2px solid #f657a0;
outline-offset: 3px;
}
.primaryButton:disabled {
cursor: not-allowed;
opacity: 0.52;
box-shadow: none;
}
.visuallyHidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
}
.successShell {
display: grid;
place-items: center;
padding:
calc(var(--app-safe-top, 0px) + 28px)
calc(var(--app-safe-right, 0px) + 24px)
calc(var(--app-safe-bottom, 0px) + 28px)
calc(var(--app-safe-left, 0px) + 24px);
}
.successGlow {
width: 330px;
height: 330px;
background: rgba(246, 87, 160, 0.12);
filter: blur(12px);
}
.successCard {
position: relative;
z-index: 1;
width: 100%;
max-width: 420px;
box-sizing: border-box;
padding: clamp(28px, 8vw, 42px) clamp(22px, 7vw, 36px);
border: 1px solid rgba(45, 31, 36, 0.07);
border-radius: 30px;
background: rgba(255, 255, 255, 0.92);
text-align: center;
box-shadow: 0 26px 70px rgba(70, 48, 55, 0.12);
backdrop-filter: blur(20px);
}
.successIcon {
display: inline-flex;
width: 72px;
height: 72px;
align-items: center;
justify-content: center;
margin-bottom: 20px;
border-radius: 24px;
background: linear-gradient(145deg, #fff0f7, #fff8f1);
color: #e34d8b;
box-shadow: 0 12px 28px rgba(246, 87, 160, 0.14);
}
.successCopy {
margin: 14px auto 24px;
color: #76666c;
font-size: 14px;
line-height: 1.55;
}
.feedbackIdBlock {
display: flex;
flex-direction: column;
gap: 5px;
margin-bottom: 24px;
padding: 13px 16px;
border-radius: 16px;
background: #faf6f7;
color: #8f7f85;
font-size: 10px;
font-weight: 780;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.feedbackIdBlock strong {
overflow-wrap: anywhere;
color: #45373c;
font-size: 13px;
letter-spacing: 0.03em;
text-transform: none;
}
@media (max-width: 350px) {
.shell {
padding-right: calc(var(--app-safe-right, 0px) + 14px);
padding-left: calc(var(--app-safe-left, 0px) + 14px);
}
.form {
padding: 16px;
border-radius: 24px;
}
.categoryGrid {
grid-template-columns: 1fr;
}
}
@media (prefers-reduced-motion: no-preference) {
.headingBlock,
.form,
.successCard {
animation: reveal 0.38s ease both;
}
.form {
animation-delay: 60ms;
}
}
@keyframes reveal {
from {
opacity: 0;
transform: translateY(9px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
+239
View File
@@ -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>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { FeedbackScreen } from "./feedback-screen";
export default function FeedbackPage() {
return <FeedbackScreen />;
}
+152
View File
@@ -0,0 +1,152 @@
"use client";
import { type FormEvent, useEffect, useRef, useState } from "react";
import type { FeedbackCategory } from "@/data/dto/feedback";
import { submitFeedback } from "@/lib/feedback";
import { createFeedbackContext } from "./feedback-context";
import {
FEEDBACK_IMAGE_LIMIT,
prepareFeedbackImage,
} from "./feedback-image";
export const FEEDBACK_CONTENT_MIN_LENGTH = 10;
export const FEEDBACK_CONTENT_MAX_LENGTH = 2000;
export interface FeedbackImageItem {
id: string;
file: File;
previewUrl: string;
}
export function useFeedbackSubmission() {
const [category, setCategory] = useState<FeedbackCategory>("problem");
const [content, setContent] = useState("");
const [images, setImages] = useState<FeedbackImageItem[]>([]);
const [isPreparingImages, setIsPreparingImages] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [feedbackId, setFeedbackId] = useState<string | null>(null);
const imagesRef = useRef(images);
useEffect(() => {
imagesRef.current = images;
}, [images]);
useEffect(
() => () => {
imagesRef.current.forEach((image) => URL.revokeObjectURL(image.previewUrl));
},
[],
);
const addImages = async (files: FileList | readonly File[]) => {
if (isPreparingImages || isSubmitting) return;
const availableSlots = FEEDBACK_IMAGE_LIMIT - images.length;
if (availableSlots <= 0) {
setErrorMessage(`You can add up to ${FEEDBACK_IMAGE_LIMIT} images.`);
return;
}
const selectedFiles = Array.from(files);
const filesToPrepare = selectedFiles.slice(0, availableSlots);
const nextImages: FeedbackImageItem[] = [];
const errors: string[] = [];
setIsPreparingImages(true);
setErrorMessage(null);
for (const file of filesToPrepare) {
try {
const preparedFile = await prepareFeedbackImage(file);
nextImages.push({
id: createImageId(),
file: preparedFile,
previewUrl: URL.createObjectURL(preparedFile),
});
} catch (error) {
errors.push(error instanceof Error ? error.message : "Image processing failed.");
}
}
if (selectedFiles.length > availableSlots) {
errors.push(`You can add up to ${FEEDBACK_IMAGE_LIMIT} images.`);
}
setImages((current) => [...current, ...nextImages]);
setErrorMessage(errors[0] ?? null);
setIsPreparingImages(false);
};
const removeImage = (id: string) => {
if (isSubmitting) return;
setImages((current) => {
const image = current.find((item) => item.id === id);
if (image) URL.revokeObjectURL(image.previewUrl);
return current.filter((item) => item.id !== id);
});
setErrorMessage(null);
};
const submit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (isSubmitting || isPreparingImages) return;
const normalizedContent = content.trim();
const validationError = validateFeedbackContent(normalizedContent);
if (validationError) {
setErrorMessage(validationError);
return;
}
setIsSubmitting(true);
setErrorMessage(null);
const result = await submitFeedback({
category,
content: normalizedContent,
context: createFeedbackContext(),
images: images.map((image) => image.file),
});
if (result.success) {
setFeedbackId(result.data.feedbackId);
} else {
setErrorMessage(result.error.message || "Feedback could not be sent. Please try again.");
}
setIsSubmitting(false);
};
return {
category,
content,
images,
isPreparingImages,
isSubmitting,
errorMessage,
feedbackId,
canSubmit:
validateFeedbackContent(content.trim()) === null &&
!isPreparingImages &&
!isSubmitting,
setCategory,
setContent,
addImages,
removeImage,
submit,
};
}
export function validateFeedbackContent(content: string): string | null {
if (content.length < FEEDBACK_CONTENT_MIN_LENGTH) {
return `Please enter at least ${FEEDBACK_CONTENT_MIN_LENGTH} characters.`;
}
if (content.length > FEEDBACK_CONTENT_MAX_LENGTH) {
return `Feedback cannot exceed ${FEEDBACK_CONTENT_MAX_LENGTH} characters.`;
}
return null;
}
function createImageId(): string {
return typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
@@ -112,6 +112,11 @@
background: rgba(255, 255, 255, 0.9);
}
.feedbackCard {
border-color: rgba(208, 150, 91, 0.16);
background: rgba(255, 253, 250, 0.92);
}
.logoutCard:hover {
background: #ffffff;
box-shadow: 0 16px 34px rgba(55, 36, 44, 0.1);
@@ -144,6 +149,11 @@
color: #f657a0;
}
.feedbackIcon {
background: rgba(208, 150, 91, 0.13);
color: #b8783f;
}
.logoutText {
display: flex;
min-width: 0;
@@ -165,6 +175,13 @@
line-height: 1.15;
}
.feedbackTitle {
color: #171114;
font-size: var(--responsive-body, 16px);
font-weight: 760;
line-height: 1.15;
}
.logoutSubtitle {
color: #817076;
font-size: var(--responsive-caption, 13px);
+22 -1
View File
@@ -1,6 +1,6 @@
"use client";
import { Download, LogOut } from "lucide-react";
import { Download, LogOut, MessageCircleQuestion } from "lucide-react";
import { signOut } from "next-auth/react";
import { BackButton } from "@/app/_components";
@@ -99,6 +99,27 @@ export function SidebarScreen() {
<section className={`${styles.settingsSlot} ${styles.revealThree}`}>
<p className={styles.settingsLabel}>Other</p>
<div className={styles.settingsActions}>
<button
type="button"
data-analytics-key="sidebar.open_feedback"
data-analytics-label="Open feedback"
className={`${styles.logoutCard} ${styles.feedbackCard}`}
onClick={() => navigator.push(ROUTES.feedback)}
>
<span
className={`${styles.logoutIcon} ${styles.feedbackIcon}`}
aria-hidden="true"
>
<MessageCircleQuestion size={19} strokeWidth={2.2} />
</span>
<span className={styles.logoutText}>
<span className={styles.feedbackTitle}>Feedback</span>
<span className={styles.logoutSubtitle}>
Report a problem or share an idea
</span>
</span>
</button>
{pwaInstallEntry.canInstall ? (
<button
type="button"