refactor: unify health-severity logic, add live clock ticking, and drop polar/downgrade wrapper

- Extracted `getHealthSeverity` and `getDaysUntilExpiry` into `dashboard-utils.ts` as shared exports so `DomainHealthBadge`, `getHealthAccent`, and the table sort column all classify a domain identically; the badge, the health filter, and the sort order can no longer disagree on which bucket a domain belongs to.
- Added `EXPIRING_CRITICAL_DAYS = 7` to constants, tying the badge's red threshold to the `domain_expiry_7d` notification so the badge and the expiry email always agree on the cutoff.
- `useHydratedNow` now advances the shared clock every 60 seconds via `setInterval` after the first hydration, so health badges self-refresh on a long-lived tab without a page reload; the interval starts when the first subscriber attaches and is cleared when the last one unmounts. Added a `pinned` flag so `resetHydratedNow` in tests locks the clock and no subsequent tick can overwrite it.
- Deleted `packages/polar/src/downgrade.ts`; it was a single-line re-export of `downgradeToFree` — the workflow and the handlers now import that function directly from `@domainstack/db/queries/user-subscription` and the `./downgrade` export entry has been removed from the polar package manifest.
This commit is contained in:
2026-09-10 10:01:55 -04:00
parent 10968ed366
commit 017d493984
15 changed files with 162 additions and 111 deletions
@@ -348,7 +348,7 @@ export const DashboardGridCard = memo(function DashboardGridCard({
>
<div
className={cn(
"pointer-events-none absolute inset-0 rounded-xl transition-all duration-150",
"pointer-events-none absolute inset-0 rounded-xl transition duration-150",
selected ? "ring-2 ring-primary/60 ring-offset-2 ring-offset-background" : "ring-0",
)}
aria-hidden
@@ -17,6 +17,7 @@ import { ScreenshotPopover } from "@/components/domain/screenshot-popover";
import { Favicon } from "@/components/icons/favicon";
import { useIsDomainSelected, useToggleDomainSelection } from "@/hooks/use-dashboard-selection";
import type { DashboardTableFeatures } from "@/lib/dashboard-table-features";
import { getHealthSeverity, type HealthSeverity } from "@/lib/dashboard-utils";
import type { TrackedDomainWithDetails, VerificationMethod } from "@domainstack/types";
import { Button } from "@domainstack/ui/button";
import { Checkbox } from "@domainstack/ui/checkbox";
@@ -81,6 +82,14 @@ export const HIDEABLE_COLUMNS = (
] as const satisfies readonly (keyof typeof COLUMN_HEADERS)[]
).map((id) => ({ id, header: COLUMN_HEADERS[id] }));
/** Health badge severities in the order the health column sorts them. */
const HEALTH_SORT_PRIORITY: Record<HealthSeverity, number> = {
critical: 0,
warning: 1,
healthy: 2,
unknown: 3,
};
/**
* Creates a sorting function factory that pushes unverified domains to the end.
* Returns a function that creates `sortFn` functions with access to the current
@@ -273,13 +282,8 @@ export function createColumns(
// Within the same status, sort by expiration date for more granular ordering
sortFn: withUnverifiedLast((a, b) => {
const now = new Date();
const getHealthPriority = (exp: Date | null, verified: boolean): number => {
if (!verified || !exp) return 3; // unknown
const days = Math.floor((exp.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
if (days <= 7) return 0; // critical
if (days <= 30) return 1; // warning
return 2; // healthy
};
const getHealthPriority = (exp: Date | null, verified: boolean): number =>
HEALTH_SORT_PRIORITY[getHealthSeverity(exp, verified, now)];
const aPriority = getHealthPriority(a.expirationDate, a.verified);
const bPriority = getHealthPriority(b.expirationDate, b.verified);
@@ -5,16 +5,15 @@ import {
IconQuestionMark,
type TablerIcon,
} from "@tabler/icons-react";
import { differenceInDays, formatDistanceStrict } from "date-fns";
import { formatDistanceStrict } from "date-fns";
import { useMemo } from "react";
import { BadgeWithTooltip } from "@/components/dashboard/badge-with-tooltip";
import { useHydratedNow } from "@/hooks/use-hydrated-now";
import { getHealthSeverity, type HealthSeverity } from "@/lib/dashboard-utils";
import { cn } from "@domainstack/ui/utils";
import { toDateTimeAttr } from "@domainstack/utils/date";
type HealthStatus = "healthy" | "warning" | "critical" | "unknown";
type DomainHealthBadgeProps = {
expirationDate: Date | null;
verified: boolean;
@@ -26,7 +25,9 @@ export function DomainHealthBadge({ expirationDate, verified, className }: Domai
const dateTime = expirationDate ? toDateTimeAttr(expirationDate) : undefined;
// SSR and invalid dates stay "unknown" — NaN day counts would otherwise look healthy.
const status = now ? getHealthStatus(dateTime ? expirationDate : null, verified, now) : "unknown";
const status = now
? getHealthSeverity(dateTime ? expirationDate : null, verified, now)
: "unknown";
const { label, colorClass, icon } = getStatusConfig(status);
const tooltipText = useMemo(() => {
@@ -55,23 +56,7 @@ export function DomainHealthBadge({ expirationDate, verified, className }: Domai
);
}
function getHealthStatus(expirationDate: Date | null, verified: boolean, now: Date): HealthStatus {
if (!verified || !expirationDate || Number.isNaN(expirationDate.getTime())) {
return "unknown";
}
const daysUntilExpiry = differenceInDays(expirationDate, now);
if (daysUntilExpiry <= 7) {
return "critical";
}
if (daysUntilExpiry <= 30) {
return "warning";
}
return "healthy";
}
function getStatusConfig(status: HealthStatus): {
function getStatusConfig(status: HealthSeverity): {
label: string;
colorClass: string;
icon: TablerIcon;
@@ -117,7 +102,7 @@ export function getHealthAccent(
// If no 'now' provided, return slate (unknown) to avoid Date.now() during SSR
if (!now) return "slate";
const status = getHealthStatus(expirationDate, verified, now);
const status = getHealthSeverity(expirationDate, verified, now);
switch (status) {
case "healthy":
+2 -3
View File
@@ -92,8 +92,7 @@ export function SectionNav({
>
<div
className={cn(
"flex shrink-0 items-center gap-2 overflow-hidden",
"transition-all duration-200 ease-out",
"flex shrink-0 items-center gap-2 overflow-hidden transition duration-200 ease-out",
isHeaderVisible
? "w-0 opacity-0"
: "mr-3 w-auto border-r border-black/10 pr-3 opacity-100 dark:border-white/10",
@@ -129,7 +128,7 @@ export function SectionNav({
}
className={cn(
"rounded-md px-3 py-1.5 text-[13px] tracking-[0.01em] whitespace-nowrap",
"transition-all duration-150",
"transition duration-150",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none",
"hover:!bg-[color-mix(in_oklch,var(--section-accent)_15%,transparent)] hover:text-foreground",
activeSection === slug
+44 -6
View File
@@ -10,6 +10,36 @@ import { useSyncExternalStore } from "react";
let hydratedNow: Date | null = null;
const listeners = new Set<() => void>();
let cancelPendingInitializer: (() => void) | null = null;
let tickTimer: ReturnType<typeof setInterval> | null = null;
/** Set by `resetHydratedNow` so a pinned test clock never drifts. */
let pinned = false;
/**
* How often the shared clock advances. Everything built on it is phrased in
* minutes or days ("expires in 6 days"), so a minute is fine and keeps the
* re-render cascade rare on a tab left open for hours.
*/
const TICK_MS = 60_000;
function emit(): void {
for (const listener of listeners) {
listener();
}
}
function startTicking(): void {
if (pinned || tickTimer !== null || typeof window === "undefined") return;
tickTimer = setInterval(() => {
hydratedNow = new Date();
emit();
}, TICK_MS);
}
function stopTicking(): void {
if (tickTimer === null) return;
clearInterval(tickTimer);
tickTimer = null;
}
function subscribe(callback: () => void): () => void {
listeners.add(callback);
@@ -25,18 +55,25 @@ function subscribe(callback: () => void): () => void {
return;
}
hydratedNow = new Date();
for (const listener of listeners) {
listener();
}
emit();
startTicking();
});
cancelPendingInitializer = () => {
cancelled = true;
window.cancelAnimationFrame(frame);
};
} else if (hydratedNow !== null) {
// Clock already running from an earlier mount; keep it going.
startTicking();
}
return () => {
listeners.delete(callback);
// Nothing on screen depends on the time any more, so stop re-rendering
// for it. The next subscriber restarts the interval.
if (listeners.size === 0) {
stopTicking();
}
};
}
@@ -67,8 +104,9 @@ export function useHydratedNow(): Date | null {
export function resetHydratedNow(date: Date | null = new Date()): void {
cancelPendingInitializer?.();
cancelPendingInitializer = null;
// Pinning the clock means pinning it: no interval may advance it afterwards.
stopTicking();
pinned = date !== null;
hydratedNow = date ? new Date(date.getTime()) : null;
for (const listener of listeners) {
listener();
}
emit();
}
+46 -6
View File
@@ -1,7 +1,8 @@
import type { SortingState } from "@tanstack/react-table";
import { EXPIRING_SOON_DAYS } from "@domainstack/constants";
import { EXPIRING_CRITICAL_DAYS, EXPIRING_SOON_DAYS } from "@domainstack/constants";
import type { TrackedDomainWithDetails } from "@domainstack/types";
import { calculateDaysRemaining } from "@domainstack/utils/expiry";
// ---------------------------------------------------------------------------
// Provider Types
@@ -30,6 +31,29 @@ const VALID_STATUS_FILTERS = new Set<StatusFilter>(["verified", "pending"]);
/** Valid health filter values for runtime validation of URL params */
const VALID_HEALTH_FILTERS = new Set<HealthFilter>(["healthy", "expiring", "expired"]);
/** Severity shown on a domain's health badge. */
export type HealthSeverity = "healthy" | "warning" | "critical" | "unknown";
/**
* Whole days until a tracked domain expires, or null when there is nothing to
* count (unverified, missing date, unparseable date).
*
* Every dashboard reading of "days left" goes through here so the filter, the
* summary counts, the row badge, and the sort all classify a domain the same
* way. `calculateDaysRemaining` is the same helper the expiry notifications
* use, so the dashboard and the emails agree on the day count too.
*/
export function getDaysUntilExpiry(
expirationDate: Date | null,
verified: boolean,
now: Date,
): number | null {
if (!verified || !expirationDate || Number.isNaN(expirationDate.getTime())) return null;
const days = calculateDaysRemaining(expirationDate, now);
return Number.isNaN(days) ? null : days;
}
/**
* Determine health status based on expiration date
*/
@@ -38,17 +62,33 @@ function getHealthStatus(
verified: boolean,
now: Date,
): HealthFilter | null {
if (!verified || !expirationDate || Number.isNaN(expirationDate.getTime())) return null;
const daysUntilExpiry = Math.ceil(
(expirationDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24),
);
const daysUntilExpiry = getDaysUntilExpiry(expirationDate, verified, now);
if (daysUntilExpiry === null) return null;
if (daysUntilExpiry <= 0) return "expired";
if (daysUntilExpiry <= EXPIRING_SOON_DAYS) return "expiring";
return "healthy";
}
/**
* Determine badge severity based on expiration date.
*
* Shares its day count and thresholds with the health filter above, so a row
* badge can never contradict the "Expiring Soon" summary beside it.
*/
export function getHealthSeverity(
expirationDate: Date | null,
verified: boolean,
now: Date,
): HealthSeverity {
const daysUntilExpiry = getDaysUntilExpiry(expirationDate, verified, now);
if (daysUntilExpiry === null) return "unknown";
if (daysUntilExpiry <= EXPIRING_CRITICAL_DAYS) return "critical";
if (daysUntilExpiry <= EXPIRING_SOON_DAYS) return "warning";
return "healthy";
}
export const DASHBOARD_VIEW_MODE_OPTIONS = ["grid", "table"] as const;
export type DashboardViewModeOptions = (typeof DASHBOARD_VIEW_MODE_OPTIONS)[number];
@@ -85,14 +85,13 @@ async function clearEndsAt(userId: string): Promise<void> {
async function downgrade(userId: string): Promise<number> {
"use step";
const [{ clearSubscriptionEndsAt }, { handleDowngrade }, { sendSubscriptionExpiredEmail }] =
const [{ clearSubscriptionEndsAt, downgradeToFree }, { sendSubscriptionExpiredEmail }] =
await Promise.all([
import("@domainstack/db/queries/user-subscription"),
import("@domainstack/polar/downgrade"),
import("@domainstack/polar/emails"),
]);
const archivedCount = await handleDowngrade(userId);
const archivedCount = await downgradeToFree(userId);
await clearSubscriptionEndsAt(userId);
try {
@@ -34,6 +34,10 @@ type NotificationType =
// Dashboard "expiring soon" threshold (first notification threshold)
export const EXPIRING_SOON_DAYS = DOMAIN_EXPIRY_THRESHOLDS[0];
// Dashboard "critical" threshold, where an expiry badge turns red. Matches the
// `domain_expiry_7d` notification so the badge and the email agree.
export const EXPIRING_CRITICAL_DAYS = 7 satisfies DomainExpiryThreshold;
// Mapping from threshold to notification type
export const DOMAIN_THRESHOLD_TO_TYPE: Record<DomainExpiryThreshold, NotificationType> = {
30: "domain_expiry_30d",
-1
View File
@@ -7,7 +7,6 @@
"./products": "./src/products.ts",
"./handlers": "./src/handlers.ts",
"./emails": "./src/emails.ts",
"./downgrade": "./src/downgrade.ts",
"./reconcile": "./src/reconcile.ts",
"./better-auth/server": "./src/better-auth/server.ts",
"./better-auth/client": "./src/better-auth/client.ts",
-12
View File
@@ -1,12 +0,0 @@
import { downgradeToFree } from "@domainstack/db/queries/user-subscription";
/**
* Handle user downgrade from Pro to Free tier.
* Archives oldest domains that exceed the free tier limit.
*
* @param userId - The user ID (Polar customer ID maps to our user ID)
* @returns The number of domains that were archived (0 if none)
*/
export async function handleDowngrade(userId: string): Promise<number> {
return downgradeToFree(userId);
}
+15 -17
View File
@@ -35,6 +35,7 @@ const {
updateUserTier,
setSubscriptionEndsAt,
clearSubscriptionEndsAt,
downgradeToFree,
getUserSubscription,
getCustomerSubscriptionState,
createMockLogger,
@@ -60,6 +61,7 @@ const {
updateUserTier: vi.fn<(userId: string, tier: "free" | "pro") => Promise<void>>(),
setSubscriptionEndsAt: vi.fn<(userId: string, endsAt: Date) => Promise<void>>(),
clearSubscriptionEndsAt: vi.fn<(userId: string) => Promise<void>>(),
downgradeToFree: vi.fn<(userId: string) => Promise<number>>(),
getUserSubscription: vi.fn<(userId: string) => Promise<UserSubscriptionFixture>>(),
getCustomerSubscriptionState: vi.fn<(userId: string) => Promise<CustomerStateFixture>>(),
createMockLogger: buildMockLogger,
@@ -73,6 +75,7 @@ vi.mock("@domainstack/db/queries/user-subscription", () => ({
updateUserTier,
setSubscriptionEndsAt,
clearSubscriptionEndsAt,
downgradeToFree,
getUserSubscription,
}));
@@ -83,10 +86,6 @@ vi.mock("@domainstack/logger", () => ({
),
}));
vi.mock("./downgrade", () => ({
handleDowngrade: vi.fn<(userId: string) => Promise<number>>(),
}));
vi.mock("./products", () => ({
getTierForProductId: vi.fn<(productId: string) => "pro" | null>(),
getProductByProductId:
@@ -109,7 +108,6 @@ vi.mock("./reconcile", () => ({
getCustomerSubscriptionState,
}));
import { handleDowngrade } from "./downgrade";
import {
sendProUpgradeEmail,
sendSubscriptionCancelingEmail,
@@ -354,7 +352,7 @@ describe("handleSubscriptionCanceled", () => {
expect(setSubscriptionEndsAt).toHaveBeenCalledWith("user-456", periodEnd);
// Should NOT change tier yet
expect(updateUserTier).not.toHaveBeenCalled();
expect(handleDowngrade).not.toHaveBeenCalled();
expect(downgradeToFree).not.toHaveBeenCalled();
});
it("does not set end date when currentPeriodEnd is null", async () => {
@@ -452,15 +450,15 @@ describe("handleSubscriptionCanceled", () => {
describe("handleSubscriptionRevoked", () => {
beforeEach(() => {
vi.resetAllMocks();
// Default: handleDowngrade returns 0 archived domains
vi.mocked(handleDowngrade).mockResolvedValue(0);
// Default: downgradeToFree returns 0 archived domains
vi.mocked(downgradeToFree).mockResolvedValue(0);
vi.mocked(getCustomerSubscriptionState).mockResolvedValue(okPolarState());
});
it("calls handleDowngrade with user ID from customer.externalId", async () => {
it("calls downgradeToFree with user ID from customer.externalId", async () => {
await handleSubscriptionRevoked(createRevokedPayload());
expect(handleDowngrade).toHaveBeenCalledWith("user-456");
expect(downgradeToFree).toHaveBeenCalledWith("user-456");
});
it("clears subscription end date after downgrade", async () => {
@@ -470,7 +468,7 @@ describe("handleSubscriptionRevoked", () => {
});
it("sends subscription expired email with archived count", async () => {
vi.mocked(handleDowngrade).mockResolvedValue(3);
vi.mocked(downgradeToFree).mockResolvedValue(3);
await handleSubscriptionRevoked(createRevokedPayload());
@@ -483,14 +481,14 @@ describe("handleSubscriptionRevoked", () => {
// Should not throw - email errors are logged but swallowed
await expect(handleSubscriptionRevoked(createRevokedPayload())).resolves.not.toThrow();
expect(handleDowngrade).toHaveBeenCalled();
expect(downgradeToFree).toHaveBeenCalled();
expect(clearSubscriptionEndsAt).toHaveBeenCalled();
});
it("does not downgrade when externalId (userId) is missing", async () => {
await handleSubscriptionRevoked(createRevokedPayload({ userId: null }));
expect(handleDowngrade).not.toHaveBeenCalled();
expect(downgradeToFree).not.toHaveBeenCalled();
expect(clearSubscriptionEndsAt).not.toHaveBeenCalled();
expect(sendSubscriptionExpiredEmail).not.toHaveBeenCalled();
});
@@ -500,7 +498,7 @@ describe("handleSubscriptionRevoked", () => {
await handleSubscriptionRevoked(createRevokedPayload());
expect(handleDowngrade).not.toHaveBeenCalled();
expect(downgradeToFree).not.toHaveBeenCalled();
expect(clearSubscriptionEndsAt).not.toHaveBeenCalled();
});
@@ -511,12 +509,12 @@ describe("handleSubscriptionRevoked", () => {
await handleSubscriptionRevoked(createRevokedPayload());
expect(handleDowngrade).not.toHaveBeenCalled();
expect(downgradeToFree).not.toHaveBeenCalled();
expect(clearSubscriptionEndsAt).not.toHaveBeenCalled();
});
it("re-throws errors from handleDowngrade for webhook retry", async () => {
vi.mocked(handleDowngrade).mockRejectedValue(new Error("Downgrade failed"));
it("re-throws errors from downgradeToFree for webhook retry", async () => {
vi.mocked(downgradeToFree).mockRejectedValue(new Error("Downgrade failed"));
await expect(handleSubscriptionRevoked(createRevokedPayload())).rejects.toThrow(
"Downgrade failed",
+2 -2
View File
@@ -2,6 +2,7 @@ import type { WebhooksOptions } from "@polar-sh/better-auth";
import {
clearSubscriptionEndsAt,
downgradeToFree,
getUserSubscription,
setSubscriptionEndsAt,
updateUserTier,
@@ -9,7 +10,6 @@ import {
import { createLogger } from "@domainstack/logger";
import { analytics } from "./analytics";
import { handleDowngrade } from "./downgrade";
import {
sendProUpgradeEmail,
sendSubscriptionCancelingEmail,
@@ -243,7 +243,7 @@ export async function handleSubscriptionRevoked(
}
// Downgrade user to free tier (may archive domains if over limit)
const archivedCount = await handleDowngrade(userId);
const archivedCount = await downgradeToFree(userId);
// Clear the subscription end date
await clearSubscriptionEndsAt(userId);
@@ -6,8 +6,7 @@ const { makePGliteDb, closePGliteDb } = await import("@domainstack/db/testing");
const { db } = await makePGliteDb();
// Now import modules that depend on the db (they'll use the test db via lazy init)
const { handleDowngrade } = await import("./downgrade");
const { getUserIdsPastDue, getUserSubscription } =
const { downgradeToFree, getUserIdsPastDue, getUserSubscription } =
await import("@domainstack/db/queries/user-subscription");
const { domains, userSubscriptions, users, userTrackedDomains } =
await import("@domainstack/db/schema");
@@ -60,7 +59,7 @@ beforeEach(async () => {
await db.update(userSubscriptions).set({ tier: "pro" });
});
describe("handleDowngrade", () => {
describe("downgradeToFree", () => {
it("updates user tier to free", async () => {
// Add 3 tracked domains (under limit)
for (let i = 0; i < 3; i++) {
@@ -72,7 +71,7 @@ describe("handleDowngrade", () => {
});
}
await handleDowngrade(testUserId);
await downgradeToFree(testUserId);
// Check tier was updated to free
const [subscription] = await db.select().from(userSubscriptions).limit(1);
@@ -90,7 +89,7 @@ describe("handleDowngrade", () => {
});
}
const result = await handleDowngrade(testUserId);
const result = await downgradeToFree(testUserId);
expect(result).toBe(0);
@@ -110,7 +109,7 @@ describe("handleDowngrade", () => {
});
}
const result = await handleDowngrade(testUserId);
const result = await downgradeToFree(testUserId);
expect(result).toBe(0);
@@ -130,7 +129,7 @@ describe("handleDowngrade", () => {
});
}
const result = await handleDowngrade(testUserId);
const result = await downgradeToFree(testUserId);
expect(result).toBe(3);
@@ -156,7 +155,7 @@ describe("handleDowngrade", () => {
});
}
const result = await handleDowngrade(testUserId);
const result = await downgradeToFree(testUserId);
expect(result).toBe(2);
@@ -195,7 +194,7 @@ describe("handleDowngrade", () => {
verified: true,
});
const result = await handleDowngrade(newUser.id);
const result = await downgradeToFree(newUser.id);
expect(result).toBe(0);
+20 -21
View File
@@ -19,35 +19,34 @@ let redis: Redis | undefined;
* @returns Redis client instance, or undefined if not configured
*/
export function getRedis(): Redis | undefined {
if (
process.env.NODE_ENV !== "production" &&
(!process.env.UPSTASH_REDIS_REST_URL || !process.env.UPSTASH_REDIS_REST_TOKEN)
) {
logger.warn("UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN are not set");
// Don't block app if Redis is not set in development
// Mirror the fallbacks in `Redis.fromEnv`, which also accepts the
// `KV_REST_API_*` pair used by Vercel KV. Checking only the `UPSTASH_*`
// names would report "not configured" on a perfectly good Vercel KV setup.
const url = process.env.UPSTASH_REDIS_REST_URL || process.env.KV_REST_API_URL;
const token = process.env.UPSTASH_REDIS_REST_TOKEN || process.env.KV_REST_API_TOKEN;
if (!url || !token) {
// `Redis.fromEnv` only warns on a missing pair and hands back a client
// whose every call fails. Callers all branch on `undefined` to fail open,
// so return that instead of a client that cannot work.
const message =
"Redis is not configured (set UPSTASH_REDIS_REST_URL/TOKEN or KV_REST_API_URL/TOKEN); continuing without it";
// Expected locally, but in production it silently drops rate limiting and
// session caching, so it should page rather than blend into the logs.
if (process.env.NODE_ENV === "production") {
logger.error(message);
} else {
logger.warn(message);
}
return undefined;
}
if (!redis) {
redis = Redis.fromEnv();
redis = new Redis({ url, token });
}
return redis;
}
/**
* Create a Redis client from explicit configuration.
* Use this when environment variables are not available.
*
* @param config - Redis connection configuration
* @returns Redis client instance
*/
export function createRedisClient(config: { url: string; token: string }): Redis {
return new Redis({
url: config.url,
token: config.token,
});
}
// Re-export the Redis type for consumers
export { Redis } from "@upstash/redis";
@@ -64,7 +64,6 @@ function PaginationLink({
nativeButton={false}
render={
// Children are passed through `...props` by PaginationLink callers.
// oxlint-disable-next-line jsx-a11y/anchor-has-content
<a
aria-current={isActive ? "page" : undefined}
data-slot="pagination-link"