feat(me): show actionable personal tasks with unavailable states
This commit is contained in:
1 parent
d9ea93a2e7
commit
a537c55793
9 files changed
+435
-12
No files matched your search
@@ -0,0 +1,108 @@
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { createTranslator } from "next-intl";
|
||||
import type { ReactNode } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
data: {
|
||||
support: 2 as number | null,
|
||||
friendRequests: 3 as number | null,
|
||||
registeredEvent: false as
|
||||
| false
|
||||
| null
|
||||
| { id: number; title: string; startsAt: Date },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/services/dashboard-tasks", () => ({
|
||||
loadDashboardTasks: async () => state.data,
|
||||
}));
|
||||
vi.mock("@/components/link", () => ({
|
||||
default: ({
|
||||
href,
|
||||
children,
|
||||
...props
|
||||
}: {
|
||||
href: string;
|
||||
children: ReactNode;
|
||||
}) => (
|
||||
<a href={href} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
vi.mock("next-intl/server", () => ({
|
||||
getTranslations: async () => {
|
||||
const flat = JSON.parse(
|
||||
readFileSync("docs/task-account-i18n.json", "utf8"),
|
||||
).it;
|
||||
const messages = Object.fromEntries(
|
||||
Object.entries(flat).map(([key, value]) => [
|
||||
key.replace("pages.myDashboard.tasks.", ""),
|
||||
value,
|
||||
]),
|
||||
);
|
||||
return createTranslator({ locale: "it", messages });
|
||||
},
|
||||
getFormatter: async () => ({
|
||||
dateTime: (date: Date) =>
|
||||
new Intl.DateTimeFormat("it", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
timeZone: "UTC",
|
||||
}).format(date),
|
||||
}),
|
||||
}));
|
||||
|
||||
import DashboardTasks from "./DashboardTasks";
|
||||
|
||||
describe("dashboard task presentation", () => {
|
||||
it("renders actionable links from real task data", async () => {
|
||||
state.data = {
|
||||
support: 2,
|
||||
friendRequests: 3,
|
||||
registeredEvent: {
|
||||
id: 7,
|
||||
title:
|
||||
"Un evento con un titolo molto lungo per verificare la visualizzazione sullo schermo del telefono",
|
||||
startsAt: new Date("2026-09-12T15:00:00Z"),
|
||||
},
|
||||
};
|
||||
const html = renderToStaticMarkup(
|
||||
await DashboardTasks({ userId: 42, rewardAvailable: true }),
|
||||
);
|
||||
expect(html).toContain('href="/help/tickets"');
|
||||
expect(html).toContain('href="/friends"');
|
||||
expect(html).toContain('href="#referral-reward"');
|
||||
expect(html).toContain("-7");
|
||||
if (process.env.ACCOUNT_VISUAL_FIXTURE) {
|
||||
const css = readFileSync(
|
||||
"src/app/(site)/me/dashboard.module.css",
|
||||
"utf8",
|
||||
).replaceAll(/\.([\w]+)(?=[\s.{:#>])/g, ".$1");
|
||||
// Vitest CSS modules are scoped; reproduce those class names for the fixture.
|
||||
const taskClass = html.match(/<ul class="([^"]+)"/)?.[1] ?? "tasks";
|
||||
const output = join("storage", "account-fixtures");
|
||||
mkdirSync(output, { recursive: true });
|
||||
writeFileSync(
|
||||
join(output, "tasks.html"),
|
||||
`<!doctype html><html lang="it"><meta name="viewport" content="width=device-width, initial-scale=1"><style>:root{--color-border:#ddd;--color-primary:#a34023;--color-text-muted:#777;--color-surface:#fff}*{box-sizing:border-box}body{font:16px Arial;background:#f7f5f2;margin:0;padding:20px}main{max-width:1000px;margin:auto}a{color:#943712;text-decoration:none}.p-5{padding:20px}.surface-card{border:1px solid #ddd;border-radius:16px;padding:12px}h3{margin:12px}${css.replaceAll(".tasks", "." + taskClass)}</style><main>${html}<div id="referral-reward">Premio inviti</div></main></html>`,
|
||||
);
|
||||
}
|
||||
});
|
||||
it("shows an honest empty state only after successful queries", async () => {
|
||||
state.data = { support: 0, friendRequests: 0, registeredEvent: false };
|
||||
expect(
|
||||
renderToStaticMarkup(
|
||||
await DashboardTasks({ userId: 42, rewardAvailable: false }),
|
||||
),
|
||||
).toContain("Nessuna azione");
|
||||
state.data = { support: null, friendRequests: null, registeredEvent: null };
|
||||
const html = renderToStaticMarkup(
|
||||
await DashboardTasks({ userId: 42, rewardAvailable: null }),
|
||||
);
|
||||
expect(html).toContain('role="status"');
|
||||
expect(html).not.toContain("Nessuna azione");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { getFormatter, getTranslations } from "next-intl/server";
|
||||
import Link from "@/components/link";
|
||||
import { SurfaceCard } from "@/components/surface-card";
|
||||
import { slugify } from "@/lib/format";
|
||||
import { loadDashboardTasks } from "@/lib/services/dashboard-tasks";
|
||||
import styles from "./dashboard.module.css";
|
||||
|
||||
export default async function DashboardTasks({
|
||||
userId,
|
||||
rewardAvailable,
|
||||
}: {
|
||||
userId: number;
|
||||
rewardAvailable: boolean | null;
|
||||
}) {
|
||||
const [data, t, format] = await Promise.all([
|
||||
loadDashboardTasks(userId),
|
||||
getTranslations("pages.myDashboard.tasks"),
|
||||
getFormatter(),
|
||||
]);
|
||||
const items: { href: string; label: string }[] = [];
|
||||
if (data.support)
|
||||
items.push({
|
||||
href: "/help/tickets",
|
||||
label: t("support", { count: data.support }),
|
||||
});
|
||||
if (data.friendRequests)
|
||||
items.push({
|
||||
href: "/friends",
|
||||
label: t("friends", { count: data.friendRequests }),
|
||||
});
|
||||
if (data.registeredEvent)
|
||||
items.push({
|
||||
href: `/events/${slugify(data.registeredEvent.title)}-${data.registeredEvent.id}`,
|
||||
label: t("event", {
|
||||
title: data.registeredEvent.title,
|
||||
date: format.dateTime(data.registeredEvent.startsAt, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}),
|
||||
}),
|
||||
});
|
||||
if (rewardAvailable)
|
||||
items.push({ href: "#referral-reward", label: t("reward") });
|
||||
const unavailable =
|
||||
data.support === null ||
|
||||
data.friendRequests === null ||
|
||||
data.registeredEvent === null ||
|
||||
rewardAvailable === null;
|
||||
return (
|
||||
<SurfaceCard title={t("title")} bodyClassName="p-5">
|
||||
{items.length > 0 ? (
|
||||
<ul className={styles.tasks}>
|
||||
{items.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href}>
|
||||
{item.label}
|
||||
<span aria-hidden="true"> →</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : !unavailable ? (
|
||||
<p className="muted">{t("empty")}</p>
|
||||
) : null}
|
||||
{unavailable && (
|
||||
<p role="status" className="muted">
|
||||
{t("unavailable")} <Link href="/me">{t("retry")}</Link>
|
||||
</p>
|
||||
)}
|
||||
</SurfaceCard>
|
||||
);
|
||||
}
|
||||
@@ -325,3 +325,20 @@
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
.tasks {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.tasks a {
|
||||
display: block;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 10px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.tasks a:hover {
|
||||
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-surface));
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { auth } from "@/lib/auth";
|
||||
import { avatarImageUrl, slugify } from "@/lib/format";
|
||||
import { loadUserDashboard } from "@/lib/services/user-dashboard";
|
||||
import CopyReferralButton from "./CopyReferralButton";
|
||||
import DashboardTasks from "./DashboardTasks";
|
||||
import styles from "./dashboard.module.css";
|
||||
|
||||
export async function generateMetadata() {
|
||||
@@ -96,9 +97,13 @@ export default async function MePage({
|
||||
: null;
|
||||
const referralTotal = Math.max(
|
||||
0,
|
||||
Number(referralsRows[0]?.referralsTotal ?? 0),
|
||||
Number(referralsRows?.[0]?.referralsTotal ?? 0),
|
||||
);
|
||||
const canClaim = referralTotal >= needed;
|
||||
const canClaim =
|
||||
referralsRows !== null &&
|
||||
referralTotal >= needed &&
|
||||
rewardAmount > 0 &&
|
||||
rewardKind !== null;
|
||||
const remaining = Math.max(0, needed - referralTotal);
|
||||
const onlineFriends = friends.filter((friend) => friend.online === "1");
|
||||
const date = (value: number) =>
|
||||
@@ -195,6 +200,10 @@ export default async function MePage({
|
||||
))}
|
||||
</nav>
|
||||
{alertRaw && <p className={styles.feedback}>{alertRaw}</p>}
|
||||
<DashboardTasks
|
||||
userId={userId}
|
||||
rewardAvailable={referralsRows === null ? null : canClaim}
|
||||
/>
|
||||
<section aria-label={t("wallet")} className={styles.wallet}>
|
||||
{wallet.map((currency) => (
|
||||
<SurfaceCard key={currency.kind} className={styles.currency}>
|
||||
@@ -334,6 +343,7 @@ export default async function MePage({
|
||||
</div>
|
||||
<aside className={styles.stack}>
|
||||
<SurfaceCard title={t("referrals")} bodyClassName="p-5">
|
||||
<div id="referral-reward" />
|
||||
<p>{t("referralHint")}</p>
|
||||
<div className={styles.progressLabel}>
|
||||
<strong>
|
||||
@@ -369,7 +379,11 @@ export default async function MePage({
|
||||
className={styles.referralInput}
|
||||
/>
|
||||
<CopyReferralButton value={referralLink} />
|
||||
{canClaim ? (
|
||||
{referralsRows === null ? (
|
||||
<p role="status" className="muted">
|
||||
{t("tasks.rewardUnavailable")}
|
||||
</p>
|
||||
) : canClaim ? (
|
||||
<form action={claimReferral} className={styles.claim}>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
{t("claim")}
|
||||
|
||||
Reference in new issue
Block a user