diff --git a/apps/web/src/app/admin/account-swaps/page.tsx b/apps/web/src/app/admin/account-swaps/page.tsx
index 314b423e..06e4f08d 100644
--- a/apps/web/src/app/admin/account-swaps/page.tsx
+++ b/apps/web/src/app/admin/account-swaps/page.tsx
@@ -4,9 +4,9 @@ import { notFound } from "next/navigation"
import { AccountStatusBadge, StatusBadge, VerificationBadge } from "@/components/badges"
import { ActionForm, SubmitButton } from "@/components/forms"
import { Badge, type BadgeTone } from "@/components/ui/Badge"
+import { Callout } from "@/components/ui/Callout"
import { EmptyState } from "@/components/ui/EmptyState"
import { Field } from "@/components/ui/Field"
-import { Notice } from "@/components/ui/Notice"
import {
approvalBlock,
listDecidedSwaps,
@@ -187,11 +187,11 @@ function SwapCard({
{swap.status === "pending" &&
(own ? (
- Đây là chiến dịch của bạn; một nhân viên khác cần xử lý.
+ Đây là chiến dịch của bạn; một nhân viên khác cần xử lý.
) : (
{block ? (
- Chưa duyệt được: {block}
+ Chưa duyệt được: {block}
) : (
{own ? (
- Đây là tài khoản của bạn; một nhân viên khác cần xử lý.
+ Đây là tài khoản của bạn; một nhân viên khác cần xử lý.
) : (
{status === "pending" && options.verifyBlocked && (
- Chưa xác minh được: {options.verifyBlocked}
+ Chưa xác minh được: {options.verifyBlocked}
)}
{status === "pending" && !options.verifyBlocked && (
void }) {
@@ -9,9 +9,9 @@ export default function AdminError({ retry }: { error: Error & { digest?: string
Không tải được trang này
-
+
Có lỗi khi đọc dữ liệu của trang. Hãy thử lại; nếu vẫn lỗi, hãy tải lại trang sau ít phút.
-
+
diff --git a/apps/web/src/app/admin/evidence/page.tsx b/apps/web/src/app/admin/evidence/page.tsx
index 56d937cd..19ec7219 100644
--- a/apps/web/src/app/admin/evidence/page.tsx
+++ b/apps/web/src/app/admin/evidence/page.tsx
@@ -4,9 +4,9 @@ import Link from "next/link"
import { notFound } from "next/navigation"
import { ActionForm, SubmitButton } from "@/components/forms"
+import { Callout } from "@/components/ui/Callout"
import { EmptyState } from "@/components/ui/EmptyState"
import { Field } from "@/components/ui/Field"
-import { Notice } from "@/components/ui/Notice"
import { requireSession } from "@/lib/auth/session"
import { isStaff } from "@/lib/campaigns"
import { HIDDEN_LIMIT, listModerationQueue, MODERATION_ROLES, type ModerationItem } from "@/lib/evidence-moderation"
@@ -124,10 +124,10 @@ function EvidenceCard({
)}
diff --git a/apps/web/src/app/admin/inbound/page.tsx b/apps/web/src/app/admin/inbound/page.tsx
index 41572286..e9aa1804 100644
--- a/apps/web/src/app/admin/inbound/page.tsx
+++ b/apps/web/src/app/admin/inbound/page.tsx
@@ -2,8 +2,8 @@ import type { Metadata } from "next"
import { notFound } from "next/navigation"
import { ActionForm, SubmitButton } from "@/components/forms"
+import { Callout } from "@/components/ui/Callout"
import { EmptyState } from "@/components/ui/EmptyState"
-import { Notice } from "@/components/ui/Notice"
import { requireSession } from "@/lib/auth/session"
import { isStaff } from "@/lib/campaigns"
import { formatDateTime } from "@/lib/format"
@@ -109,13 +109,13 @@ function ParkedCard({
{account ? ` · ${account}` : ""}
{part ? ` · phần ${part.number}/${part.count} của file` : ""} · nhận lúc {formatDateTime(event.received_at)}
-
+
Mã lỗi: {event.last_error ?? "—"} · đã thử {event.attempts} lần
{event.last_attempt_at ? `, lần cuối ${formatDateTime(event.last_attempt_at)}` : ""} · dừng lúc{" "}
{formatDateTime(event.parked_at)}
-
+
{event.id}
diff --git a/apps/web/src/app/admin/page.tsx b/apps/web/src/app/admin/page.tsx
index 289361ed..324957aa 100644
--- a/apps/web/src/app/admin/page.tsx
+++ b/apps/web/src/app/admin/page.tsx
@@ -5,8 +5,8 @@ import type { ReactNode } from "react"
import { TypeBadge, VerificationBadge } from "@/components/badges"
import { ActionForm, SubmitButton } from "@/components/forms"
import { ButtonLink } from "@/components/ui/Button"
+import { Callout } from "@/components/ui/Callout"
import { EmptyState } from "@/components/ui/EmptyState"
-import { Notice } from "@/components/ui/Notice"
import { requireSession } from "@/lib/auth/session"
import {
accountProblemLabel,
@@ -100,7 +100,7 @@ export default async function AdminReviewPage() {
)}
{own ? (
- Đây là chiến dịch của bạn; một kiểm duyệt viên khác cần duyệt.
+ Đây là chiến dịch của bạn; một kiểm duyệt viên khác cần duyệt.
) : (
{waiting === null ? (
-
+
Các phần khác của trang vẫn dùng được. Hãy tải lại trang sau.
-
+
) : waiting.length === 0 ? (
) : (
@@ -180,9 +180,9 @@ export default async function AdminReviewPage() {
)}
- {advice.note && {advice.note}}
+ {advice.note && {advice.note}}
{advice.sendBackReason === null ? null : own ? (
- Đây là chiến dịch của bạn; một kiểm duyệt viên khác cần xử lý.
+ Đây là chiến dịch của bạn; một kiểm duyệt viên khác cần xử lý.
) : (
-
{campaign.payments_stopped_note && (
Ghi chú công khai: {campaign.payments_stopped_note}
Khớp với dòng {candidate.row_no} của một lần tải trước ({rowStatusLabel(candidate.status).toLowerCase()}):{" "}
@@ -238,10 +238,10 @@ function QueueCard({
))}
{targets.total > targets.shown.length && (
-
+
Còn {targets.total - targets.shown.length} giao dịch khác cùng số tiền xa ngày hơn không hiện ở đây.
Nếu không thấy đúng giao dịch, đừng chọn đại một giao dịch.
-
+
)}
Là giao dịch đã ghi
diff --git a/apps/web/src/app/me/_components/MeError.tsx b/apps/web/src/app/me/_components/MeError.tsx
new file mode 100644
index 00000000..b45df26f
--- /dev/null
+++ b/apps/web/src/app/me/_components/MeError.tsx
@@ -0,0 +1,25 @@
+"use client"
+
+import { Button, ButtonLink } from "@/components/ui/Button"
+import { Callout } from "@/components/ui/Callout"
+
+/**
+ * What a /me section shows when its page fails to render: what happened,
+ * a retry, and a way back to the person's campaigns.
+ */
+export function MeError({ retry }: { retry: () => void }) {
+ return (
+
+
Không tải được trang này
+
+ Có thể kết nối bị gián đoạn hoặc máy chủ đang bận. Dữ liệu của bạn không bị ảnh hưởng; hãy thử lại sau ít phút.
+
+
+
+
+ Về Chiến dịch của tôi
+
+
+
+ )
+}
diff --git a/apps/web/src/app/me/_components/MePage.tsx b/apps/web/src/app/me/_components/MePage.tsx
new file mode 100644
index 00000000..14865aed
--- /dev/null
+++ b/apps/web/src/app/me/_components/MePage.tsx
@@ -0,0 +1,37 @@
+import type { ReactNode } from "react"
+
+import { TabNav } from "@/components/ui/Tabs"
+
+export type MeSection = "campaigns" | "contributions" | "notices" | "accounts"
+
+const SECTIONS: { key: MeSection; href: string; label: string }[] = [
+ { key: "campaigns", href: "/me/campaigns", label: "Chiến dịch của tôi" },
+ { key: "contributions", href: "/me/contributions", label: "Ủng hộ của tôi" },
+ { key: "notices", href: "/me/notices", label: "Thông báo" },
+ { key: "accounts", href: "/me/accounts", label: "Tài khoản nhận tiền" },
+]
+
+/**
+ * A top-level /me page (DOS/Give#96): the tabs between the signed-in
+ * person's lists, then the page. On phones the tabs scroll sideways.
+ */
+export function MePage({ section, children }: { section: MeSection; children: ReactNode }) {
+ return (
+
+ )
+}
+
+/** An inner /me page (an editor, a report): one centred column, no tabs. */
+export function MeDetail({ wide, children }: { wide?: boolean; children: ReactNode }) {
+ return (
+
+
{children}
+
+ )
+}
diff --git a/apps/web/src/app/me/_components/Panel.tsx b/apps/web/src/app/me/_components/Panel.tsx
new file mode 100644
index 00000000..845b005b
--- /dev/null
+++ b/apps/web/src/app/me/_components/Panel.tsx
@@ -0,0 +1,45 @@
+import clsx from "clsx"
+import type { ReactNode } from "react"
+
+/**
+ * One section of an inner /me page: a card with an h2, an optional lead and
+ * an optional action on the right. `id` is the anchor the page links to.
+ */
+export function Panel({
+ id,
+ title,
+ description,
+ action,
+ className,
+ children,
+}: {
+ id: string
+ title: string
+ description?: ReactNode
+ action?: ReactNode
+ className?: string
+ children?: ReactNode
+}) {
+ return (
+
+
+
+
+ {title}
+
+ {description &&
{description}
}
+
+ {action &&
{action}
}
+
+ {children &&
{children}
}
+
+ )
+}
+
+/** A file input whose picker button looks like the kit's secondary button. */
+export const fileInputClass =
+ "block w-full text-base text-body file:mr-3 file:min-h-11 file:cursor-pointer file:rounded file:border file:border-gray-300 file:bg-white file:px-4 file:py-2 file:font-semibold file:text-ink hover:file:bg-surface"
+
+/** A checkbox or radio with its label, sized for touch. */
+export const checkLabelClass = "flex min-h-11 items-center gap-3 text-base text-ink"
+export const checkInputClass = "size-5 shrink-0 accent-brand-700"
diff --git a/apps/web/src/app/me/_components/tones.ts b/apps/web/src/app/me/_components/tones.ts
new file mode 100644
index 00000000..9032517b
--- /dev/null
+++ b/apps/web/src/app/me/_components/tones.ts
@@ -0,0 +1,31 @@
+import type { BadgeTone } from "@/components/ui/Badge"
+import type { DonationStatus } from "@/lib/contributions"
+import type { FulfillmentStatus } from "@/lib/fulfillment"
+import type { RowStatus } from "@/lib/statement-import"
+
+/** Badge tones of the statuses /me lists show (labels stay in lib). */
+export const DONATION_TONES: Record = {
+ awaiting_transfer: "warning",
+ received: "brand",
+ expired: "neutral",
+}
+
+export const FULFILLMENT_TONES: Record = {
+ pending: "neutral",
+ in_progress: "info",
+ shipped: "info",
+ delivered: "brand",
+ issue: "danger",
+}
+
+export const ROW_TONES: Record = {
+ ready: "neutral",
+ duplicate: "neutral",
+ ambiguous: "warning",
+ imported: "brand",
+}
+
+/** The tone of a status read from the database as a plain string; neutral when unknown. */
+export function toneOf(tones: Partial>, status: string | null): BadgeTone {
+ return (status !== null && tones[status]) || "neutral"
+}
diff --git a/apps/web/src/app/me/accounts/error.tsx b/apps/web/src/app/me/accounts/error.tsx
new file mode 100644
index 00000000..5bc8feea
--- /dev/null
+++ b/apps/web/src/app/me/accounts/error.tsx
@@ -0,0 +1,7 @@
+"use client"
+
+import { MeError } from "../_components/MeError"
+
+export default function ErrorPage({ retry }: { error: unknown; retry: () => void }) {
+ return
+}
diff --git a/apps/web/src/app/me/accounts/page.tsx b/apps/web/src/app/me/accounts/page.tsx
index 82ec6fb9..ab332637 100644
--- a/apps/web/src/app/me/accounts/page.tsx
+++ b/apps/web/src/app/me/accounts/page.tsx
@@ -1,11 +1,16 @@
import type { Metadata } from "next"
import Link from "next/link"
-import { AccountStatusBadge } from "@/components/badges"
+import { AccountStatusBadge, VerificationBadge } from "@/components/badges"
import { ActionForm, SubmitButton } from "@/components/forms"
+import { buttonClass, ButtonLink } from "@/components/ui/Button"
+import { Callout } from "@/components/ui/Callout"
+import { EmptyState } from "@/components/ui/EmptyState"
+import { BankIcon } from "@/components/ui/icons"
+import { PageHeader } from "@/components/ui/PageHeader"
import { requireSession } from "@/lib/auth/session"
import { BANKS, bankLabel } from "@/lib/banks"
-import { getOrganizer, listMyCampaigns, VERIFICATION_LABELS, type Organizer } from "@/lib/campaigns"
+import { getOrganizer, listMyCampaigns, type Organizer } from "@/lib/campaigns"
import { formatDate, formatVnd } from "@/lib/format"
import {
ACCOUNT_KIND_LABELS,
@@ -20,6 +25,9 @@ import {
} from "@/lib/receiving-accounts"
import { dbAs } from "@/lib/supabase"
+import { MePage } from "../_components/MePage"
+import { checkInputClass, checkLabelClass } from "../_components/Panel"
+
import { bindAccount, registerAccount, releaseAccount } from "./actions"
export const metadata: Metadata = { title: "Tài khoản nhận tiền", robots: { index: false } }
@@ -31,16 +39,13 @@ export default async function MyAccountsPage() {
if (!organizer) {
return (
-
-
Tài khoản nhận tiền
-
+
+
+ Tạo hồ sơ}>
Bạn cần tạo hồ sơ người tổ chức (tên pháp lý trùng với tên chủ tài khoản ngân hàng) trước khi khai báo tài
- khoản nhận.{" "}
-
- Tạo hồ sơ
-
-
- Tiền ủng hộ đi thẳng vào tài khoản ngân hàng của bạn; Give không giữ tiền. Mỗi tài khoản cần được xác minh là
- của bạn, rồi gắn với một chiến dịch.
-
-
+
+ }
+ >
+ Tiền ủng hộ đi thẳng vào tài khoản ngân hàng của bạn; Give không giữ tiền. Mỗi tài khoản cần được xác minh là
+ của bạn, rồi gắn với một chiến dịch.
+
-
-
- Tên pháp lý: {organizer.legal_name}
-
-
- Hồ sơ người tổ chức:{" "}
- {VERIFICATION_LABELS[organizer.verification_status]}
-
- Dùng một tài khoản riêng cho việc gây quỹ, đứng tên bạn, không dùng chung với tài khoản chi tiêu cá nhân. Với
- chiến dịch thiên tai, dịch bệnh, sự cố, mỗi chiến dịch cần một tài khoản riêng.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Đúng như ngân hàng hiển thị; phải trùng với tên pháp lý (không phân biệt dấu và hoa thường).
-
- Đang gắn với chiến dịch{" "}
-
- {boundTitle(holder.campaign_id)}
- {" "}
- từ ngày {formatDate(holder.bound_at)}. Khi chiến dịch còn là bản nháp, hoặc được yêu cầu chỉnh sửa mà tài
- khoản chưa nhận giao dịch nào, bạn có thể nhả tài khoản để đổi tài khoản khác; ngoài ra, sau khi gửi duyệt,
- tài khoản chỉ được nhả khi chiến dịch đó đã xong hẳn phần tiền.
+
+
+ Đang gắn với chiến dịch{" "}
+
+ {boundTitle(holder.campaign_id)}
+ {" "}
+ từ ngày {formatDate(holder.bound_at)}.
+
+
+ Khi chiến dịch còn là bản nháp, hoặc được yêu cầu chỉnh sửa mà tài khoản chưa nhận giao dịch nào, bạn có thể
+ nhả tài khoản để đổi tài khoản khác; ngoài ra, sau khi gửi duyệt, tài khoản chỉ được nhả khi chiến dịch đó
+ đã xong hẳn phần tiền.
+