feat(feedback): add problem reporting flow
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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 图片规则
|
||||||
|
|
||||||
|
| 规则 | 要求 |
|
||||||
|
| --- | --- |
|
||||||
|
| 数量 | 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 <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 }));
|
||||||
|
}
|
||||||
@@ -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);
|
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);
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
);
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./submit_feedback";
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user