mirror of
https://github.com/civitai/civitai.git
synced 2026-09-20 22:08:18 +08:00
169e918a8c
## What Completes the `/user/account` redesign behind the `accountSettingsV2` flag. The earlier commits on this branch built the two-pane shell and converted three panes; this finishes the other five, then polishes the result against the Pencil canvas (`designs/user-account.pen`). **OFF serves the legacy single-column page byte-identically.** See "Rollout" below. ## Panes | Pane | Change | |---|---| | Overview | Tier badge art in the Membership tile (links `/user/membership`); Standing replaces the creator-score figure; username renders its nameplate + badge cosmetics; identity card stacks on mobile | | Profile & Account | `ProfileCard` / `SocialProfileCard` flattened; Account standing shows the exact score; session refresh and delete are pointer rows, grouped | | Preferences | Regrouped to Media playback / Generation / File preferences / Features; image format moved to File preferences; assistant folded into Features | | Content & Browsing | Eye callout; mature-content rows; Topics as chips; hidden tags/users flattened | | Creator | Placement, remix and metric-visibility sections; sticker inventory pointer moved inside Stickers | | Membership & Billing | Subscription / payment methods / payouts flattened; gifts point at `/pricing/gift`; membership row stacks on mobile; empty states when the user has neither a membership nor a Creator Program payout config | | Security & Apps | Sign-in methods, API keys, OAuth apps, connected apps flattened; create buttons on the section heading | | Notifications | Delivery section; per-category icons; more room in an open category; `Other` sorted last | ## Decisions worth a reviewer's attention - **Cards take a `flat` prop rather than being forked.** The legacy page mounts the same components while the flag is alive; two copies of a settings form is how one of them silently loses a field. - **One rule per section.** Eight rows had nine dividers and read as a table. Rows are spaced instead. - **`/user/account/overview` is a new URL.** On mobile the index renders the section *menu*, so an overview reachable only at the index has no way in. `AccountLayout` takes `isIndex` from the route now; inferring it from `section.path` rendered the menu at both URLs. Covered by a test — deleting the alias 404s that URL. - **Standing thresholds moved to `accountStandingFromPoints`** (`strike.schema.ts`). Two surfaces show standing and it derives from active *points*, not the strike count. - **The sticker-inventory pointer survives its host section's bail paths.** It is not gated on placement, so nesting it inside that section would drop it whenever the placement controls cannot render (flag off, or a failed spaces read). - **`BrowsingCategories` switched to chips outright**, including the legacy card, rather than growing a variant prop — one rendering, no fork. - **First use of a Tailwind `has-[…]` variant in this repo** (`SettingRow`, to keep switch rows inline at every width). Tailwind is 3.4.17, so it is supported. - **Billing empty states are `flat`-only.** `SubscriptionCard` and `UserPaymentConfigurationCard` both returned `null` with nothing to show, which left the whole pane blank. They now offer the plans / the Creator Program instead — but only in the flat panes, so the legacy page keeps hiding them and stays byte-identical. Both reuse the metric-visibility upsell, extracted as `UpsellPanel`. ## Verification Typecheck clean; no new lint warnings. Covering suites green: `account-sections` (17), `strike.service` + `process-strikes` (75), the four Account browser suites (24), and the notification suites (79). `SettingsCard.earlyAdopter.browser` needed one assertion updated — it pinned the literal early-adopter copy. Kept its intent (the opt-in must explain itself) and split it into the promise and the caveat rather than loosening it. Walked every pane at 1440px and 390px as a subscribed Creator Program account, and the billing/notification changes on a free account with no Creator Program. ## Not in this PR - `designs/user-account.pen` has uncommitted local changes that predate this work; left alone deliberately. ## Rollout `accountSettingsV2` → Flipt key `account-settings-v2`. `availability: ['mod']` is the STATIC FALLBACK only — it decides nothing while Flipt answers, so it matters solely during a Flipt outage, where mods get the new shell and everyone else keeps the legacy page. The Flipt rollout is the on-switch: `account-settings-v2` is `enabled: false` with no rollouts today, so the page is off for everyone until a segment or threshold rollout is merged in `flipt-state`. Instant rollback = drop that rollout / set the threshold to 0. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_013NnY26APwddt5dySmmubkZ Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013NnY26APwddt5dySmmubkZ
237 lines
8.3 KiB
TypeScript
237 lines
8.3 KiB
TypeScript
import { Alert, Button, Card, Text } from '@mantine/core';
|
|
import {
|
|
IconAlertTriangle,
|
|
IconArrowUpRight,
|
|
IconBell,
|
|
IconCreditCard,
|
|
IconEye,
|
|
IconKey,
|
|
IconMailCheck,
|
|
IconPencilMinus,
|
|
IconShieldCheck,
|
|
IconShieldExclamation,
|
|
IconUserCircle,
|
|
} from '@tabler/icons-react';
|
|
import React from 'react';
|
|
|
|
import { accountSections, getAccountSectionHref } from '~/components/Account/account-sections';
|
|
import { useQueryBuzz } from '~/components/Buzz/useBuzz';
|
|
import { CurrencyIcon } from '~/components/Currency/CurrencyIcon';
|
|
import { EdgeMedia } from '~/components/EdgeMedia/EdgeMedia';
|
|
import { NextLink } from '~/components/NextLink/NextLink';
|
|
import { openUserProfileEditModal } from '~/components/Dialog/triggers/user-profile-edit';
|
|
import { useActiveSubscription } from '~/components/Stripe/memberships.util';
|
|
import { getPlanDetails } from '~/components/Subscriptions/getPlanDetails';
|
|
import { UserAvatar } from '~/components/UserAvatar/UserAvatar';
|
|
import { Username } from '~/components/User/Username';
|
|
import { useCurrentUser } from '~/hooks/useCurrentUser';
|
|
import { useFeatureFlags } from '~/providers/FeatureFlagsProvider';
|
|
import { accountStandingFromPoints } from '~/server/schema/strike.schema';
|
|
import { formatDate } from '~/utils/date-helpers';
|
|
import { trpc } from '~/utils/trpc';
|
|
|
|
function StatTile({
|
|
label,
|
|
href,
|
|
icon,
|
|
children,
|
|
}: {
|
|
label: string;
|
|
href: string;
|
|
icon: React.ReactNode;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<NextLink
|
|
href={href}
|
|
className="flex flex-col gap-1 rounded-md bg-gray-1 p-3 no-underline transition-colors hover:bg-gray-2 dark:bg-dark-5 dark:hover:bg-dark-4"
|
|
>
|
|
<div className="flex items-center justify-between gap-2">
|
|
<Text size="xs" fw={600} tt="uppercase" c="dimmed" className="tracking-wide">
|
|
{label}
|
|
</Text>
|
|
<IconArrowUpRight size={13} className="text-gray-6 dark:text-dark-2" />
|
|
</div>
|
|
<div className="flex items-center gap-1.5">
|
|
{icon}
|
|
{children}
|
|
</div>
|
|
</NextLink>
|
|
);
|
|
}
|
|
|
|
/** Tailwind only emits classes it can see literally, so the standing colours cannot be templated. */
|
|
const standingTextClass: Record<string, string> = {
|
|
green: 'text-green-6',
|
|
yellow: 'text-yellow-6',
|
|
red: 'text-red-6',
|
|
};
|
|
|
|
const quickLinks: { id: string; icon: React.ReactNode }[] = [
|
|
{ id: 'notifications', icon: <IconBell size={18} /> },
|
|
{ id: 'content', icon: <IconEye size={18} /> },
|
|
{ id: 'billing', icon: <IconCreditCard size={18} /> },
|
|
{ id: 'security', icon: <IconKey size={18} /> },
|
|
];
|
|
|
|
export function AccountOverview() {
|
|
const currentUser = useCurrentUser();
|
|
const features = useFeatureFlags();
|
|
const { data: buzz } = useQueryBuzz();
|
|
const { data: strikeSummary } = trpc.strike.getMyStrikeSummary.useQuery(undefined, {
|
|
enabled: !!currentUser && !!features.strikes,
|
|
});
|
|
// Equipped cosmetics aren't on the session user, so the nameplate and badge need the profile.
|
|
const { data: profile } = trpc.userProfile.get.useQuery(
|
|
{ username: currentUser?.username ?? '' },
|
|
{ enabled: !!currentUser?.username }
|
|
);
|
|
const { subscription } = useActiveSubscription({ includeBuzzPurchase: true });
|
|
|
|
if (!currentUser) return null;
|
|
|
|
const emailVerified = !!currentUser.emailVerified;
|
|
const standing = accountStandingFromPoints(strikeSummary?.totalActivePoints ?? 0);
|
|
const StandingIcon = standing.good ? IconShieldCheck : IconShieldExclamation;
|
|
const tierBadge = subscription ? getPlanDetails(subscription.product, features).image : undefined;
|
|
const funded = (buzz?.accounts ?? []).filter((account) => account.balance > 0);
|
|
|
|
return (
|
|
<>
|
|
{!emailVerified && (
|
|
<Alert color="yellow" icon={<IconAlertTriangle size={18} />} title="Verify your email">
|
|
<Text size="sm">
|
|
Until you do, you can't publish models, withdraw Buzz, or recover the account.
|
|
</Text>
|
|
</Alert>
|
|
)}
|
|
|
|
<Card withBorder padding="lg">
|
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
|
|
<div className="flex min-w-0 flex-1 items-center gap-4">
|
|
<UserAvatar user={currentUser} size="lg" />
|
|
<div className="flex min-w-0 flex-col gap-1">
|
|
<Username
|
|
username={currentUser.username}
|
|
cosmetics={profile?.cosmetics}
|
|
size="xl"
|
|
badgeSize={26}
|
|
/>
|
|
<Text size="sm" c="dimmed">
|
|
{currentUser.email}
|
|
{currentUser.createdAt && ` · Member since ${formatDate(currentUser.createdAt)}`}
|
|
</Text>
|
|
</div>
|
|
</div>
|
|
<Button
|
|
variant="default"
|
|
leftSection={<IconPencilMinus size={16} />}
|
|
onClick={() => openUserProfileEditModal()}
|
|
className="shrink-0"
|
|
>
|
|
Customize profile
|
|
</Button>
|
|
</div>
|
|
</Card>
|
|
|
|
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
|
<StatTile
|
|
label="Membership"
|
|
href="/user/membership"
|
|
icon={
|
|
tierBadge ? (
|
|
<EdgeMedia src={tierBadge} width={40} className="size-5" />
|
|
) : (
|
|
<IconUserCircle size={16} className="text-yellow-6" />
|
|
)
|
|
}
|
|
>
|
|
<Text size="lg" fw={700} tt="capitalize">
|
|
{currentUser.tier ?? 'Free'}
|
|
</Text>
|
|
</StatTile>
|
|
|
|
<StatTile
|
|
label="Buzz balance"
|
|
href="/user/buzz-dashboard"
|
|
icon={<CurrencyIcon currency="BUZZ" size={16} />}
|
|
>
|
|
<div className="flex min-w-0 flex-col">
|
|
<Text size="lg" fw={700}>
|
|
{(buzz?.total ?? 0).toLocaleString()}
|
|
</Text>
|
|
{funded.length > 1 && (
|
|
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
|
|
{funded.map((account) => (
|
|
<div key={account.type} className="flex items-center gap-0.5">
|
|
<CurrencyIcon currency="BUZZ" type={account.type} size={11} />
|
|
<Text size="xs" c="dimmed">
|
|
{account.balance.toLocaleString()}
|
|
</Text>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</StatTile>
|
|
|
|
<StatTile
|
|
label="Standing"
|
|
href={getAccountSectionHref(
|
|
accountSections.find((section) => section.id === 'profile') ?? accountSections[0]
|
|
)}
|
|
icon={<StandingIcon size={16} className={standingTextClass[standing.color]} />}
|
|
>
|
|
<Text size="lg" fw={700} c={`${standing.color}.6`}>
|
|
{standing.short}
|
|
</Text>
|
|
</StatTile>
|
|
|
|
<StatTile
|
|
label="Email"
|
|
href={getAccountSectionHref(
|
|
accountSections.find((section) => section.id === 'profile') ?? accountSections[0]
|
|
)}
|
|
icon={
|
|
<IconMailCheck size={16} className={emailVerified ? 'text-green-6' : 'text-yellow-6'} />
|
|
}
|
|
>
|
|
<Text size="lg" fw={700} c={emailVerified ? 'green.6' : 'yellow.6'}>
|
|
{emailVerified ? 'Verified' : 'Unverified'}
|
|
</Text>
|
|
</StatTile>
|
|
</div>
|
|
|
|
<Text fw={600} mt="xs">
|
|
Jump to
|
|
</Text>
|
|
<div className="grid gap-3 md:grid-cols-2">
|
|
{quickLinks.map(({ id, icon }) => {
|
|
const section = accountSections.find((item) => item.id === id);
|
|
if (!section) return null;
|
|
return (
|
|
<NextLink key={id} href={getAccountSectionHref(section)} className="no-underline">
|
|
<Card withBorder padding="md" className="h-full">
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex size-9 items-center justify-center rounded bg-blue-1 text-blue-6 dark:bg-blue-8/25">
|
|
{icon}
|
|
</div>
|
|
<div className="flex min-w-0 flex-1 flex-col">
|
|
<Text size="sm" fw={600}>
|
|
{section.label}
|
|
</Text>
|
|
<Text size="xs" c="dimmed" lineClamp={1}>
|
|
{section.keywords.slice(0, 3).join(', ')}
|
|
</Text>
|
|
</div>
|
|
<IconArrowUpRight size={16} className="text-gray-6 dark:text-dark-2" />
|
|
</div>
|
|
</Card>
|
|
</NextLink>
|
|
);
|
|
})}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|