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
+4
View File
@@ -14,6 +14,8 @@
"./src/data/services/api", "./src/data/services/api",
"./src/data/dto/auth", "./src/data/dto/auth",
"./src/data/dto/chat", "./src/data/dto/chat",
"./src/data/dto/feedback",
"./src/data/dto/feedback/response",
"./src/data/dto/metrics", "./src/data/dto/metrics",
"./src/data/dto/user", "./src/data/dto/user",
"./src/data/schemas/auth", "./src/data/schemas/auth",
@@ -22,6 +24,8 @@
"./src/data/schemas/chat", "./src/data/schemas/chat",
"./src/data/schemas/chat/request", "./src/data/schemas/chat/request",
"./src/data/schemas/chat/response", "./src/data/schemas/chat/response",
"./src/data/schemas/feedback",
"./src/data/schemas/feedback/response",
"./src/data/schemas/metrics", "./src/data/schemas/metrics",
"./src/data/schemas/metrics/request", "./src/data/schemas/metrics/request",
"./src/data/schemas/payment", "./src/data/schemas/payment",
+315
View File
@@ -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>
```
后端必须从 Token 中解析用户或游客身份,不接受前端传递 `userId``guestId` 或其他身份字段。
Token 缺失、无效或过期时返回 HTTP `401`
## 3. 请求定义
### 3.1 请求地址
```http
POST <API_BASE_URL>/api/feedback
```
### 3.2 Content-Type
请求使用:
```http
Content-Type: multipart/form-data; boundary=<AUTO_GENERATED_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 图片规则
| 规则 | 要求 |
| --- | --- |
| 数量 | 03 张 |
| 单文件大小 | 不超过 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 <TOKEN>' \
-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 <TOKEN>' \
-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`
@@ -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); 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 { .logoutCard:hover {
background: #ffffff; background: #ffffff;
box-shadow: 0 16px 34px rgba(55, 36, 44, 0.1); box-shadow: 0 16px 34px rgba(55, 36, 44, 0.1);
@@ -144,6 +149,11 @@
color: #f657a0; color: #f657a0;
} }
.feedbackIcon {
background: rgba(208, 150, 91, 0.13);
color: #b8783f;
}
.logoutText { .logoutText {
display: flex; display: flex;
min-width: 0; min-width: 0;
@@ -165,6 +175,13 @@
line-height: 1.15; line-height: 1.15;
} }
.feedbackTitle {
color: #171114;
font-size: var(--responsive-body, 16px);
font-weight: 760;
line-height: 1.15;
}
.logoutSubtitle { .logoutSubtitle {
color: #817076; color: #817076;
font-size: var(--responsive-caption, 13px); font-size: var(--responsive-caption, 13px);
+22 -1
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { Download, LogOut } from "lucide-react"; import { Download, LogOut, MessageCircleQuestion } from "lucide-react";
import { signOut } from "next-auth/react"; import { signOut } from "next-auth/react";
import { BackButton } from "@/app/_components"; import { BackButton } from "@/app/_components";
@@ -99,6 +99,27 @@ export function SidebarScreen() {
<section className={`${styles.settingsSlot} ${styles.revealThree}`}> <section className={`${styles.settingsSlot} ${styles.revealThree}`}>
<p className={styles.settingsLabel}>Other</p> <p className={styles.settingsLabel}>Other</p>
<div className={styles.settingsActions}> <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 ? ( {pwaInstallEntry.canInstall ? (
<button <button
type="button" type="button"
@@ -0,0 +1,22 @@
export const FEEDBACK_CATEGORIES = [
"problem",
"suggestion",
"payment",
"other",
] as const;
export type FeedbackCategory = (typeof FEEDBACK_CATEGORIES)[number];
export interface FeedbackContext {
appVersion: string;
platform: string;
browser: string;
viewport: string;
}
export interface SubmitFeedbackInput {
category: FeedbackCategory;
content: string;
context: FeedbackContext;
images: readonly File[];
}
+2
View File
@@ -0,0 +1,2 @@
export * from "./feedback_submission";
export * from "./response";
@@ -0,0 +1,26 @@
import {
FeedbackSubmitResponseSchema,
type FeedbackSubmitResponseData,
type FeedbackSubmitResponseInput,
} from "@/data/schemas/feedback";
export class FeedbackSubmitResponse {
declare readonly feedbackId: string;
private constructor(input: FeedbackSubmitResponseInput) {
Object.assign(this, FeedbackSubmitResponseSchema.parse(input));
Object.freeze(this);
}
static from(input: FeedbackSubmitResponseInput): FeedbackSubmitResponse {
return new FeedbackSubmitResponse(input);
}
static fromJson(json: unknown): FeedbackSubmitResponse {
return FeedbackSubmitResponse.from(json as FeedbackSubmitResponseInput);
}
toJson(): FeedbackSubmitResponseData {
return FeedbackSubmitResponseSchema.parse(this);
}
}
+1
View File
@@ -0,0 +1 @@
export * from "./feedback_submit_response";
@@ -0,0 +1,21 @@
import type {
FeedbackSubmitResponse,
SubmitFeedbackInput,
} from "@/data/dto/feedback";
import type { IFeedbackRepository } from "@/data/repositories/interfaces";
import { FeedbackApi, feedbackApi } from "@/data/services/api/feedback_api";
import { Result } from "@/utils/result";
import { createLazySingleton } from "./lazy_singleton";
export class FeedbackRepository implements IFeedbackRepository {
constructor(private readonly api: FeedbackApi) {}
submit(input: SubmitFeedbackInput): Promise<Result<FeedbackSubmitResponse>> {
return Result.wrap(() => this.api.submit(input));
}
}
export const getFeedbackRepository = createLazySingleton<IFeedbackRepository>(
() => new FeedbackRepository(feedbackApi),
);
+2
View File
@@ -4,12 +4,14 @@
export * from "./auth_repository"; export * from "./auth_repository";
export * from "./chat_repository"; export * from "./chat_repository";
export * from "./feedback_repository";
export * from "./metrics_repository"; export * from "./metrics_repository";
export * from "./payment_repository"; export * from "./payment_repository";
export * from "./private_room_repository"; export * from "./private_room_repository";
export * from "./user_repository"; export * from "./user_repository";
export * from "./interfaces/iauth_repository"; export * from "./interfaces/iauth_repository";
export * from "./interfaces/ichat_repository"; export * from "./interfaces/ichat_repository";
export * from "./interfaces/ifeedback_repository";
export * from "./interfaces/imetrics_repository"; export * from "./interfaces/imetrics_repository";
export * from "./interfaces/ipayment_repository"; export * from "./interfaces/ipayment_repository";
export * from "./interfaces/iprivate_room_repository"; export * from "./interfaces/iprivate_room_repository";
@@ -0,0 +1,9 @@
import type {
FeedbackSubmitResponse,
SubmitFeedbackInput,
} from "@/data/dto/feedback";
import type { Result } from "@/utils/result";
export interface IFeedbackRepository {
submit(input: SubmitFeedbackInput): Promise<Result<FeedbackSubmitResponse>>;
}
@@ -4,6 +4,7 @@
export * from "./iauth_repository"; export * from "./iauth_repository";
export * from "./ichat_repository"; export * from "./ichat_repository";
export * from "./ifeedback_repository";
export * from "./imetrics_repository"; export * from "./imetrics_repository";
export * from "./ipayment_repository"; export * from "./ipayment_repository";
export * from "./iprivate_room_repository"; export * from "./iprivate_room_repository";
+1
View File
@@ -0,0 +1 @@
export * from "./response";
@@ -0,0 +1,12 @@
import { z } from "zod";
export const FeedbackSubmitResponseSchema = z.object({
feedbackId: z.string().min(1),
});
export type FeedbackSubmitResponseInput = z.input<
typeof FeedbackSubmitResponseSchema
>;
export type FeedbackSubmitResponseData = z.output<
typeof FeedbackSubmitResponseSchema
>;
@@ -0,0 +1 @@
export * from "./feedback_submit_response";
@@ -0,0 +1,53 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const httpClientMock = vi.hoisted(() => vi.fn());
vi.mock("../http_client", () => ({
httpClient: httpClientMock,
}));
import { FeedbackApi } from "../feedback_api";
describe("FeedbackApi", () => {
beforeEach(() => {
httpClientMock.mockReset();
});
it("submits the supported fields as multipart data", async () => {
httpClientMock.mockResolvedValue({
success: true,
data: { feedbackId: "feedback-123", ignored: "server-only" },
});
const image = new File(["image"], "screen.jpg", {
type: "image/jpeg",
});
const response = await new FeedbackApi().submit({
category: "problem",
content: "The send button did not respond.",
context: {
appVersion: "0.1.0",
platform: "android Android 15",
browser: "facebook IAB / Chrome 135",
viewport: "392x760@2.75",
},
images: [image],
});
expect(response.toJson()).toEqual({ feedbackId: "feedback-123" });
expect(httpClientMock).toHaveBeenCalledWith(
"/api/feedback",
expect.objectContaining({ method: "POST", body: expect.any(FormData) }),
);
const body = httpClientMock.mock.calls[0][1].body as FormData;
expect(body.get("category")).toBe("problem");
expect(body.get("content")).toBe("The send button did not respond.");
expect(JSON.parse(String(body.get("context")))).toEqual({
appVersion: "0.1.0",
platform: "android Android 15",
browser: "facebook IAB / Chrome 135",
viewport: "392x760@2.75",
});
expect(body.getAll("images")).toEqual([image]);
});
});
@@ -22,6 +22,13 @@ function callUrl(call: Parameters<typeof fetch>): URL {
return new URL(request instanceof Request ? request.url : String(request)); return new URL(request instanceof Request ? request.url : String(request));
} }
function callHeaders(call: Parameters<typeof fetch>): Headers {
const [request, init] = call;
return request instanceof Request
? request.headers
: new Headers(init?.headers);
}
describe("createHttpClient", () => { describe("createHttpClient", () => {
beforeEach(() => { beforeEach(() => {
process.env.NEXT_PUBLIC_API_BASE_URL = "https://api.example.test"; process.env.NEXT_PUBLIC_API_BASE_URL = "https://api.example.test";
@@ -264,4 +271,35 @@ describe("createHttpClient", () => {
]); ]);
await expect(authStorage.getGuestToken()).resolves.toEqual(Result.ok(null)); await expect(authStorage.getGuestToken()).resolves.toEqual(Result.ok(null));
}); });
it("lets ofetch set JSON content headers for object bodies", async () => {
const fetchMock = vi.fn<typeof fetch>(async () =>
jsonResponse({ success: true }),
);
vi.stubGlobal("fetch", fetchMock);
const http = createHttpClient();
await http("/api/example", {
method: "POST",
body: { message: "hello" },
});
expect(callHeaders(fetchMock.mock.calls[0]).get("Content-Type")).toContain(
"application/json",
);
});
it("does not override multipart boundaries for FormData bodies", async () => {
const fetchMock = vi.fn<typeof fetch>(async () =>
jsonResponse({ success: true }),
);
vi.stubGlobal("fetch", fetchMock);
const body = new FormData();
body.append("content", "feedback");
const http = createHttpClient();
await http("/api/feedback", { method: "POST", body });
expect(callHeaders(fetchMock.mock.calls[0]).get("Content-Type")).toBeNull();
});
}); });
+3
View File
@@ -103,4 +103,7 @@ export class ApiPath {
/** 上报用户信息 */ /** 上报用户信息 */
static readonly reportUserInfo = `${ApiPath._data}/report-user-info`; static readonly reportUserInfo = `${ApiPath._data}/report-user-info`;
// ============ 用户反馈相关 ============
static readonly feedback = `${ApiPath._baseUrl}/feedback`;
} }
+26
View File
@@ -0,0 +1,26 @@
import {
FeedbackSubmitResponse,
type SubmitFeedbackInput,
} from "@/data/dto/feedback";
import { ApiPath } from "./api_path";
import { httpClient } from "./http_client";
import { type ApiEnvelope, unwrap } from "./response_helper";
export class FeedbackApi {
async submit(input: SubmitFeedbackInput): Promise<FeedbackSubmitResponse> {
const body = new FormData();
body.append("category", input.category);
body.append("content", input.content);
body.append("context", JSON.stringify(input.context));
input.images.forEach((image) => body.append("images", image));
const envelope = await httpClient<ApiEnvelope<unknown>>(ApiPath.feedback, {
method: "POST",
body,
});
return FeedbackSubmitResponse.fromJson(unwrap(envelope));
}
}
export const feedbackApi = new FeedbackApi();
-3
View File
@@ -44,9 +44,6 @@ export function createHttpClient(): $Fetch {
timeout: config.receiveTimeout, timeout: config.receiveTimeout,
retry: 1, // 401 刷新后由 ofetch 重放一次原请求 retry: 1, // 401 刷新后由 ofetch 重放一次原请求
retryStatusCodes: [ErrorCode.httpUnauthorized], retryStatusCodes: [ErrorCode.httpUnauthorized],
headers: {
"Content-Type": "application/json",
},
onRequest: chain( onRequest: chain(
loggingInterceptor.onRequest, loggingInterceptor.onRequest,
tokenInterceptor.onRequest tokenInterceptor.onRequest
+1
View File
@@ -6,6 +6,7 @@ export * from "./api_path";
export * from "./api_result"; export * from "./api_result";
export * from "./auth_api"; export * from "./auth_api";
export * from "./chat_api"; export * from "./chat_api";
export * from "./feedback_api";
export * from "./http_client"; export * from "./http_client";
export * from "./metrics_api"; export * from "./metrics_api";
export * from "./payment_api"; export * from "./payment_api";
+1
View File
@@ -0,0 +1 @@
export * from "./submit_feedback";
+12
View File
@@ -0,0 +1,12 @@
import type {
FeedbackSubmitResponse,
SubmitFeedbackInput,
} from "@/data/dto/feedback";
import { getFeedbackRepository } from "@/data/repositories";
import type { Result } from "@/utils/result";
export function submitFeedback(
input: SubmitFeedbackInput,
): Promise<Result<FeedbackSubmitResponse>> {
return getFeedbackRepository().submit(input);
}
+5
View File
@@ -12,6 +12,7 @@ describe("route meta", () => {
expect(getRouteAccess(ROUTES.tip)).toBe("public"); expect(getRouteAccess(ROUTES.tip)).toBe("public");
expect(getRouteAccess(ROUTES.externalEntry)).toBe("public"); expect(getRouteAccess(ROUTES.externalEntry)).toBe("public");
expect(getRouteAccess(ROUTES.sidebar)).toBe("session"); expect(getRouteAccess(ROUTES.sidebar)).toBe("session");
expect(getRouteAccess(ROUTES.feedback)).toBe("session");
expect(getRouteAccess(ROUTES.subscription)).toBe("realUser"); expect(getRouteAccess(ROUTES.subscription)).toBe("realUser");
expect(getRouteAccess(ROUTES.coinsRules)).toBe("public"); expect(getRouteAccess(ROUTES.coinsRules)).toBe("public");
}); });
@@ -32,6 +33,10 @@ describe("route meta", () => {
expect(ALL_STATIC_ROUTES).toContain(ROUTES.externalEntry); expect(ALL_STATIC_ROUTES).toContain(ROUTES.externalEntry);
}); });
it("includes feedback in static routes", () => {
expect(ALL_STATIC_ROUTES).toContain(ROUTES.feedback);
});
it("treats unknown routes as public by default", () => { it("treats unknown routes as public by default", () => {
expect(getRouteAccess("/unknown")).toBe("public"); expect(getRouteAccess("/unknown")).toBe("public");
}); });
+1
View File
@@ -16,6 +16,7 @@ const STATIC_ROUTE_ACCESS: Partial<Record<string, RouteAccess>> = {
[ROUTES.tip]: "public", [ROUTES.tip]: "public",
[ROUTES.externalEntry]: "public", [ROUTES.externalEntry]: "public",
[ROUTES.sidebar]: "session", [ROUTES.sidebar]: "session",
[ROUTES.feedback]: "session",
[ROUTES.subscription]: "realUser", [ROUTES.subscription]: "realUser",
[ROUTES.coinsRules]: "public", [ROUTES.coinsRules]: "public",
}; };
+2
View File
@@ -25,6 +25,7 @@ export const ROUTES = {
externalEntry: "/external-entry", externalEntry: "/external-entry",
auth: "/auth", auth: "/auth",
sidebar: "/sidebar", sidebar: "/sidebar",
feedback: "/feedback",
subscription: "/subscription", subscription: "/subscription",
coinsRules: "/coins-rules", coinsRules: "/coins-rules",
} as const; } as const;
@@ -69,6 +70,7 @@ export const ALL_STATIC_ROUTES: readonly StaticRoute[] = [
ROUTES.externalEntry, ROUTES.externalEntry,
ROUTES.auth, ROUTES.auth,
ROUTES.sidebar, ROUTES.sidebar,
ROUTES.feedback,
ROUTES.coinsRules, ROUTES.coinsRules,
] as const; ] as const;