feat(feedback): add problem reporting flow
This commit is contained in:
@@ -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 }));
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { FeedbackScreen } from "./feedback-screen";
|
||||
|
||||
export default function FeedbackPage() {
|
||||
return <FeedbackScreen />;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user