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({ )}

{isHidden && ( - + Đang ẩn từ {formatDateTime(item.state!.moderated_at)} {item.state?.reason ? `: ${item.state.reason}` : ""} - + )} {item.reports.length > 0 && (
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}

)} -
+

- {ambiguousReasonLabel(row.ambiguous_reason)} + {ambiguousReasonLabel(row.ambiguous_reason)} {candidate && (

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 ( +
+ ({ href: s.href, label: s.label, current: s.key === section }))} + /> +
{children}
+
+ ) +} + +/** 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ơ - -
-
+ khoản nhận. +
+ ) } @@ -61,132 +66,147 @@ export default async function MyAccountsPage() { })) return ( -
-
-
-

Tài khoản nhận tiền

- + + Tải sao kê - -
-

- 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]} -

+
+
+
+
Tên pháp lý
+
{organizer.legal_name}
+
+
+
Hồ sơ người tổ chức
+
+ +
+
+
{organizer.verification_status !== "verified" && ( -

+ Give chỉ xác minh tài khoản nhận sau khi hồ sơ người tổ chức đã được xác minh. Bạn vẫn có thể khai báo tài khoản ngay bây giờ. -

+ )}
-
-

Tài khoản đã khai báo ({accounts.length})

- {accounts.length === 0 ? ( -

Bạn chưa khai báo tài khoản nào.

- ) : ( -
    - {accounts.map((account) => ( - campaignById.get(id)?.title ?? "Chiến dịch"} - boundReleasable={(id) => canAskRelease(campaignById.get(id)?.status, campaignById.get(id)?.type)} - bindChoices={bindChoices} - /> - ))} -
- )} -
+
+
+

+ Tài khoản đã khai báo ({accounts.length}) +

+ {accounts.length === 0 ? ( + + Khai báo tài khoản ở mục Khai báo tài khoản mới, rồi chuyển khoản xác minh để Give xác nhận tài khoản là + của bạn. + + ) : ( +
    + {accounts.map((account) => ( + campaignById.get(id)?.title ?? "Chiến dịch"} + boundReleasable={(id) => canAskRelease(campaignById.get(id)?.status, campaignById.get(id)?.type)} + bindChoices={bindChoices} + /> + ))} +
+ )} +
-
-

Khai báo tài khoản mới

-

- 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. -

- -
- - + + {BANKS.map((bank) => ( + + ))} + +
+
+ + +
+
+ + +

+ Đú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). +

+
+
+ Loại tài khoản + {(["transparent_charity", "standard"] as const).map((kind) => ( + ))} - -
-
- - -
-
- - -

- Đú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). -

-
-
- Loại tài khoản - {(["transparent_charity", "standard"] as const).map((kind) => ( -
+
+ - ))} - -
- - -
- Khai báo tài khoản - -
-
+ + + Khai báo tài khoản +
+ + + ) } @@ -218,83 +238,99 @@ function AccountItem({ const anyBindable = bindChoices.some((c) => !c.disabledReason) return ( -
  • +
  • -
    - {/* The owner's own page: the full number (RLS keeps other users out). */} -

    - {bankLabel(account.bank_bin)} · {account.account_number} -

    -

    {account.account_name}

    -

    - {account.account_kind ? ACCOUNT_KIND_LABELS[account.account_kind] : ""} · Khai báo ngày{" "} - {formatDate(account.created_at)} -

    - {account.public_statement_url && ( - - Trang sao kê công khai - - )} +
    + + + +
    + {/* The owner's own page: the full number (RLS keeps other users out). */} +

    + {bankLabel(account.bank_bin)} · {account.account_number} +

    +

    {account.account_name}

    +

    + {account.account_kind ? ACCOUNT_KIND_LABELS[account.account_kind] : ""} · Khai báo ngày{" "} + {formatDate(account.created_at)} +

    + {account.public_statement_url && ( + + Trang sao kê công khai + + )} +
    {account.verification_status === "pending" && ( -
    -

    Chuyển khoản xác minh

    -

    + +

    Từ chính tài khoản này, chuyển{" "} {formatVnd(VERIFICATION_TRANSFER_AMOUNT)} tới một tài khoản khác của bạn, với nội dung chuyển khoản đúng là:

    -

    {account.verification_code}

    -

    +

    + {account.verification_code} +

    +

    Give đối chiếu giao dịch này trên sao kê của tài khoản (trang sao kê công khai nếu có) rồi xác minh. Nếu cần, đội ngũ Give sẽ liên hệ để xem biên lai.

    -
    + )} {(account.verification_status === "rejected" || account.verification_status === "disabled") && - account.review_note &&

    Lý do: {account.review_note}

    } + account.review_note && ( + + Lý do: {account.review_note} + + )} {holder && ( -

    - Đ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. +

    {(account.verification_status === "disabled" || unusable) && ( - - {" "} +

    Chiến dịch này hiện không nhận được tiền ủng hộ qua tài khoản này. - +

    )} -

    +
    )} {holder && boundReleasable(holder.campaign_id) && ( - Nhả tài khoản + Nhả tài khoản )} - {unusable &&

    Tài khoản tạm không dùng được: {unusable}

    } + {unusable && Tài khoản tạm không dùng được: {unusable}} {canBind && (bindChoices.length === 0 ? ( -

    +

    Tạo chiến dịch, rồi quay lại đây để gắn tài khoản trước khi gửi duyệt.{" "} - + Tạo chiến dịch

    @@ -302,33 +338,38 @@ function AccountItem({ - + - ))} - - {anyBindable ? ( - Gắn tài khoản - ) : ( -

    + {bindChoices.map((choice) => ( + + ))} + + {anyBindable && ( + + Gắn tài khoản + + )} +

    + {!anyBindable && ( +

    Chỉ gắn được tài khoản với chiến dịch chưa kết thúc (kể cả bản nháp) và chưa có tài khoản nhận khác.

    )} diff --git a/apps/web/src/app/me/campaigns/[id]/RewardTiersSection.tsx b/apps/web/src/app/me/campaigns/[id]/RewardTiersSection.tsx index d465e5fe..b54175e4 100644 --- a/apps/web/src/app/me/campaigns/[id]/RewardTiersSection.tsx +++ b/apps/web/src/app/me/campaigns/[id]/RewardTiersSection.tsx @@ -1,6 +1,7 @@ -import Link from "next/link" - import { ActionForm, SubmitButton } from "@/components/forms" +import { buttonClass, ButtonLink } from "@/components/ui/Button" +import { Callout } from "@/components/ui/Callout" +import { ChevronDownIcon, GiftIcon } from "@/components/ui/icons" import type { CampaignStatus } from "@/lib/campaigns" import { formatVnd } from "@/lib/format" import { deliveryMonth } from "@/lib/pledges" @@ -16,6 +17,7 @@ import { type RewardTier, } from "@/lib/reward-tiers" +import { checkInputClass, checkLabelClass, Panel } from "../../_components/Panel" import { deleteRewardTier, moveRewardTier, retireRewardTier, saveRewardTier } from "../reward-actions" /** @@ -39,10 +41,11 @@ export function RewardTiersSection({ const ordered = tiers ? sortTiers(tiers) : [] return ( -
    -
    -

    Phần thưởng

    -

    + Mỗi mức có số tiền tối thiểu (không thấp hơn mức góp tối thiểu {formatVnd(minContribution)}), có thể giới hạn số suất. Người ủng hộ chọn một mức khi cam kết.{" "} {editable @@ -51,46 +54,53 @@ export function RewardTiersSection({ ? "Dự án đã gửi duyệt nên các mức không sửa được nữa. Bạn vẫn có thể ngừng nhận một mức chưa có ai cam kết." : "Các mức không sửa được ở trạng thái này."}

    -
    - + } + > {!tiers ? ( -

    Không tải được các mức phần thưởng. Hãy tải lại trang.

    + Không tải được các mức phần thưởng. Hãy tải lại trang. ) : ordered.length === 0 ? ( -

    +

    {editable ? "Chưa có mức nào. Không có mức nào thì người ủng hộ chỉ cam kết mà không nhận quà." : "Dự án không có mức phần thưởng nào."}

    ) : ( -
      +
        {ordered.map((tier, index) => ( -
      1. +
      2. {editable ? ( -
        +
        {index > 0 && ( - ↑ Lên + + ↑ Lên + )} {index < ordered.length - 1 && ( - ↓ Xuống + + ↓ Xuống + )} - Xóa + Xóa
        -
        - Sửa mức này - +
        + + Sửa mức này + + + - Lưu phần thưởng + Lưu phần thưởng
        @@ -99,12 +109,12 @@ export function RewardTiersSection({ action={retireRewardTier.bind(null, campaignId, tier.id)} confirm={`Ngừng nhận cam kết mới cho mức “${tier.title}”? Mức này sẽ ẩn khỏi trang dự án và không mở lại được.`} > - Ngừng nhận mức này + Ngừng nhận mức này ) : ( RETIRABLE_STATUSES.includes(status) && tier.quantity_claimed > 0 && ( -

        Đã có người cam kết nên mức này không ngừng nhận được.

        +

        Đã có người cam kết nên mức này không ngừng nhận được.

        ) )}
      3. @@ -113,15 +123,25 @@ export function RewardTiersSection({ )} {editable && tiers && ordered.length < MAX_TIERS && ( -
        - Thêm mức phần thưởng - +
        + + Thêm mức phần thưởng + + + - Thêm mức + Thêm mức
        )} -
    + ) } @@ -130,11 +150,11 @@ function TierSummary({ tier }: { tier: RewardTier }) { return (
    -

    {tier.title}

    - {minAmountLabel(tier.min_amount)} +

    {tier.title}

    + {minAmountLabel(tier.min_amount)}
    - {tier.description &&

    {tier.description}

    } -

    + {tier.description &&

    {tier.description}

    } +

    {tier.quantity_limit === null ? "Không giới hạn số suất" : `${tier.quantity_limit} suất · ${quantityLabel(tier)}`} @@ -143,10 +163,10 @@ function TierSummary({ tier }: { tier: RewardTier }) { {tier.requires_shipping ? " · Cần giao hàng" : ""}

    {tier.requires_shipping && tier.shipping_note && ( -

    Giao hàng: {tier.shipping_note}

    +

    Giao hàng: {tier.shipping_note}

    )} {tier.status === "retired" && ( -

    Đã ngừng nhận cam kết mới (ẩn khỏi trang dự án).

    +

    Đã ngừng nhận cam kết mới (ẩn khỏi trang dự án).

    )}
    ) @@ -156,7 +176,7 @@ function TierSummary({ tier }: { tier: RewardTier }) { function TierFields({ prefix, tier }: { prefix: string; tier: RewardTier | null }) { return ( <> -
    +
    -