diff --git a/barrelsby.json b/barrelsby.json index 38c15d5b..2ece9f97 100644 --- a/barrelsby.json +++ b/barrelsby.json @@ -14,6 +14,8 @@ "./src/data/services/api", "./src/data/dto/auth", "./src/data/dto/chat", + "./src/data/dto/feedback", + "./src/data/dto/feedback/response", "./src/data/dto/metrics", "./src/data/dto/user", "./src/data/schemas/auth", @@ -22,6 +24,8 @@ "./src/data/schemas/chat", "./src/data/schemas/chat/request", "./src/data/schemas/chat/response", + "./src/data/schemas/feedback", + "./src/data/schemas/feedback/response", "./src/data/schemas/metrics", "./src/data/schemas/metrics/request", "./src/data/schemas/payment", diff --git a/docs/backend/FRONTEND_FEEDBACK_API.md b/docs/backend/FRONTEND_FEEDBACK_API.md new file mode 100644 index 00000000..0b5d4287 --- /dev/null +++ b/docs/backend/FRONTEND_FEEDBACK_API.md @@ -0,0 +1,315 @@ +# 问题反馈 API 接口定义 + +## 1. 文档状态 + +本文档定义 CozSweet 问题反馈提交接口,供后端实现和前后端联调使用。 + +前端已经按本文协议完成以下功能: + +- 反馈分类和文字提交; +- 最多 3 张截图; +- 图片压缩和格式限制; +- Login Token 与 Guest Token 认证; +- 提交成功后展示 `feedbackId`。 + +后端需要实现: + +```http +POST /api/feedback +``` + +环境地址: + +| 环境 | API Base URL | +| --- | --- | +| test 测试环境 | `https://testapi.banlv-ai.com` | +| pro 预发环境 | `https://proapi.banlv-ai.com` | +| production 生产环境 | `https://api.banlv-ai.com` | + +## 2. 身份认证 + +接口需要 Bearer Token,支持以下两种身份: + +1. 正式用户 Login Token; +2. 游客 Guest Token。 + +```http +Authorization: Bearer +``` + +后端必须从 Token 中解析用户或游客身份,不接受前端传递 `userId`、`guestId` 或其他身份字段。 + +Token 缺失、无效或过期时返回 HTTP `401`。 + +## 3. 请求定义 + +### 3.1 请求地址 + +```http +POST /api/feedback +``` + +### 3.2 Content-Type + +请求使用: + +```http +Content-Type: multipart/form-data; boundary= +``` + +调用方不能手工固定 `boundary`。浏览器会根据 FormData 自动生成完整 Content-Type。 + +### 3.3 Multipart 字段 + +| 字段 | 类型 | 是否必填 | 说明 | +| --- | --- | --- | --- | +| `category` | string | 是 | 反馈分类,只允许标准枚举值。 | +| `content` | string | 是 | 用户反馈正文,去除首尾空白后长度为 10–2000。 | +| `context` | string | 是 | JSON 字符串,结构见下文。 | +| `images` | file[] | 否 | 重复字段,最多 3 张图片。没有图片时不发送该字段。 | + +标准 `category`: + +| 值 | 含义 | +| --- | --- | +| `problem` | 功能异常或使用问题 | +| `suggestion` | 产品建议 | +| `payment` | 充值、订阅或支付问题 | +| `other` | 其他反馈 | + +不支持分类别名。非法分类返回 HTTP `400`。 + +### 3.4 Context JSON + +`context` 在 multipart 中是一个 JSON 字符串: + +```json +{ + "appVersion": "0.1.0", + "platform": "android Android 15", + "browser": "facebook IAB / Chrome 135.0", + "viewport": "392x760@2.75" +} +``` + +| 字段 | 类型 | 最大长度 | 说明 | +| --- | --- | ---: | --- | +| `appVersion` | string | 64 | 前端应用版本。 | +| `platform` | string | 128 | 平台、系统名称和版本。 | +| `browser` | string | 256 | 浏览器或应用内浏览器信息。 | +| `viewport` | string | 64 | CSS viewport 宽高和设备像素比。 | + +后端只需要保存上述 4 个字段。不要要求前端增加 User-Agent、设备型号、IP、聊天内容、联系方式或 Token。 + +### 3.5 图片规则 + +| 规则 | 要求 | +| --- | --- | +| 数量 | 0–3 张 | +| 单文件大小 | 不超过 2 MiB,即 `2 * 1024 * 1024` bytes | +| 接受格式 | JPEG、PNG、WebP | +| 接受 MIME | `image/jpeg`、`image/png`、`image/webp` | +| 推荐请求总大小上限 | 8 MiB | + +前端允许用户选择不超过 5 MiB 的源文件,但提交前会压缩到最长边不超过 1600px、目标大小不超过 2 MiB。后端仍需独立执行数量、大小、扩展名、MIME 和文件魔数校验,不能信任浏览器提供的文件名或 Content-Type。 + +建议后端移除 EXIF 等元数据,并将图片保存到私有存储桶。反馈截图不得使用永久公开 URL。 + +## 4. 请求示例 + +### 4.1 仅文字反馈 + +```bash +curl -X POST 'https://api.banlv-ai.com/api/feedback' \ + -H 'Authorization: Bearer ' \ + -F 'category=problem' \ + -F 'content=The send button did not respond after I entered a message.' \ + -F 'context={"appVersion":"0.1.0","platform":"android Android 15","browser":"Chrome 135.0","viewport":"392x760@2.75"}' +``` + +### 4.2 带多张截图 + +```bash +curl -X POST 'https://api.banlv-ai.com/api/feedback' \ + -H 'Authorization: Bearer ' \ + -F 'category=payment' \ + -F 'content=The payment completed but my balance did not update.' \ + -F 'context={"appVersion":"0.1.0","platform":"android Android 15","browser":"facebook IAB / Chrome 135.0","viewport":"392x760@2.75"}' \ + -F 'images=@payment-screen-1.webp;type=image/webp' \ + -F 'images=@payment-screen-2.jpg;type=image/jpeg' +``` + +## 5. 成功响应 + +成功时返回 HTTP `200`: + +```json +{ + "success": true, + "message": "Feedback submitted", + "data": { + "feedbackId": "c5db8e32-60f0-4f47-81bb-c22254c0562f" + } +} +``` + +前端公开响应字段只有: + +| 字段 | 类型 | 是否必填 | 说明 | +| --- | --- | --- | --- | +| `data.feedbackId` | string | 是 | 非空反馈编号,推荐使用 UUID。 | + +前端不需要后端返回用户信息、反馈正文、图片地址、状态、创建时间或诊断上下文。 + +## 6. 失败响应 + +失败响应使用统一 envelope: + +```json +{ + "success": false, + "message": "Feedback content must contain at least 10 characters", + "error": "INVALID_FEEDBACK_CONTENT" +} +``` + +建议状态码: + +| HTTP 状态 | error | 场景 | +| ---: | --- | --- | +| `400` | `INVALID_FEEDBACK_CATEGORY` | 分类不在标准枚举中。 | +| `400` | `INVALID_FEEDBACK_CONTENT` | 正文为空或长度不在 10–2000。 | +| `400` | `INVALID_FEEDBACK_CONTEXT` | Context 不是合法 JSON 或字段不合法。 | +| `400` | `TOO_MANY_FEEDBACK_IMAGES` | 图片超过 3 张。 | +| `401` | `UNAUTHORIZED` | Token 缺失、无效或过期。 | +| `413` | `FEEDBACK_PAYLOAD_TOO_LARGE` | 单文件或请求总大小超过限制。 | +| `415` | `UNSUPPORTED_FEEDBACK_IMAGE` | 图片格式、MIME 或文件魔数不支持。 | +| `429` | `FEEDBACK_RATE_LIMITED` | 用户或游客提交过于频繁。 | +| `500` | `FEEDBACK_SUBMIT_FAILED` | 持久化或文件存储失败。 | + +`message` 应当是可展示给用户的简短英文文本。不得在响应中返回堆栈、存储路径、数据库错误或内部实现信息。 + +## 7. 后端处理流程 + +推荐处理顺序: + +1. 验证 Bearer Token,解析正式用户或游客身份。 +2. 读取 multipart 字段并验证分类、正文和 Context。 +3. 在读取完整文件前检查图片数量和请求大小。 +4. 校验每张图片的真实格式、文件魔数、尺寸和大小。 +5. 使用服务端生成的随机对象名写入私有存储。 +6. 创建反馈记录并关联图片对象。 +7. 返回非空 `feedbackId`。 +8. 任一步失败时清理本次已经上传但未关联的图片。 + +每个成功请求创建一条反馈记录。当前前端没有发送 Idempotency-Key,因此后端不能依赖客户端幂等键去重。 + +## 8. 推荐数据模型 + +反馈主记录最少需要: + +| 字段 | 说明 | +| --- | --- | +| `id` | UUID,作为 `feedbackId` 返回。 | +| `user_id` | 正式用户 ID,可为空。 | +| `guest_id` | Guest 身份 ID,可为空。 | +| `category` | 标准反馈分类。 | +| `content` | 反馈正文。 | +| `context` | JSON/JSONB,只保存定义的 4 个字段。 | +| `status` | 内部处理状态,例如 `open`、`resolved`。不返回前端。 | +| `created_at` | 服务端创建时间。 | + +图片记录最少需要反馈 ID、私有对象 key、MIME、字节大小、宽高和创建时间。不要把用户原始文件名作为存储对象名。 + +正式用户必须只写入 `user_id`,Guest 必须只写入 `guest_id`。身份信息由 Token 派生。 + +## 9. 安全与日志 + +- 建议按用户或 Guest 身份限流,例如每小时不超过 10 次。 +- 普通应用日志不得记录反馈正文、Token、图片内容、原始文件名或完整 Context。 +- 可以记录 `feedbackId`、分类、图片数量、处理时长和错误码。 +- 图片下载和后台查看必须经过授权,不允许匿名公开访问。 +- 数据保留、删除和后台访问需要遵循现有隐私政策。 + +## 10. OpenAPI 参考 + +```yaml +paths: + /api/feedback: + post: + summary: Submit user feedback + security: + - bearerAuth: [] + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: + - category + - content + - context + properties: + category: + type: string + enum: [problem, suggestion, payment, other] + content: + type: string + minLength: 10 + maxLength: 2000 + context: + type: string + description: JSON-encoded FeedbackContext + images: + type: array + maxItems: 3 + items: + type: string + format: binary + responses: + "200": + description: Feedback submitted + content: + application/json: + schema: + type: object + required: [success, data] + properties: + success: + type: boolean + const: true + message: + type: string + data: + type: object + required: [feedbackId] + properties: + feedbackId: + type: string + minLength: 1 + "400": + description: Invalid feedback payload + "401": + description: Missing or invalid token + "413": + description: Payload too large + "415": + description: Unsupported image + "429": + description: Too many requests + "500": + description: Feedback persistence failed +``` + +## 11. 联调验收 + +后端完成后至少验证: + +1. Login Token 和 Guest Token 都能成功提交。 +2. 无图片、1 张图片和 3 张图片均能创建反馈。 +3. 第 4 张图片、超大图片、伪造 MIME 和非法 Context 被拒绝。 +4. 成功响应始终包含非空 `data.feedbackId`。 +5. 存储失败时不留下孤立图片对象或半成品反馈记录。 +6. 日志中不出现反馈正文、Token、图片内容和原始文件名。 +7. 同一身份超过限流阈值后返回 HTTP `429`。 diff --git a/src/app/feedback/__tests__/feedback-image.test.tsx b/src/app/feedback/__tests__/feedback-image.test.tsx new file mode 100644 index 00000000..db59aad2 --- /dev/null +++ b/src/app/feedback/__tests__/feedback-image.test.tsx @@ -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); + }); +}); diff --git a/src/app/feedback/__tests__/feedback-screen.test.tsx b/src/app/feedback/__tests__/feedback-screen.test.tsx new file mode 100644 index 00000000..f65226b1 --- /dev/null +++ b/src/app/feedback/__tests__/feedback-screen.test.tsx @@ -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()); + + 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('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()); + 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( + '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 })); +} diff --git a/src/app/feedback/feedback-context.ts b/src/app/feedback/feedback-context.ts new file mode 100644 index 00000000..041561f0 --- /dev/null +++ b/src/app/feedback/feedback-context.ts @@ -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"; +} diff --git a/src/app/feedback/feedback-image.ts b/src/app/feedback/feedback-image.ts new file mode 100644 index 00000000..f2deb981 --- /dev/null +++ b/src/app/feedback/feedback-image.ts @@ -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 { + 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(); + + 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 { + 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((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 { + 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(), + }); +} diff --git a/src/app/feedback/feedback-screen.module.css b/src/app/feedback/feedback-screen.module.css new file mode 100644 index 00000000..6a125146 --- /dev/null +++ b/src/app/feedback/feedback-screen.module.css @@ -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); + } +} diff --git a/src/app/feedback/feedback-screen.tsx b/src/app/feedback/feedback-screen.tsx new file mode 100644 index 00000000..d74f089a --- /dev/null +++ b/src/app/feedback/feedback-screen.tsx @@ -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 ( + +
+
+
+ ); + } + + const handleImageChange = (event: ChangeEvent) => { + if (event.target.files) void form.addImages(event.target.files); + event.target.value = ""; + }; + + return ( + +
+