1
This commit is contained in:
24
.trellis/tasks/05-09-billing-dashboard-replica/check.jsonl
Normal file
24
.trellis/tasks/05-09-billing-dashboard-replica/check.jsonl
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"file": ".trellis/spec/frontend/index.md",
|
||||||
|
"reason": "Confirm the implementation follows the expected frontend stack."
|
||||||
|
}
|
||||||
|
{
|
||||||
|
"file": ".trellis/spec/frontend/directory-structure.md",
|
||||||
|
"reason": "Check billing files live in the expected route-local structure."
|
||||||
|
}
|
||||||
|
{
|
||||||
|
"file": ".trellis/spec/frontend/component-guidelines.md",
|
||||||
|
"reason": "Review component naming, style isolation, and required comments."
|
||||||
|
}
|
||||||
|
{
|
||||||
|
"file": ".trellis/spec/frontend/state-management.md",
|
||||||
|
"reason": "Verify existing global stores are reused instead of duplicated."
|
||||||
|
}
|
||||||
|
{
|
||||||
|
"file": ".trellis/spec/frontend/type-safety.md",
|
||||||
|
"reason": "Check local interfaces and types are documented."
|
||||||
|
}
|
||||||
|
{
|
||||||
|
"file": ".trellis/spec/frontend/quality-guidelines.md",
|
||||||
|
"reason": "Verify async actions, loading states, and defensive rendering."
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"file": ".trellis/spec/frontend/index.md",
|
||||||
|
"reason": "Frontend stack and high-level conventions for Next.js, Zustand, SCSS, and TypeScript."
|
||||||
|
}
|
||||||
|
{
|
||||||
|
"file": ".trellis/spec/frontend/directory-structure.md",
|
||||||
|
"reason": "Route-local organization and naming conventions for the billing module."
|
||||||
|
}
|
||||||
|
{
|
||||||
|
"file": ".trellis/spec/frontend/component-guidelines.md",
|
||||||
|
"reason": "Component, style isolation, and documentation rules for new billing UI pieces."
|
||||||
|
}
|
||||||
|
{
|
||||||
|
"file": ".trellis/spec/frontend/state-management.md",
|
||||||
|
"reason": "Rules for consuming existing Zustand stores and avoiding redundant state."
|
||||||
|
}
|
||||||
|
{
|
||||||
|
"file": ".trellis/spec/frontend/type-safety.md",
|
||||||
|
"reason": "Type and JSDoc rules for route-local billing interfaces."
|
||||||
|
}
|
||||||
|
{
|
||||||
|
"file": ".trellis/spec/frontend/quality-guidelines.md",
|
||||||
|
"reason": "Async handling, loading states, defensive data access, and React quality checks."
|
||||||
|
}
|
||||||
22
.trellis/tasks/05-09-billing-dashboard-replica/prd.md
Normal file
22
.trellis/tasks/05-09-billing-dashboard-replica/prd.md
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Replicate Billing Dashboard Page
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Rebuild the existing `/web/app/dashboard/billing` page inside `/src/app/dashboard/billing` with equivalent UI and
|
||||||
|
behavior while adapting the implementation to the current `src` architecture.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Preserve the billing page content, layout, plan cards, checkout interaction, and checkout-return cleanup behavior from
|
||||||
|
the `web` implementation.
|
||||||
|
- Do not copy the `web` structure verbatim when it conflicts with the `src` conventions.
|
||||||
|
- Reuse existing `src/api`, `src/store`, and utility wrappers whenever they cover the needed behavior.
|
||||||
|
- Keep route-local UI, hooks, styles, and types inside `src/app/dashboard/billing`.
|
||||||
|
- Keep async actions guarded by loading states and defensive error handling.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- `/dashboard/billing` exists under `src/app`.
|
||||||
|
- The page renders without TypeScript or build errors.
|
||||||
|
- Billing-related API calls use existing request helpers and stores where applicable.
|
||||||
|
- The checkout return cleanup mirrors the current behavior from `web`.
|
||||||
26
.trellis/tasks/05-09-billing-dashboard-replica/task.json
Normal file
26
.trellis/tasks/05-09-billing-dashboard-replica/task.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"id": "billing-dashboard-replica",
|
||||||
|
"name": "billing-dashboard-replica",
|
||||||
|
"title": "Replicate billing dashboard page",
|
||||||
|
"description": "",
|
||||||
|
"status": "in_progress",
|
||||||
|
"dev_type": null,
|
||||||
|
"scope": null,
|
||||||
|
"package": null,
|
||||||
|
"priority": "P2",
|
||||||
|
"creator": "tao",
|
||||||
|
"assignee": "tao",
|
||||||
|
"createdAt": "2026-05-09",
|
||||||
|
"completedAt": null,
|
||||||
|
"branch": null,
|
||||||
|
"base_branch": "master",
|
||||||
|
"worktree_path": null,
|
||||||
|
"commit": null,
|
||||||
|
"pr_url": null,
|
||||||
|
"subtasks": [],
|
||||||
|
"children": [],
|
||||||
|
"parent": null,
|
||||||
|
"relatedFiles": [],
|
||||||
|
"notes": "",
|
||||||
|
"meta": {}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
"qrcode.react": "^4.2.0",
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
"zustand": "^5.0.13"
|
"zustand": "^5.0.13"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
14
pnpm-lock.yaml
generated
14
pnpm-lock.yaml
generated
@@ -29,6 +29,9 @@ importers:
|
|||||||
react-dom:
|
react-dom:
|
||||||
specifier: 19.2.4
|
specifier: 19.2.4
|
||||||
version: 19.2.4(react@19.2.4)
|
version: 19.2.4(react@19.2.4)
|
||||||
|
sonner:
|
||||||
|
specifier: ^2.0.7
|
||||||
|
version: 2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
zustand:
|
zustand:
|
||||||
specifier: ^5.0.13
|
specifier: ^5.0.13
|
||||||
version: 5.0.13(@types/react@19.2.14)(react@19.2.4)
|
version: 5.0.13(@types/react@19.2.14)(react@19.2.4)
|
||||||
@@ -784,6 +787,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
|
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
|
||||||
|
sonner@2.0.7:
|
||||||
|
resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
|
||||||
|
|
||||||
source-map-js@1.2.1:
|
source-map-js@1.2.1:
|
||||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -1425,6 +1434,11 @@ snapshots:
|
|||||||
'@img/sharp-win32-x64': 0.34.5
|
'@img/sharp-win32-x64': 0.34.5
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
sonner@2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.4
|
||||||
|
react-dom: 19.2.4(react@19.2.4)
|
||||||
|
|
||||||
source-map-js@1.2.1: {}
|
source-map-js@1.2.1: {}
|
||||||
|
|
||||||
styled-jsx@5.1.6(react@19.2.4):
|
styled-jsx@5.1.6(react@19.2.4):
|
||||||
|
|||||||
15
src/api/stripe.ts
Normal file
15
src/api/stripe.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import request from "@/utils/reqeust";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建订单
|
||||||
|
*/
|
||||||
|
export async function stripeCheckoutApi() {
|
||||||
|
return request.post("/stripe/checkout", {agreement_version: 2})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建支付链接
|
||||||
|
*/
|
||||||
|
export async function stripePortalApi() {
|
||||||
|
return request.post("/stripe/portal")
|
||||||
|
}
|
||||||
@@ -94,7 +94,7 @@ const NavTabs = () => {
|
|||||||
<nav className="flex items-center gap-0.5" aria-label="Dashboard navigation">
|
<nav className="flex items-center gap-0.5" aria-label="Dashboard navigation">
|
||||||
{tabs.map(item => {
|
{tabs.map(item => {
|
||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
const isActive = pathname.startsWith(item.href);
|
const isActive = pathname == item.href;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
212
src/app/dashboard/billing/page.tsx
Normal file
212
src/app/dashboard/billing/page.tsx
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import PlanCard from "./plan-card";
|
||||||
|
import {Hourglass, Sparkles} from "lucide-react";
|
||||||
|
import React, {useEffect, useState} from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import {useSearchParams} from "next/navigation";
|
||||||
|
import useSubscribeStore from "@/store/subscribe";
|
||||||
|
import {toast} from "sonner";
|
||||||
|
import {stripeCheckoutApi, stripePortalApi} from "@/api/stripe";
|
||||||
|
|
||||||
|
export default function BillingPage() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const {status, init} = useSubscribeStore();
|
||||||
|
const [agreed, setAgreed] = useState(false)
|
||||||
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化
|
||||||
|
*/
|
||||||
|
async function _init() {
|
||||||
|
const success = searchParams.get('success');
|
||||||
|
const canceled = searchParams.get('canceled');
|
||||||
|
if (success) {
|
||||||
|
toast.success("Payment successful! Syncing your account...");
|
||||||
|
startStatusPolling(); // 启动轮询同步
|
||||||
|
}
|
||||||
|
if (canceled) {
|
||||||
|
toast.info("Payment canceled.");
|
||||||
|
}
|
||||||
|
await init();
|
||||||
|
}
|
||||||
|
|
||||||
|
const startStatusPolling = () => {
|
||||||
|
let count = 0;
|
||||||
|
const maxRetries = 5;
|
||||||
|
|
||||||
|
const poll = setInterval(async () => {
|
||||||
|
count++;
|
||||||
|
await init(); // 刷新状态
|
||||||
|
const currentStatus = useSubscribeStore.getState().status;
|
||||||
|
|
||||||
|
// 如果状态变为了 active,或者达到最大尝试次数,停止轮询
|
||||||
|
if (currentStatus === 'active' || count >= maxRetries) {
|
||||||
|
clearInterval(poll);
|
||||||
|
if (currentStatus === 'active') toast.success("Account activated!");
|
||||||
|
}
|
||||||
|
}, 3000); // 每 3 秒请求一次
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
// 异常拦截
|
||||||
|
if (status === "trial" || status === "") {
|
||||||
|
if (!agreed) {
|
||||||
|
toast.error("Please agree to the SaaS agreement first.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsProcessing(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
let res: any;
|
||||||
|
|
||||||
|
// 分支 A: 已经是付费用户或曾付过钱(管理订阅)
|
||||||
|
if (status === "active" || status === "past_due" || status === "canceled") {
|
||||||
|
try {
|
||||||
|
res = await stripePortalApi();
|
||||||
|
} catch (err: any) {
|
||||||
|
// 容错:如果 Portal 报 no_customer,强制走 Checkout
|
||||||
|
if (err.response?.data?.data?.error === "no_customer") {
|
||||||
|
res = await stripeCheckoutApi();
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 分支 B: 新用户或试用期(发起支付)
|
||||||
|
else {
|
||||||
|
res = await stripeCheckoutApi();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取重定向 URL
|
||||||
|
const targetUrl = res.url || res.data?.url;
|
||||||
|
|
||||||
|
if (targetUrl) {
|
||||||
|
// 执行跳转,用户将离开当前站点进入 stripe.com
|
||||||
|
window.location.href = targetUrl;
|
||||||
|
} else {
|
||||||
|
toast.error("Failed to generate payment link.");
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
const msg = error.response?.data?.message || "Internal server error";
|
||||||
|
toast.error(msg);
|
||||||
|
} finally {
|
||||||
|
setIsProcessing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
_init();
|
||||||
|
}, [searchParams])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="container mx-auto max-w-3xl px-4 py-8 md:px-6 md:py-12">
|
||||||
|
<header className="mb-7 sm:mb-9">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
|
Billing
|
||||||
|
</p>
|
||||||
|
<h1 className="mt-1 text-2xl font-semibold tracking-tight sm:text-[26px]">
|
||||||
|
Subscription & invoices
|
||||||
|
</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className={"grid gap-4 sm:grid-cols-2"}>
|
||||||
|
<PlanCard icon={<div
|
||||||
|
className="inline-flex h-9 w-9 items-center justify-center rounded-md border border-border/60 bg-muted/30 text-foreground/70">
|
||||||
|
<Hourglass className="h-4 w-4"/>
|
||||||
|
</div>}
|
||||||
|
title={"Trial"}
|
||||||
|
desc={"Try the full thing — 24 hours, on us."}
|
||||||
|
price={"Free"}
|
||||||
|
priceUnit={"for 24 hours"}
|
||||||
|
tipTitle={" What’s included"}
|
||||||
|
tip={[
|
||||||
|
"Twice-daily AI scans (morning brief + evening recap)",
|
||||||
|
"Top 3 actions every scan, with RM impact derived from real evidence",
|
||||||
|
"Telegram alerts for P1 issues — stock-outs, ad waste, ROAS dips",
|
||||||
|
"Up to 10 competitor URLs tracked daily"
|
||||||
|
]}
|
||||||
|
content={
|
||||||
|
<div className="mt-5">
|
||||||
|
<div className="text-[13px] text-muted-foreground">
|
||||||
|
Trial already ended.
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-[12px] text-muted-foreground">
|
||||||
|
Auto-pauses when the 24h window closes — no charge, no card on file.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
}/>
|
||||||
|
<PlanCard
|
||||||
|
className={"border-2 border-foreground"}
|
||||||
|
icon={
|
||||||
|
<div
|
||||||
|
className="inline-flex h-9 w-9 items-center justify-center rounded-md bg-foreground text-background">
|
||||||
|
<Sparkles className="h-4 w-4"/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
title={"Pro"}
|
||||||
|
desc={"Two scans a day, every day — never miss a beat."}
|
||||||
|
price={"$500"}
|
||||||
|
priceUnit={"USD / month"}
|
||||||
|
tipTitle={"What you keep when you subscribe"}
|
||||||
|
tip={[
|
||||||
|
"Twice-daily AI scans (morning brief + evening recap)",
|
||||||
|
"Top 3 actions every scan, with RM impact derived from real evidence",
|
||||||
|
"Telegram alerts for P1 issues — stock-outs, ad waste, ROAS dips",
|
||||||
|
"Up to 10 competitor URLs tracked daily"
|
||||||
|
]}
|
||||||
|
content={
|
||||||
|
<div className="mt-5">
|
||||||
|
<div className="mt-1 text-[12px] text-muted-foreground">
|
||||||
|
Billed monthly · cancel any time via Stripe’s portal.
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 flex flex-col gap-2.5">
|
||||||
|
<button
|
||||||
|
disabled={isProcessing || !agreed}
|
||||||
|
className={
|
||||||
|
!agreed && isProcessing
|
||||||
|
? 'inline-flex h-11 w-full items-center justify-center rounded-md bg-muted px-5 text-[14px] font-semibold text-muted-foreground cursor-not-allowed'
|
||||||
|
: 'inline-flex h-11 w-full items-center justify-center rounded-md bg-emerald-600 px-5 text-[14px] font-semibold text-white shadow-sm transition-colors hover:bg-emerald-700 disabled:opacity-50'
|
||||||
|
}
|
||||||
|
onClick={handleSubmit}>
|
||||||
|
Subscribe to keep scanning
|
||||||
|
</button>
|
||||||
|
<label
|
||||||
|
className="flex cursor-pointer items-start gap-2 text-[12px] leading-snug text-muted-foreground">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={agreed}
|
||||||
|
onChange={(e) => setAgreed(e.target.checked)}
|
||||||
|
className="mt-0.5 h-3.5 w-3.5 shrink-0 cursor-pointer rounded border-border accent-emerald-600"
|
||||||
|
aria-describedby="agreement-version-hint"
|
||||||
|
/>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
I agree to the{' '}
|
||||||
|
<Link
|
||||||
|
href={"/legal/terms"}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
className="font-medium text-foreground underline underline-offset-2 hover:text-foreground/80">
|
||||||
|
SaaS Agreement
|
||||||
|
</Link>
|
||||||
|
&
|
||||||
|
<Link
|
||||||
|
href={`/legal/terms#schedule-c`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
className="font-medium text-foreground underline underline-offset-2 hover:text-foreground/80">
|
||||||
|
DPA
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}/>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
50
src/app/dashboard/billing/plan-card.tsx
Normal file
50
src/app/dashboard/billing/plan-card.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {Check} from "lucide-react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
className?: string,
|
||||||
|
icon: React.ReactNode,
|
||||||
|
title: string,
|
||||||
|
desc: string,
|
||||||
|
price: string,
|
||||||
|
priceUnit: string,
|
||||||
|
content: React.ReactNode,
|
||||||
|
tipTitle: string,
|
||||||
|
tip: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const PlanCard = (props: Props) => {
|
||||||
|
return (
|
||||||
|
<article className={`flex flex-col rounded-xl border border-border bg-card p-6 shadow-sm ${props.className}`}>
|
||||||
|
{props.icon}
|
||||||
|
<h3 className="mt-4 text-[20px] font-semibold tracking-tighst">{props.title}</h3>
|
||||||
|
<p className="mt-1 text-[13px] text-muted-foreground">
|
||||||
|
{props.desc}
|
||||||
|
</p>
|
||||||
|
<div className="mt-5 flex items-baseline gap-2">
|
||||||
|
<div className="text-[34px] font-semibold tracking-tight">
|
||||||
|
{props.price}
|
||||||
|
</div>
|
||||||
|
<div className="pb-1 text-[13px] text-muted-foreground">
|
||||||
|
{props.priceUnit}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{props.content}
|
||||||
|
<div className="mt-6 border-t border-border/60 pt-4">
|
||||||
|
<p className="text-[12px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
{props.tipTitle}
|
||||||
|
</p>
|
||||||
|
<ul className="mt-3 space-y-2 text-[13px] text-foreground/85">
|
||||||
|
{props.tip.map((feat) => (
|
||||||
|
<li key={feat} className="flex gap-2">
|
||||||
|
<Check className="mt-0.5 h-4 w-4 shrink-0 text-emerald-600" aria-hidden/>
|
||||||
|
<span>{feat}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PlanCard;
|
||||||
1432
src/app/legal/saas-agreement/page.tsx
Normal file
1432
src/app/legal/saas-agreement/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1075
src/app/legal/terms-preview/page.tsx
Normal file
1075
src/app/legal/terms-preview/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1055
src/app/legal/terms/page.tsx
Normal file
1055
src/app/legal/terms/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user