From 7249006550b428ba780a22481eb76f793589550d Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Wed, 4 Nov 2026 03:04:10 +0700 Subject: [PATCH 1/5] Design tokens, header, home hero and campaign card on the new green kit (#95) Replaces the rose brand with the Design-Principles palette (green primary, light orange accent, low radius, 44px targets), loads Be Vietnam Pro through next/font, and adds the first kit pieces (Button, Badge, Money, ProgressBar, EmptyState, SectionHeader, icons), the search-centric header with account and mobile menus, the sitemap footer, and a home page whose hero shows the featured campaign's public statement as a receipt. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/app/globals.css | 105 ++++++- apps/web/src/app/layout.tsx | 78 ++--- apps/web/src/app/page.tsx | 292 ++++++++++++++---- apps/web/src/components/CampaignCard.tsx | 104 ++++--- apps/web/src/components/ProgressBar.tsx | 24 +- apps/web/src/components/badges.tsx | 78 ++--- .../src/components/home/FeaturedReceipt.tsx | 86 ++++++ apps/web/src/components/layout/Disclosure.tsx | 85 +++++ apps/web/src/components/layout/Logo.tsx | 22 ++ apps/web/src/components/layout/SiteFooter.tsx | 71 +++++ apps/web/src/components/layout/SiteHeader.tsx | 196 ++++++++++++ apps/web/src/components/ui/Badge.tsx | 39 +++ apps/web/src/components/ui/Button.tsx | 55 ++++ .../src/components/ui/CoverPlaceholder.tsx | 14 + apps/web/src/components/ui/EmptyState.tsx | 12 + apps/web/src/components/ui/Money.tsx | 83 +++++ apps/web/src/components/ui/SectionHeader.tsx | 26 ++ apps/web/src/components/ui/icons.tsx | 131 ++++++++ apps/web/src/lib/home.test.ts | 40 +++ apps/web/src/lib/home.ts | 34 ++ 20 files changed, 1357 insertions(+), 218 deletions(-) create mode 100644 apps/web/src/components/home/FeaturedReceipt.tsx create mode 100644 apps/web/src/components/layout/Disclosure.tsx create mode 100644 apps/web/src/components/layout/Logo.tsx create mode 100644 apps/web/src/components/layout/SiteFooter.tsx create mode 100644 apps/web/src/components/layout/SiteHeader.tsx create mode 100644 apps/web/src/components/ui/Badge.tsx create mode 100644 apps/web/src/components/ui/Button.tsx create mode 100644 apps/web/src/components/ui/CoverPlaceholder.tsx create mode 100644 apps/web/src/components/ui/EmptyState.tsx create mode 100644 apps/web/src/components/ui/Money.tsx create mode 100644 apps/web/src/components/ui/SectionHeader.tsx create mode 100644 apps/web/src/components/ui/icons.tsx create mode 100644 apps/web/src/lib/home.test.ts create mode 100644 apps/web/src/lib/home.ts diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 12b25bac..0985c5c4 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -1,41 +1,111 @@ @import "tailwindcss"; +/* + * Give design tokens (DOS/Give#95, docs/Design-Principles.md): green primary, + * light orange accent, neutral grays, 8px spacing grid, low corner radius + * (rounded for buttons and images, rounded-md for cards and inputs). + */ @theme { --font-sans: - "Be Vietnam Pro", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; - --color-brand-50: #fff1f3; - --color-brand-100: #ffe0e5; - --color-brand-500: #f43f5e; - --color-brand-600: #e11d48; - --color-brand-700: #be123c; + var(--font-be-vietnam-pro), "Be Vietnam Pro", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, + "Helvetica Neue", Arial, sans-serif; + + /* Primary: green, for the main action and progress. */ + --color-brand-50: #ecfdf5; + --color-brand-100: #d1fae5; + --color-brand-200: #a7f3d0; + --color-brand-500: #10b981; + --color-brand-600: #059669; + --color-brand-700: #047857; + --color-brand-800: #065f46; + --color-brand-900: #064e3b; + + /* Accent: light orange, for highlights only (never for text on white). */ + --color-accent-50: #fff7ed; + --color-accent-100: #ffedd5; + --color-accent-200: #fed7aa; + --color-accent-700: #c2410c; + --color-accent-800: #9a3412; + + /* Neutrals: ink for headings, body for text, muted for captions. */ + --color-ink: #111827; + --color-body: #374151; + --color-muted: #4b5563; + --color-line: #e5e7eb; + --color-surface: #f9fafb; + + --radius-sm: 0.25rem; + --radius: 0.25rem; + --radius-md: 0.375rem; +} + +/* Base styles sit in the base layer so utilities (text-white on a dark band) win. */ +@layer base { + html { + -webkit-text-size-adjust: 100%; + } + + body { + @apply bg-white text-base leading-relaxed text-body antialiased; + } + + h1, + h2, + h3 { + @apply text-ink; + text-wrap: balance; + } + + :focus-visible { + @apply outline-2 outline-offset-2 outline-brand-600; + } + + @media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; + } + } +} + +/* Page width and gutters: 16px on phones, 24px from sm, 1152px wide at most. */ +@utility container-page { + @apply mx-auto w-full max-w-6xl px-4 sm:px-6; } -body { - @apply bg-white text-gray-900 antialiased; +/* Money is always tabular so figures line up and do not jump. */ +@utility figure { + @apply font-bold text-ink tabular-nums; } @utility field { - @apply block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-xs focus:border-brand-500 focus:ring-2 focus:ring-brand-100 focus:outline-none disabled:bg-gray-50 disabled:text-gray-500; + @apply block min-h-11 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-base text-ink placeholder:text-gray-500 focus:border-brand-600 focus:ring-2 focus:ring-brand-100 focus:outline-none disabled:bg-surface disabled:text-muted; } @utility label { - @apply mb-1 block text-sm font-medium text-gray-700; + @apply mb-1 block text-base font-medium text-ink; } @utility hint { - @apply mt-1 text-xs text-gray-500; + @apply mt-1 text-sm text-muted; } @utility btn { - @apply inline-flex items-center justify-center gap-2 rounded-md px-4 py-2 text-sm font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-60; + @apply inline-flex min-h-11 items-center justify-center gap-2 rounded px-5 py-2 text-base font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-60; } @utility btn-primary { - @apply btn bg-brand-600 text-white hover:bg-brand-700; + @apply btn bg-brand-600 text-white shadow-xs hover:bg-brand-700 active:bg-brand-800; } @utility btn-secondary { - @apply btn border border-gray-300 bg-white text-gray-800 hover:bg-gray-50; + @apply btn border border-gray-300 bg-white text-ink hover:border-gray-400 hover:bg-surface; +} + +@utility btn-ghost { + @apply btn px-3 text-ink hover:bg-surface; } @utility btn-danger { @@ -43,5 +113,10 @@ body { } @utility card { - @apply rounded-lg border border-gray-200 bg-white; + @apply rounded-md border border-line bg-white; +} + +/* An in-text link: green, underlined on hover. */ +@utility link { + @apply font-medium text-brand-700 underline-offset-4 hover:text-brand-800 hover:underline; } diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 1013231e..31fa8329 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -1,7 +1,9 @@ import type { Metadata } from "next" -import Link from "next/link" +import { Be_Vietnam_Pro } from "next/font/google" import type { ReactNode } from "react" +import { SiteFooter } from "@/components/layout/SiteFooter" +import { SiteHeader } from "@/components/layout/SiteHeader" import { siteOrigin } from "@/lib/auth/origin" import { getSession, type Session } from "@/lib/auth/session" import { countUnreadNotices, unreadBadge } from "@/lib/notices" @@ -9,6 +11,15 @@ import { dbAs } from "@/lib/supabase" import "./globals.css" +// Be Vietnam Pro: drawn for Vietnamese, so stacked diacritics (ễ, ặ, ỗ) keep +// their spacing at every weight. Self-hosted by next/font, no layout shift. +const font = Be_Vietnam_Pro({ + subsets: ["latin", "vietnamese"], + weight: ["400", "500", "600", "700"], + display: "swap", + variable: "--font-be-vietnam-pro", +}) + export const metadata: Metadata = { // Absolute base for Open Graph images such as /media/public/... covers; an // unset or malformed NEXT_PUBLIC_SITE_URL leaves Next's default. @@ -31,66 +42,13 @@ export default async function RootLayout({ children }: { children: ReactNode }) const unread = session ? await unreadNotices(session) : null return ( - + -
- -
-
{children}
- + +
+ {children} +
+ ) diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index e9f53b85..824f17a4 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -1,8 +1,15 @@ import Link from "next/link" +import type { ReactNode } from "react" import { CampaignCard } from "@/components/CampaignCard" -import { categoriesFor, discoveryHref, listCategories, listPublicCampaigns } from "@/lib/campaigns" -import { listMoneyTotals, raisedAmount } from "@/lib/statement" +import { FeaturedReceipt } from "@/components/home/FeaturedReceipt" +import { ButtonLink } from "@/components/ui/Button" +import { EmptyState } from "@/components/ui/EmptyState" +import { BankIcon, GiftIcon, HeartIcon, LedgerIcon, ShieldIcon, SparkIcon } from "@/components/ui/icons" +import { SectionHeader } from "@/components/ui/SectionHeader" +import { categoriesFor, discoveryHref, listCategories, listPublicCampaigns, type PublicCampaign } from "@/lib/campaigns" +import { homeSections } from "@/lib/home" +import { listMoneyTotals, raisedAmount, type MoneyTotals } from "@/lib/statement" import { dbAs } from "@/lib/supabase" const LOGIN_ERRORS: Record = { @@ -13,91 +20,252 @@ const LOGIN_ERRORS: Record = { unavailable: "Đăng nhập DOS.Me chưa được cấu hình trên môi trường này.", } +/** How many live campaigns the home page loads: one featured, three trending, up to six new. */ +const HOME_CAMPAIGNS = 10 + export default async function HomePage({ searchParams }: { searchParams: Promise<{ login_error?: string }> }) { const { login_error: loginError } = await searchParams const db = dbAs() const categories = await listCategories(db) - const campaigns = await listPublicCampaigns(db, { type: null, category: null, q: null }, categories, 6) + const campaigns = await listPublicCampaigns(db, { type: null, category: null, q: null }, categories, HOME_CAMPAIGNS) const totals = await listMoneyTotals( db, campaigns.map((c) => c.id) ) + const { featured, trending, newest } = homeSections(campaigns, totals) return ( <> {loginError && ( -
+
{Object.hasOwn(LOGIN_ERRORS, loginError) ? LOGIN_ERRORS[loginError] : LOGIN_ERRORS.exchange}
)} -
-
-

- Gây quỹ minh bạch cho điều tốt đẹp và ý tưởng mới -

-

- Ủng hộ chiến dịch từ thiện, hoặc đồng hành cùng dự án sáng tạo. Tiền đi thẳng vào tài khoản nhận của từng - chiến dịch, Give chỉ ghi nhận và công khai. -

-
- - Khám phá chiến dịch - - - Tạo chiến dịch - +
+
+
+

+ Gây quỹ minh bạch, từng đồng đều có sao kê +

+

+ Ủng hộ chiến dịch từ thiện hoặc đồng hành cùng dự án sáng tạo. Tiền đi thẳng vào tài khoản nhận của chiến + dịch, Give chỉ ghi nhận và công khai mọi giao dịch. +

+
+ + Khám phá chiến dịch + + + Hoặc tạo chiến dịch của bạn + +
+ {featured && }
-
-
-

Chiến dịch mới

- - Xem tất cả → - -
- {campaigns.length > 0 ? ( -
- {campaigns.map((campaign) => ( - - ))} + {campaigns.length === 0 ? ( +
+ Tạo chiến dịch đầu tiên} + > + Chiến dịch hiện ở đây ngay khi được duyệt. + +
+ ) : ( + <> + + Xem tất cả chiến dịch} + /> + + )} + +
+
+ + Mỗi chiến dịch chọn một hình thức. Nhãn trên thẻ cho bạn biết đó là hình thức nào. + +
+ } + tone="brand" + title="Từ thiện" + points={[ + "Chọn số tiền bạn muốn góp và chuyển khoản bằng mã VietQR.", + "Tiền vào tài khoản nhận của chiến dịch ngay khi bạn chuyển.", + "Người tổ chức giải trình từng khoản chi, kèm chứng từ.", + ]} + links={categoriesFor(categories, "give").map((c) => ({ + href: discoveryHref({ type: "give", category: c.slug }), + label: c.name, + }))} + allHref={discoveryHref({ type: "give" })} + allLabel="Xem chiến dịch từ thiện" + /> + } + tone="accent" + title="Dự án sáng tạo" + points={[ + "Chọn một mức ủng hộ và nhận quà từ người tạo dự án.", + "Bạn cam kết trước, chỉ chuyển tiền khi dự án đạt mục tiêu.", + "Dự án không đạt mục tiêu thì không ai phải trả.", + ]} + links={categoriesFor(categories, "back").map((c) => ({ + href: discoveryHref({ type: "back", category: c.slug }), + label: c.name, + }))} + allHref={discoveryHref({ type: "back" })} + allLabel="Xem dự án sáng tạo" + />
- ) : ( -

- Chưa có chiến dịch nào đang gây quỹ.{" "} - - Tạo chiến dịch đầu tiên - -

- )} +
-
-
- {(["give", "back"] as const).map((type) => ( -
-

{type === "give" ? "❤️ Từ thiện" : "🚀 Dự án sáng tạo"}

-
- {categoriesFor(categories, type).map((category) => ( - - {category.icon} {category.name} - - ))} -
-
- ))} +
+
+ + Give là nơi ghi nhận và công khai, không phải ví trung gian. Vì vậy bạn luôn kiểm tra được tiền đã đi đâu. + +
    + } title="Thẳng vào tài khoản nhận"> + Mã VietQR trỏ tới tài khoản ngân hàng đã xác minh của chiến dịch, không qua tài khoản của Give. + + } title="Sao kê công khai"> + Mọi khoản vào và ra của tài khoản đó hiện trên trang sao kê, ai cũng xem được. + + } title="Tài khoản nhận đã xác minh"> + Give kiểm tra tài khoản ngân hàng thuộc về người tổ chức trước khi chiến dịch được nhận tiền. + +
+
+
+ +
+
+
+

Bạn có một việc tốt hay một ý tưởng cần gây quỹ?

+

+ Viết câu chuyện, liên kết tài khoản ngân hàng của bạn và bắt đầu nhận ủng hộ khi chiến dịch được duyệt. +

+
+ + + Tạo chiến dịch +
) } + +function CampaignGrid({ + id, + title, + campaigns, + totals, + more, +}: { + id: string + title: string + campaigns: readonly PublicCampaign[] + totals: ReadonlyMap + more?: ReactNode +}) { + if (campaigns.length === 0) return null + return ( +
+ +
+ {campaigns.map((campaign) => ( + + ))} +
+
+ ) +} + +function TypeExplainer({ + icon, + tone, + title, + points, + links, + allHref, + allLabel, +}: { + icon: ReactNode + tone: "brand" | "accent" + title: string + points: string[] + links: { href: string; label: string }[] + allHref: string + allLabel: string +}) { + return ( +
+
+ + {icon} + +

{title}

+
+
    + {points.map((point) => ( +
  • + + {point} +
  • + ))} +
+ {links.length > 0 && ( +
    + {links.map((link) => ( +
  • + + {link.label} + +
  • + ))} +
+ )} + + {allLabel} + +
+ ) +} + +function TrustPoint({ icon, title, children }: { icon: ReactNode; title: string; children: ReactNode }) { + return ( +
  • + {icon} +

    {title}

    +

    {children}

    +
  • + ) +} diff --git a/apps/web/src/components/CampaignCard.tsx b/apps/web/src/components/CampaignCard.tsx index ed26e9e4..cba6bb73 100644 --- a/apps/web/src/components/CampaignCard.tsx +++ b/apps/web/src/components/CampaignCard.tsx @@ -1,63 +1,87 @@ +import clsx from "clsx" import Image from "next/image" import Link from "next/link" import { TypeBadge } from "@/components/badges" import { ProgressBar } from "@/components/ProgressBar" +import { CoverPlaceholder } from "@/components/ui/CoverPlaceholder" import type { PublicCampaign } from "@/lib/campaigns" -import { daysLeft, formatVnd, progressPercent } from "@/lib/format" import { publicCoverUrl } from "@/lib/cover-urls" +import { daysLeft, formatVnd, progressPercent } from "@/lib/format" /** - * Campaign card for discovery (public campaigns only: the cover goes through - * the public media route), ported from the old ProjectCard. `raised` comes - * from give.public_campaigns (lib/statement.ts raisedAmount). + * Campaign card for discovery and the home page (public campaigns only: the + * cover goes through the public media route). `raised` comes from + * give.public_campaigns (lib/statement.ts raisedAmount). The whole card is + * one link named by the title. */ -export function CampaignCard({ campaign, raised = 0 }: { campaign: PublicCampaign; raised?: number }) { +export function CampaignCard({ + campaign, + raised = 0, + priority, + headingLevel = 3, +}: { + campaign: PublicCampaign + raised?: number + /** Loads the cover eagerly (above the fold). */ + priority?: boolean + headingLevel?: 2 | 3 +}) { const percent = progressPercent(raised, campaign.goal_amount) const days = daysLeft(campaign.ends_at) const options = campaign.display_options + const isGive = campaign.type === "give" + const Heading = headingLevel === 2 ? "h2" : "h3" return ( - -
    -
    - {campaign.cover?.storage_path ? ( - {campaign.cover.alt - ) : ( -
    - {campaign.category?.icon ?? (campaign.type === "give" ? "❤️" : "🚀")} -
    - )} +
    +
    + {campaign.cover?.storage_path ? ( + + ) : ( + + )} +
    +
    +
    + + {campaign.category && {campaign.category.name}}
    -
    -
    - - {campaign.category && ( - - {campaign.category.icon} {campaign.category.name} - - )} -
    -

    {campaign.title}

    - {campaign.summary &&

    {campaign.summary}

    } - {options.progress_bar !== false && } -
    + + + {campaign.title} + + + {campaign.summary &&

    {campaign.summary}

    } +
    + {options.progress_bar !== false && } +

    - {formatVnd(raised)} - / {formatVnd(campaign.goal_amount)} + {formatVnd(raised)}{" "} + {isGive ? "đã nhận" : "đã cam kết"} + + = 100 && "font-semibold text-brand-700")}> + {percent}% +

    +

    + Mục tiêu {formatVnd(campaign.goal_amount)} {options.time_remaining !== false && days !== null && ( - {days > 0 ? `Còn ${days} ngày` : "Đã hết hạn"} + {days > 0 ? `Còn ${days} ngày` : "Đã hết hạn"} )} -

    +

    -
    - +
    +
    ) } diff --git a/apps/web/src/components/ProgressBar.tsx b/apps/web/src/components/ProgressBar.tsx index 720ead41..fcd6cb5e 100644 --- a/apps/web/src/components/ProgressBar.tsx +++ b/apps/web/src/components/ProgressBar.tsx @@ -1,14 +1,32 @@ -export function ProgressBar({ percent, className }: { percent: number; className?: string }) { +import clsx from "clsx" + +/** + * Funding progress: a green bar on a light track, low radius. `label` names + * it for screen readers (the visible figures sit next to it). + */ +export function ProgressBar({ + percent, + className, + label = "Tiến độ gây quỹ", + size = "md", +}: { + percent: number + className?: string + label?: string + size?: "sm" | "md" +}) { const value = Math.max(0, Math.min(100, percent)) return (
    -
    +
    ) } diff --git a/apps/web/src/components/badges.tsx b/apps/web/src/components/badges.tsx index 89ea3ca0..5aed622f 100644 --- a/apps/web/src/components/badges.tsx +++ b/apps/web/src/components/badges.tsx @@ -1,5 +1,5 @@ -import clsx from "clsx" - +import { Badge, type BadgeTone } from "@/components/ui/Badge" +import { CheckBadgeIcon, HeartIcon, SparkIcon } from "@/components/ui/icons" import { STATUS_LABELS, TYPE_LABELS, @@ -10,64 +10,66 @@ import { } from "@/lib/campaigns" import { ACCOUNT_STATUS_LABELS, type AccountStatus } from "@/lib/receiving-accounts" -const pill = "inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium" - -/** "❤️ Từ thiện" for give, "🚀 Dự án" for back (ported from the old TypeBadge). */ +/** "Từ thiện" for give (heart), "Dự án" for back (spark). */ export function TypeBadge({ type, className }: { type: CampaignType; className?: string }) { return ( - : } + className={className} > - {type === "give" ? "❤️" : "🚀"} {TYPE_LABELS[type]} - + {TYPE_LABELS[type]} + ) } -const STATUS_COLORS: Record = { - draft: "bg-gray-100 text-gray-700", - in_review: "bg-amber-100 text-amber-800", - changes_requested: "bg-orange-100 text-orange-800", - approved: "bg-sky-100 text-sky-800", - live: "bg-green-100 text-green-800", - settling: "bg-indigo-100 text-indigo-800", - succeeded: "bg-emerald-100 text-emerald-800", - failed: "bg-gray-100 text-gray-700", - closed: "bg-gray-100 text-gray-700", - suspended: "bg-red-100 text-red-800", - cancelled: "bg-gray-100 text-gray-500", +const STATUS_TONES: Record = { + draft: "neutral", + in_review: "warning", + changes_requested: "accent", + approved: "info", + live: "brand", + settling: "info", + succeeded: "brand", + failed: "neutral", + closed: "neutral", + suspended: "danger", + cancelled: "neutral", } export function StatusBadge({ status, className }: { status: CampaignStatus; className?: string }) { - return {STATUS_LABELS[status]} + return ( + + {STATUS_LABELS[status]} + + ) } export function VerificationBadge({ status }: { status: VerificationStatus }) { return ( - : undefined} > - {status === "verified" ? "✓ " : ""} {VERIFICATION_LABELS[status]} - + ) } -const ACCOUNT_STATUS_COLORS: Record = { - pending: "bg-amber-100 text-amber-800", - verified: "bg-green-100 text-green-800", - rejected: "bg-red-100 text-red-700", - disabled: "bg-gray-100 text-gray-600", +const ACCOUNT_STATUS_TONES: Record = { + pending: "warning", + verified: "brand", + rejected: "danger", + disabled: "neutral", } export function AccountStatusBadge({ status }: { status: AccountStatus }) { return ( - - {status === "verified" ? "✓ " : ""} + : undefined} + > {ACCOUNT_STATUS_LABELS[status]} - + ) } diff --git a/apps/web/src/components/home/FeaturedReceipt.tsx b/apps/web/src/components/home/FeaturedReceipt.tsx new file mode 100644 index 00000000..39774952 --- /dev/null +++ b/apps/web/src/components/home/FeaturedReceipt.tsx @@ -0,0 +1,86 @@ +import Image from "next/image" +import Link from "next/link" + +import { TypeBadge } from "@/components/badges" +import { ProgressBar } from "@/components/ProgressBar" +import { CoverPlaceholder } from "@/components/ui/CoverPlaceholder" +import { MoneyRow } from "@/components/ui/Money" +import type { PublicCampaign } from "@/lib/campaigns" +import { publicCoverUrl } from "@/lib/cover-urls" +import { daysLeft, progressPercent } from "@/lib/format" +import { raisedAmount, remainingAmount, statementHref, type MoneyTotals } from "@/lib/statement" + +/** + * The home hero's featured campaign, shown as a receipt of its public + * statement: what came in, what went back to donors, what remains (give), + * or pledged against the goal (back). The figures are the same ones the + * campaign page and its statement show. + */ +export function FeaturedReceipt({ campaign, totals }: { campaign: PublicCampaign; totals: MoneyTotals | undefined }) { + const isGive = campaign.type === "give" + const raised = raisedAmount(totals, campaign.type) + const percent = progressPercent(raised, campaign.goal_amount) + const days = daysLeft(campaign.ends_at) + const people = totals ? (isGive ? totals.supporters_count : totals.backers_count) : 0 + + return ( +
    +
    +
    + {campaign.cover?.storage_path ? ( + + ) : ( + + )} +
    +
    + +

    + + {campaign.title} + +

    +
    +
    + +
    + {isGive && totals &&

    Theo sao kê tài khoản nhận

    } +
    + {isGive && totals ? ( + <> + + + + + + ) : ( + <> + + + + )} +
    +
    + +

    + {percent}% mục tiêu + + {people} {isGive ? "người ủng hộ" : "người đồng hành"} + + {days !== null && {days > 0 ? `Còn ${days} ngày` : "Đã hết hạn"}} +

    +
    + + Xem sao kê của chiến dịch + +
    +
    + ) +} diff --git a/apps/web/src/components/layout/Disclosure.tsx b/apps/web/src/components/layout/Disclosure.tsx new file mode 100644 index 00000000..5ea0b838 --- /dev/null +++ b/apps/web/src/components/layout/Disclosure.tsx @@ -0,0 +1,85 @@ +"use client" + +import clsx from "clsx" +import { usePathname } from "next/navigation" +import { useEffect, useId, useRef, useState, type ReactNode } from "react" + +/** + * A button that shows and hides a panel (the mobile menu, the account menu). + * The panel closes on navigation, on Escape (focus returns to the button) + * and on a click outside. Without JavaScript the button does nothing, so + * every link in a panel is also reachable from a page of its own. + */ +export function Disclosure({ + label, + openLabel, + button, + openButton, + className, + buttonClassName, + panelClassName, + children, +}: { + /** Accessible name of the button while the panel is closed. */ + label: string + /** Accessible name while open; defaults to `label`. */ + openLabel?: string + /** What the button shows while closed, and while open (defaults to the same). */ + button: ReactNode + openButton?: ReactNode + className?: string + buttonClassName?: string + panelClassName?: string + children: ReactNode +}) { + const [open, setOpen] = useState(false) + const pathname = usePathname() + const id = useId() + const root = useRef(null) + const trigger = useRef(null) + + // Close after navigating (the layout, and so this component, persists). + const [shownFor, setShownFor] = useState(pathname) + if (shownFor !== pathname) { + setShownFor(pathname) + setOpen(false) + } + + useEffect(() => { + if (!open) return + const onKey = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setOpen(false) + trigger.current?.focus() + } + } + const onClick = (event: MouseEvent) => { + if (root.current && !root.current.contains(event.target as Node)) setOpen(false) + } + document.addEventListener("keydown", onKey) + document.addEventListener("click", onClick) + return () => { + document.removeEventListener("keydown", onKey) + document.removeEventListener("click", onClick) + } + }, [open]) + + return ( +
    + + +
    + ) +} diff --git a/apps/web/src/components/layout/Logo.tsx b/apps/web/src/components/layout/Logo.tsx new file mode 100644 index 00000000..1e84d555 --- /dev/null +++ b/apps/web/src/components/layout/Logo.tsx @@ -0,0 +1,22 @@ +import Link from "next/link" + +/** The Give wordmark: a green square with a ledger tick, then the name. */ +export function Logo() { + return ( + + + + + + + Give + + ) +} diff --git a/apps/web/src/components/layout/SiteFooter.tsx b/apps/web/src/components/layout/SiteFooter.tsx new file mode 100644 index 00000000..67729d6a --- /dev/null +++ b/apps/web/src/components/layout/SiteFooter.tsx @@ -0,0 +1,71 @@ +import Link from "next/link" + +import { Logo } from "@/components/layout/Logo" +import { discoveryHref } from "@/lib/campaigns" + +const SECTIONS: { title: string; links: { href: string; label: string }[] }[] = [ + { + title: "Gây quỹ", + links: [ + { href: "/campaigns", label: "Khám phá chiến dịch" }, + { href: discoveryHref({ type: "give" }), label: "Từ thiện" }, + { href: discoveryHref({ type: "back" }), label: "Dự án sáng tạo" }, + { href: "/start", label: "Tạo chiến dịch" }, + ], + }, + { + title: "Minh bạch", + links: [ + { href: "/#minh-bach", label: "Tiền đi thẳng vào tài khoản nhận" }, + { href: "/#hai-hinh-thuc", label: "Từ thiện và dự án khác nhau thế nào" }, + ], + }, + { + title: "Tài khoản", + links: [ + { href: "/me/campaigns", label: "Chiến dịch của tôi" }, + { href: "/me/contributions", label: "Ủng hộ của tôi" }, + { href: "/me/accounts", label: "Tài khoản nhận" }, + ], + }, +] + +/** + * Site footer (DOS/Give#95): the sitemap of pages that exist today. About, + * help and legal pages join it when they are written. + */ +export function SiteFooter() { + return ( +
    +
    +
    + +

    + Give không giữ tiền: tiền ủng hộ đi thẳng vào tài khoản nhận của từng chiến dịch, và mọi giao dịch đều có + sao kê công khai. +

    +
    + {SECTIONS.map((section) => ( + + ))} +
    +
    +

    © {new Date().getFullYear()} Give. Một sản phẩm của DOS.

    +
    +
    + ) +} diff --git a/apps/web/src/components/layout/SiteHeader.tsx b/apps/web/src/components/layout/SiteHeader.tsx new file mode 100644 index 00000000..03438e03 --- /dev/null +++ b/apps/web/src/components/layout/SiteHeader.tsx @@ -0,0 +1,196 @@ +import Link from "next/link" + +import { Disclosure } from "@/components/layout/Disclosure" +import { Logo } from "@/components/layout/Logo" +import { buttonClass } from "@/components/ui/Button" +import { BellIcon, ChevronDownIcon, CloseIcon, MenuIcon, SearchIcon } from "@/components/ui/icons" +import type { Session } from "@/lib/auth/session" + +const navLink = + "inline-flex min-h-11 items-center rounded px-3 text-base font-medium text-body hover:bg-surface hover:text-ink" +const menuLink = "flex min-h-11 items-center rounded px-3 text-base text-ink hover:bg-surface" + +/** The search box: a plain GET to discovery, so it works before hydration. */ +function SearchForm({ id, className }: { id: string; className?: string }) { + return ( +
    + +
    + + +
    +
    + ) +} + +/** + * Site header (DOS/Give#95): search-centric like Kickstarter, three + * destinations (Khám phá, Tạo chiến dịch, Chiến dịch của tôi) and sign-in. + * The signed-in extras (contributions, receiving accounts, sign-out) sit in + * the account menu; on phones everything is in the menu panel. + */ +export function SiteHeader({ session, unread }: { session: Session | null; unread: string | null }) { + const name = session ? (session.user.name ?? session.user.email ?? "Tài khoản") : null + + const notices = session && ( + + + {unread && ( + + {unread} + + )} + + ) + + const logout = ( +
    + +
    + ) + + return ( +
    + + Bỏ qua, tới nội dung chính + + +
    + ) +} diff --git a/apps/web/src/components/ui/Badge.tsx b/apps/web/src/components/ui/Badge.tsx new file mode 100644 index 00000000..13fd5f73 --- /dev/null +++ b/apps/web/src/components/ui/Badge.tsx @@ -0,0 +1,39 @@ +import clsx from "clsx" +import type { ReactNode } from "react" + +export type BadgeTone = "neutral" | "brand" | "accent" | "info" | "warning" | "danger" + +const TONES: Record = { + neutral: "bg-gray-100 text-gray-800", + brand: "bg-brand-50 text-brand-800 ring-1 ring-brand-200 ring-inset", + accent: "bg-accent-100 text-accent-800", + info: "bg-sky-50 text-sky-800", + warning: "bg-amber-50 text-amber-900", + danger: "bg-red-50 text-red-800", +} + +/** A small status label: low radius, never a pill (docs/Design-Principles.md). */ +export function Badge({ + tone = "neutral", + icon, + className, + children, +}: { + tone?: BadgeTone + icon?: ReactNode + className?: string + children: ReactNode +}) { + return ( + + {icon} + {children} + + ) +} diff --git a/apps/web/src/components/ui/Button.tsx b/apps/web/src/components/ui/Button.tsx new file mode 100644 index 00000000..a2b1b2a8 --- /dev/null +++ b/apps/web/src/components/ui/Button.tsx @@ -0,0 +1,55 @@ +import clsx from "clsx" +import Link from "next/link" +import type { ComponentProps, ReactNode } from "react" + +export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger" + +const VARIANTS: Record = { + primary: "btn-primary", + secondary: "btn-secondary", + ghost: "btn-ghost", + danger: "btn-danger", +} + +/** + * The class list of a button (DOS/Give#95): every size keeps the 44px touch + * target; `lg` is the one primary action of a page. + */ +export function buttonClass({ + variant = "primary", + size = "md", + full, + className, +}: { variant?: ButtonVariant; size?: "md" | "lg"; full?: boolean; className?: string } = {}): string { + return clsx(VARIANTS[variant], size === "lg" && "min-h-12 px-6 text-lg", full && "w-full", className) +} + +type Style = Parameters[0] + +export function Button({ + variant, + size, + full, + className, + type = "button", + ...props +}: ComponentProps<"button"> & Style) { + return