feat(account): finish the /user/account redesign behind accountSettingsV2 (#4727)

## 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
This commit is contained in:
Manuel Emilio Urena
2026-09-09 16:08:19 -04:00
committed by GitHub
parent f0b1020fda
commit 169e918a8c
50 changed files with 19671 additions and 536 deletions
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -13,7 +13,7 @@ Civitai supports OAuth 2.0 for third-party applications to authenticate users an
## Registering an Application
Visit your [Account Settings](/user/account) and scroll to the **OAuth Applications** section. Click **Register App** and fill in:
Visit your [Account Settings](/user/account) **Security & Apps** and find the **OAuth Applications** section. Click **Register App** and fill in:
- **App Name** — displayed to users on the consent screen
- **Description** — what your app does
+1 -1
View File
@@ -59,7 +59,7 @@ Legend: 🛠️ devops/config · 🧪 smoke test · 👁️ monitor · 🧹 clea
(`setSessionCookie(..., { deviceCookie })`). Verify the reverse too (sign in first on `.red`).
- [ ] 🧪 **Moderator impersonate → then EXIT impersonation** (the browser-client exit path — recently fixed to
`POST /api/auth/impersonate/exit`).
- [ ] 🧪 **Connected accounts** (`/user/account`): link + unlink each provider (Discord/Google/GitHub/Reddit)
- [ ] 🧪 **Connected accounts** (`/user/account/security`, or `/user/account` on the legacy page): link + unlink each provider (Discord/Google/GitHub/Reddit)
— routes through the hub's `?link=true` flow.
- [ ] 🧪 **Discord Linked-Roles** (`/discord/link-role`): connect, then confirm roles actually sync.
- [ ] 🧪 **Same-site spokes** (`moderator.civitai.com`, `advertising.civitai.com`): they read the shared
+3 -2
View File
@@ -39,9 +39,10 @@ Items that need human review, testing, or validation before this feature is prod
- [ ] Rotate client secret
- [ ] Show client ID + secret (once)
### Connected Apps (Phase 3) — NOT YET BUILT
### Connected Apps (Phase 3)
- [ ] Page at `/user/account#connected-apps`
- [x] Built as `ConnectedAppsCard` — Account Settings → **Security & Apps** (`/user/account/security`);
no standalone page or `#connected-apps` anchor was created
- [ ] List authorized apps with revoke button
---
+5 -2
View File
@@ -23,8 +23,11 @@ Also check the `FliptFlag` enum in [src/server/flipt/client.ts](../src/server/fl
| Flag | Status |
| ------------ | -------------------------------------------------------------------------------------- |
| `imageIndex` | ✅ Removed — zero consumers |
| `apiKeys` | ❌ Restored — destructured in [user/account.tsx:30](../src/pages/user/account.tsx#L30) |
| `oauthApps` | ❌ Restored — destructured in [user/account.tsx:30](../src/pages/user/account.tsx#L30) |
| `apiKeys` | ❌ Restored — gates `ApiKeysCard` in [AccountPanes.tsx:92](../src/components/Account/AccountPanes.tsx#L92) and [LegacyAccountPage.tsx:62](../src/components/Account/LegacyAccountPage.tsx#L62) |
| `oauthApps` | ❌ Restored — gates `OAuthAppsCard` + `ConnectedAppsCard` in [AccountPanes.tsx:93-94](../src/components/Account/AccountPanes.tsx#L93) and [LegacyAccountPage.tsx:63-64](../src/components/Account/LegacyAccountPage.tsx#L63) |
⚠️ Every account-page flag has **two** consumers while `accountSettingsV2` is alive — the pane
(`AccountPanes.tsx`) and the fallback (`LegacyAccountPage.tsx`). One grep hit is not the whole answer.
`apiKeys: ['public']` is decorative-only (always-true gate); see Tier 4.
+5 -2
View File
@@ -21,6 +21,9 @@ We also have a unique-to-this-codebase wrinkle: `getFeatureFlagsLazy` in [featur
There are **203 `useFeatureFlags()` callsites across 179 files**, plus **server-side `ctx.features` reads**.
Audit snapshot, 2026-05-12. Paths and counts below are not maintained — re-run the grep before
relying on a row.
### Destructure sites (36 total) — most vulnerable to type changes
These are the ones that aren't caught by `grep "features\.X"` — when removing a flag, you must also grep the destructure variable name in scope.
@@ -30,7 +33,7 @@ These are the ones that aren't caught by `grep "features\.X"` — when removing
| File | Destructured flags |
| ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ |
| [hooks/useDomainColor.tsx:5](../src/hooks/useDomainColor.tsx#L5) | `isGreen, isBlue, isRed` |
| [pages/user/account.tsx:30](../src/pages/user/account.tsx#L30) | `apiKeys, oauthApps, canViewNsfw, strikes` |
| ~~pages/user/account.tsx:30~~ → [LegacyAccountPage.tsx:34](../src/components/Account/LegacyAccountPage.tsx#L34) + [AccountPanes.tsx:41](../src/components/Account/AccountPanes.tsx#L41) | `apiKeys, oauthApps, canViewNsfw, strikes` |
| [pages/user/[username]/comics.tsx:192](../src/pages/user/[username]/comics.tsx#L192) | `isGreen` |
| [pages/comics/[id]/[[...slug]].tsx:101](../src/pages/comics/[id]/[[...slug]].tsx#L101) | `isGreen` |
| [pages/comics/project/[id]/iterate.tsx:316](../src/pages/comics/project/[id]/iterate.tsx#L316) | `isGreen` |
@@ -177,7 +180,7 @@ The type "lies" — it claims every flag is present and boolean, when at runtime
- The wire payload is still sparse (Phase 2 stays — half the win was the bytes)
- **The flag-removal safety net still works**: `FeatureFlagKey` is the keyspace, so removing a flag from the registry shrinks the union and produces a type error at every consumer (including destructure sites)
**What we lose:** the type doesn't enforce write safety (`features.X = false` compiles even though wire payload never produces `false`). Acceptable — no consumer writes to the cache except [SettingsCard.tsx:242](../src/components/Account/SettingsCard.tsx#L242), and that one assignment is internal optimistic-cache state, not the wire payload.
**What we lose:** the type doesn't enforce write safety (`features.X = false` compiles even though wire payload never produces `false`). Acceptable — no consumer writes to the cache except [SettingsCard.tsx:334](../src/components/Account/SettingsCard.tsx#L334), and that one assignment is internal optimistic-cache state, not the wire payload.
### Phase 4 — Benefits realized
+1 -1
View File
@@ -100,7 +100,7 @@ publish-restricted.
| Window length (days) | **Creator score** | Timed |
| Concurrent windows | **Creator score** | Timed |
**Every "creator score" in this table is `User.meta.scores.total`** — the figure `/user/account`
**Every "creator score" in this table is `User.meta.scores.total`** — the figure the account Profile pane
displays under that name. Monetization and the early-access ladder both compared against the
per-category `scores.models` until 2026-09-04; 45,216 accounts were above the displayed floor and
below the enforced one, and had no way to see why. **Any gate that says "creator score" to the user**
+5 -2
View File
@@ -82,7 +82,9 @@ Realtime delivery is via Signals — the fan-out worker POSTs per affected user,
## User settings
`src/components/Account/NotificationsCard.tsx`. Settings are stored in `UserNotificationSettings`
`src/components/Account/NotificationsCard.tsx` (legacy page) and
`src/components/Account/NotificationsPane.tsx` (the `accountSettingsV2` pane) — **both**, until the
flag is retired. Settings are stored in `UserNotificationSettings`
(the one table still in the main schema); a row means opted **out**, except for `optIn` types — see
`NotificationProcessor.optIn` in `base.notifications.ts`.
@@ -98,7 +100,8 @@ WHERE NOT EXISTS (SELECT 1 FROM "UserNotificationSettings" WHERE "userId" = <rec
Omit it and the toggle renders, saves, and does nothing.
`src/server/notifications/__tests__/notification-settings-polarity.test.ts` is the guard, and it runs
in `pnpm run test:lint-rules`. Its `KNOWN_INERT` list is empty and pinned, so a new inert type fails
in `pnpm run test:lint-rules`. Its file lists name `NotificationsCard.tsx` only — the
`accountSettingsV2` pane is not yet covered. Its `KNOWN_INERT` list is empty and pinned, so a new inert type fails
there rather than shipping unmuteable.
## Caching
+314
View File
@@ -0,0 +1,314 @@
import type { MantineSize } from '@mantine/core';
import { Badge, CloseButton, Text, TextInput } from '@mantine/core';
import { IconChevronLeft, IconChevronRight, IconSearch } from '@tabler/icons-react';
import clsx from 'clsx';
import { useRouter } from 'next/router';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { NextLink } from '~/components/NextLink/NextLink';
import type { AccountSection } from '~/components/Account/account-sections';
import {
accountSectionGroups,
getAccountSectionHref,
getOverviewHref,
resolveLegacyAnchor,
searchAccountSections,
} from '~/components/Account/account-sections';
import { useQueryBuzz } from '~/components/Buzz/useBuzz';
import { CurrencyIcon } from '~/components/Currency/CurrencyIcon';
import { UserAvatar } from '~/components/UserAvatar/UserAvatar';
import { useScrollAreaRef } from '~/components/ScrollArea/ScrollAreaContext';
import { useCurrentUser } from '~/hooks/useCurrentUser';
import { useIsMobile } from '~/hooks/useIsMobile';
function useLegacyAnchorRedirect() {
const router = useRouter();
useEffect(() => {
if (!router.isReady) return;
const redirect = () => {
const section = resolveLegacyAnchor(window.location.hash);
if (!section) return;
// Read the query off the URL rather than `router.query`: that one carries the route's own
// `section` param and goes stale inside this closure.
const query = Object.fromEntries(new URLSearchParams(window.location.search).entries());
router.replace({ pathname: getAccountSectionHref(section), query });
};
redirect();
// A link to `/user/account#creator-score` from a page already on `/user/account` changes only
// the fragment, so the browser navigates within the same document and nothing remounts. Mount
// alone would leave those in-app anchors dead while the same URL worked on a cold load.
window.addEventListener('hashchange', redirect);
return () => window.removeEventListener('hashchange', redirect);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [router.isReady]);
}
const RAIL_STICKY_GAP = 16;
/**
* The subnav is `sticky top-0` inside the scroll area and hides by translating itself off screen,
* so it keeps its layout box either way. A fixed sticky offset therefore leaves a gap the height of
* the subnav once it retracts. Track where its bottom edge actually is instead.
*/
function useSubnavBottom() {
const [bottom, setBottom] = useState(0);
const frame = useRef<number>();
const measure = useCallback((node: HTMLElement) => {
if (frame.current) cancelAnimationFrame(frame.current);
frame.current = requestAnimationFrame(() => {
const subnav = node.querySelector<HTMLElement>('[data-subnav]');
if (!subnav) return setBottom(0);
const offset = subnav.getBoundingClientRect().bottom - node.getBoundingClientRect().top;
setBottom(Math.max(0, Math.round(offset)));
});
}, []);
const ref = useScrollAreaRef({ onScroll: measure });
useEffect(() => {
if (ref?.current) measure(ref.current);
return () => {
if (frame.current) cancelAnimationFrame(frame.current);
};
}, [ref, measure]);
return bottom;
}
function SectionLink({ section, active }: { section: AccountSection; active: boolean }) {
const Icon = section.icon;
return (
<NextLink
href={getAccountSectionHref(section)}
className={clsx(
'flex items-center gap-2.5 rounded px-3 py-2 text-sm no-underline transition-colors',
active
? 'bg-blue-1 font-semibold text-dark-9 dark:bg-blue-8/25 dark:text-white'
: 'font-medium text-dark-7 hover:bg-gray-1 dark:text-gray-4 dark:hover:bg-dark-5'
)}
aria-current={active ? 'page' : undefined}
>
<Icon size={18} className={active ? 'text-blue-6' : 'text-gray-6 dark:text-dark-2'} />
<span className="flex-1">{section.label}</span>
</NextLink>
);
}
function SettingSearchInput({
value,
onChange,
size,
mb,
}: {
value: string;
onChange: (value: string) => void;
size?: MantineSize;
mb?: number;
}) {
return (
<TextInput
size={size}
placeholder="Find a setting"
value={value}
onChange={(event) => onChange(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === 'Escape') onChange('');
}}
leftSection={<IconSearch size={15} />}
rightSection={
value ? (
<CloseButton
size="sm"
variant="transparent"
aria-label="Clear search"
onClick={() => onChange('')}
/>
) : null
}
mb={mb}
/>
);
}
function AccountNav({ activeId }: { activeId: string }) {
const [query, setQuery] = useState('');
const matches = useMemo(() => searchAccountSections(query), [query]);
return (
<nav className="flex flex-col gap-0.5" aria-label="Account settings">
<SettingSearchInput size="sm" value={query} onChange={setQuery} mb={4} />
{matches.length === 0 && (
<Text size="sm" c="dimmed" px="sm" py="xs">
Nothing matches {query}.
</Text>
)}
{accountSectionGroups.map((group) => {
const sections = matches.filter((section) => section.group === group.id);
if (!sections.length) return null;
return (
<React.Fragment key={group.id}>
<Text size="xs" fw={600} c="dimmed" tt="uppercase" px="sm" pt="sm" pb={4}>
{group.label}
</Text>
{sections.map((section) => (
<SectionLink key={section.id} section={section} active={section.id === activeId} />
))}
</React.Fragment>
);
})}
</nav>
);
}
function MobileIndex() {
const currentUser = useCurrentUser();
const { data: buzz } = useQueryBuzz();
const [query, setQuery] = useState('');
const matches = useMemo(() => searchAccountSections(query), [query]);
return (
<div className="flex flex-col gap-4">
{currentUser && (
<NextLink
href={getOverviewHref()}
className="flex items-center gap-3 rounded-md border border-gray-3 bg-white p-3 no-underline dark:border-dark-4 dark:bg-dark-6"
>
<UserAvatar user={currentUser} size="md" />
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex items-center gap-2">
<Text size="sm" fw={700}>
{currentUser.username}
</Text>
<Badge size="xs" color={currentUser.tier ? 'yellow' : 'gray'} tt="capitalize">
{currentUser.tier ?? 'Free'}
</Badge>
</div>
<div className="flex items-center gap-1">
<CurrencyIcon currency="BUZZ" size={12} />
<Text size="xs" c="dimmed">
{(buzz?.total ?? 0).toLocaleString()} Buzz
</Text>
</div>
</div>
<IconChevronRight size={16} className="text-gray-6 dark:text-dark-2" />
</NextLink>
)}
<SettingSearchInput value={query} onChange={setQuery} />
{matches.length === 0 && (
<Text size="sm" c="dimmed">
Nothing matches {query}.
</Text>
)}
{accountSectionGroups.map((group) => {
const sections = matches.filter((section) => section.group === group.id);
if (!sections.length) return null;
return (
<div key={group.id} className="flex flex-col gap-1.5">
<Text size="xs" fw={600} c="dimmed" tt="uppercase">
{group.label}
</Text>
<div className="overflow-hidden rounded-md border border-gray-3 dark:border-dark-4">
{sections.map((section, index) => {
const Icon = section.icon;
return (
<NextLink
key={section.id}
href={section.path ? getAccountSectionHref(section) : getOverviewHref()}
className={clsx(
'flex items-center gap-3 bg-white px-3.5 py-3 text-sm font-medium text-dark-9 no-underline dark:bg-dark-6 dark:text-gray-0',
index > 0 && 'border-t border-gray-3 dark:border-dark-4'
)}
>
<Icon size={18} className="text-gray-6 dark:text-dark-2" />
<span className="flex-1">{section.label}</span>
<IconChevronRight size={16} className="text-gray-6 dark:text-dark-2" />
</NextLink>
);
})}
</div>
</div>
);
})}
</div>
);
}
export function AccountLayout({
section,
title,
isIndex,
children,
}: {
section: AccountSection;
title: string;
/** Whether the URL is the bare index, not whether the section happens to live there. */
isIndex: boolean;
children: React.ReactNode;
}) {
useLegacyAnchorRedirect();
const subnavBottom = useSubnavBottom();
const isMobile = useIsMobile({ breakpoint: 'md' });
if (isMobile) {
if (isIndex)
return (
<div className="mx-auto flex w-full max-w-[1020px] flex-col p-4">
<Text component="h1" className="mb-4 text-xl font-bold">
Manage account
</Text>
<MobileIndex />
</div>
);
return (
// `-mt-3` cancels the subnav's own `mb-3` (see AppLayout). The bar pins flush to the subnav
// once stuck, so without this it starts 12px lower and jumps up on the first scroll.
<div className="mx-auto -mt-3 flex w-full max-w-[1020px] flex-col">
<div
// Pinned to the same edge the desktop rail tracks, so it follows the subnav up as that
// retracts rather than leaving a gap or hiding under it.
className="sticky z-10 flex items-center gap-3 border-b border-gray-3 bg-white px-4 py-3 dark:border-dark-4 dark:bg-dark-6"
style={{ top: subnavBottom }}
>
<NextLink
href="/user/account"
aria-label="Manage account"
className="flex text-dark-9 dark:text-gray-0"
>
<IconChevronLeft size={22} />
</NextLink>
<Text component="h1" className="text-base font-semibold">
{title}
</Text>
</div>
<div className="flex flex-col gap-4 p-4">{children}</div>
</div>
);
}
// 260 rail + 40 gap + 720 content. Past ~720 a row's control drifts far enough from its label
// that the pair stops reading as one thing — the same problem that killed the two-column layout.
return (
<div className="mx-auto flex w-full max-w-[1020px] gap-10 px-4 py-6 md:px-8">
<aside className="w-[260px] shrink-0">
<div className="sticky" style={{ top: subnavBottom + RAIL_STICKY_GAP }}>
<AccountNav activeId={section.id} />
</div>
</aside>
<div className="min-w-0 flex-1">
<Text component="h1" className="mb-5 text-2xl font-bold leading-tight">
{title}
</Text>
<div className="flex flex-col gap-4">{children}</div>
</div>
</div>
);
}
+236
View File
@@ -0,0 +1,236 @@
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&apos;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>
</>
);
}
+101
View File
@@ -0,0 +1,101 @@
import dynamic from 'next/dynamic';
import React from 'react';
import { AccountsCard } from '~/components/Account/AccountsCard';
import { ApiKeysCard } from '~/components/Account/ApiKeysCard';
import { ConnectedAppsCard } from '~/components/Account/ConnectedAppsCard';
import { CreatorControlsCard } from '~/components/Account/CreatorControlsCard';
import { DeleteCard } from '~/components/Account/DeleteCard';
import { MembershipGiftsCard } from '~/components/Account/MembershipGiftsCard';
import { OAuthAppsCard } from '~/components/Account/OAuthAppsCard';
import { PaymentMethodsCard } from '~/components/Account/PaymentMethodsCard';
import { ProfileCard } from '~/components/Account/ProfileCard';
import { RefreshSessionCard } from '~/components/Account/RefreshSessionCard';
import { SocialProfileCard } from '~/components/Account/SocialProfileCard';
import { StickerInventoryCard } from '~/components/Account/StickerInventoryCard';
import { StrikesCard } from '~/components/Account/StrikesCard';
import { SubscriptionCard } from '~/components/Account/SubscriptionCard';
import { UserPaymentConfigurationCard } from '~/components/Account/UserPaymentConfigurationCard';
import { AccountOverview } from '~/components/Account/AccountOverview';
import { ContentPane } from '~/components/Account/ContentPane';
import { PreferencesPane } from '~/components/Account/PreferencesPane';
import { SettingsStack } from '~/components/Account/SettingsLayout';
import { useFeatureFlags } from '~/providers/FeatureFlagsProvider';
const NotificationsPane = dynamic(() =>
import('~/components/Account/NotificationsPane').then((mod) => mod.NotificationsPane)
);
export const accountPaneCopy: Record<string, { title: string }> = {
overview: { title: 'Overview' },
profile: { title: 'Profile & Account' },
preferences: { title: 'Preferences' },
notifications: { title: 'Notifications' },
content: { title: 'Content & Browsing' },
creator: { title: 'Creator' },
billing: { title: 'Membership & Billing' },
security: { title: 'Security & Apps' },
};
export function AccountPane({ sectionId }: { sectionId: string }) {
const features = useFeatureFlags();
switch (sectionId) {
case 'overview':
return <AccountOverview />;
case 'profile':
return (
<SettingsStack>
<ProfileCard flat />
<SocialProfileCard flat />
{features.strikes && <StrikesCard flat />}
<div className="flex flex-col gap-3">
<RefreshSessionCard flat />
<DeleteCard flat />
</div>
</SettingsStack>
);
case 'preferences':
return <PreferencesPane />;
case 'notifications':
return <NotificationsPane />;
case 'content':
return <ContentPane />;
case 'creator':
return (
<SettingsStack>
{/* Ungated on purpose: the card self-gates, and still owes the sticker pointer with
every flag off. */}
<CreatorControlsCard flat stickerFooter={<StickerInventoryCard flat />} />
</SettingsStack>
);
case 'billing':
return (
<SettingsStack>
<SubscriptionCard flat />
<PaymentMethodsCard flat />
<UserPaymentConfigurationCard flat />
<MembershipGiftsCard pointer />
</SettingsStack>
);
case 'security':
return (
<SettingsStack>
<AccountsCard flat />
{features.apiKeys && <ApiKeysCard flat />}
{features.oauthApps && <OAuthAppsCard flat />}
{features.oauthApps && <ConnectedAppsCard flat />}
</SettingsStack>
);
default:
return null;
}
}
+18 -20
View File
@@ -1,14 +1,5 @@
import {
Alert,
Button,
Card,
Group,
LoadingOverlay,
Stack,
Table,
Text,
Title,
} from '@mantine/core';
import { Alert, Button, Group, LoadingOverlay, Stack, Table, Text, Title } from '@mantine/core';
import { CardOrSection } from '~/components/Account/SettingsLayout';
import { useRouter } from 'next/router';
import {
IconBrandDiscord,
@@ -42,7 +33,7 @@ function connectAccount(providerId: string) {
)}&returnUrl=${encodeURIComponent(returnUrl)}`;
}
export function AccountsCard() {
export function AccountsCard({ flat }: { flat?: boolean } = {}) {
const utils = trpc.useUtils();
const currentUser = useCurrentUser();
const { error } = useRouter().query;
@@ -61,14 +52,21 @@ export function AccountsCard() {
const canRemoveAccounts = accounts.length > 1 || currentUser?.emailVerified;
return (
<Card withBorder id="accounts">
<CardOrSection
flat={flat}
title="Sign-in methods"
description="Sign in with any account you connect."
id="accounts"
>
<Stack>
<Stack gap={0}>
<Title order={2}>Connected Accounts</Title>
<Text c="dimmed" size="sm">
Connect multiple accounts to your user and sign in with any of them
</Text>
</Stack>
{!flat && (
<Stack gap={0}>
<Title order={2}>Connected Accounts</Title>
<Text c="dimmed" size="sm">
Connect multiple accounts to your user and sign in with any of them
</Text>
</Stack>
)}
{error && (
<Alert color="yellow">
<Stack gap={4}>
@@ -124,6 +122,6 @@ export function AccountsCard() {
</Table>
</div>
</Stack>
</Card>
</CardOrSection>
);
}
+19 -1
View File
@@ -2,8 +2,9 @@ import { Switch } from '@mantine/core';
import React from 'react';
import { useBrowsingSettings } from '~/providers/BrowserSettingsProvider';
import { useMutateUserSettings } from '~/components/UserSettings/hooks';
import { SettingRow, SettingsSection } from '~/components/Account/SettingsLayout';
export function AdContent() {
export function AdContent({ flat }: { flat?: boolean } = {}) {
const allowAds = useBrowsingSettings((x) => x.allowAds);
const setState = useBrowsingSettings((x) => x.setState);
@@ -18,6 +19,23 @@ export function AdContent() {
// updateUserSettingsMutation.mutate({ allowAds: e.target.checked });
};
if (flat)
return (
<SettingsSection title="Ads">
<SettingRow
label="Allow on-site ads"
description="Supports the site."
control={
<Switch
checked={allowAds}
onChange={handleToggleAds}
disabled={updateUserSettingsMutation.isPending}
/>
}
/>
</SettingsSection>
);
return (
<div className="flex size-full flex-col justify-center">
<h4 className="font-bold">Ad Content</h4>
+39 -27
View File
@@ -5,7 +5,6 @@ import { openConfirmModal } from '@mantine/modals';
import { trpc } from '~/utils/trpc';
import {
Text,
Card,
Stack,
Group,
Title,
@@ -18,6 +17,7 @@ import {
Progress,
UnstyledButton,
} from '@mantine/core';
import { CardOrSection } from '~/components/Account/SettingsLayout';
import {
IconPlus,
IconTrash,
@@ -64,7 +64,7 @@ function getScopeBadgeColor(label: string): string {
}
}
export function ApiKeysCard() {
export function ApiKeysCard({ flat }: { flat?: boolean } = {}) {
const utils = trpc.useUtils();
const features = useFeatureFlags();
@@ -141,29 +141,40 @@ export function ApiKeysCard() {
});
};
const addKeyButton = (
<Button
size="compact-sm"
leftSection={<IconPlus size={14} stroke={1.5} />}
onClick={() => {
setPrefill(null);
open();
}}
>
Add API key
</Button>
);
return (
<>
<Card withBorder>
<Stack gap={0}>
<Group align="start" justify="space-between">
<Title order={2}>API Keys</Title>
<Button
size="compact-sm"
leftSection={<IconPlus size={14} stroke={1.5} />}
onClick={() => {
setPrefill(null);
open();
}}
>
Add API key
</Button>
</Group>
<Text c="dimmed" size="sm">
You can use API keys to interact with the site through the API as your user. These
should not be shared with anyone.
</Text>
</Stack>
<Box mt="md" style={{ position: 'relative' }}>
<CardOrSection
flat={flat}
title="API keys"
description="Programmatic access to your account. Treat them like passwords."
action={flat ? addKeyButton : undefined}
>
{!flat && (
<Stack gap={0}>
<Group align="start" justify="space-between">
<Title order={2}>API Keys</Title>
{addKeyButton}
</Group>
<Text c="dimmed" size="sm">
You can use API keys to interact with the site through the API as your user. These
should not be shared with anyone.
</Text>
</Stack>
)}
<Box mt={flat ? 0 : 'md'} style={{ position: 'relative' }}>
<LoadingOverlay visible={isLoading} />
{apiKeys.length > 0 ? (
<Stack gap="sm">
@@ -205,8 +216,9 @@ export function ApiKeysCard() {
</LegacyActionIcon>
</Group>
{/* Meta row: created · last used · spend limit (inline) */}
<Group gap="md" wrap="nowrap" align="center">
{/* Must wrap: the spend-limit meter and its nowrap "n / m per 24h" can't
share a phone-width line with two dates. */}
<Group gap="md" align="center">
<Group gap={4} wrap="nowrap">
<IconCalendar size={12} color="var(--mantine-color-dimmed)" />
<Text size="xs" c="dimmed">
@@ -227,7 +239,7 @@ export function ApiKeysCard() {
<UnstyledButton
onClick={openLimitEditor}
title="Edit spend limit"
style={{ flex: 1, minWidth: 0 }}
style={{ flex: '1 1 180px', minWidth: 0 }}
>
<Group gap={4} wrap="nowrap">
<IconCoin size={12} color="var(--mantine-color-dimmed)" />
@@ -288,7 +300,7 @@ export function ApiKeysCard() {
</Paper>
)}
</Box>
</Card>
</CardOrSection>
<ApiKeyModal
// Remount when switching between a manual open and a deeplink prefill so
// the modal's internal form re-initializes from initialName/scope.
+11 -9
View File
@@ -3,7 +3,6 @@ import { openConfirmModal } from '@mantine/modals';
import { trpc } from '~/utils/trpc';
import {
Text,
Card,
Stack,
Group,
Title,
@@ -15,6 +14,7 @@ import {
Progress,
UnstyledButton,
} from '@mantine/core';
import { CardOrSection } from '~/components/Account/SettingsLayout';
import {
IconPlugConnected,
IconTrash,
@@ -38,7 +38,7 @@ const periodLabels: Record<'day' | 'week' | 'month', string> = {
month: '30d',
};
export function ConnectedAppsCard() {
export function ConnectedAppsCard({ flat }: { flat?: boolean } = {}) {
const utils = trpc.useUtils();
const [editLimitFor, setEditLimitFor] = useState<{
clientId: string;
@@ -85,14 +85,16 @@ export function ConnectedAppsCard() {
if (apps.length === 0 && !isLoading) return null;
return (
<Card withBorder>
<CardOrSection flat={flat} title="Connected apps">
<Stack>
<Group justify="space-between">
<Group gap="xs">
<IconPlugConnected size={20} />
<Title order={4}>Connected Apps</Title>
{!flat && (
<Group justify="space-between">
<Group gap="xs">
<IconPlugConnected size={20} />
<Title order={4}>Connected Apps</Title>
</Group>
</Group>
</Group>
)}
<Box pos="relative">
<LoadingOverlay visible={isLoading} />
@@ -224,6 +226,6 @@ export function ConnectedAppsCard() {
initialLimit={editLimitFor.buzzLimit}
/>
)}
</Card>
</CardOrSection>
);
}
+52
View File
@@ -0,0 +1,52 @@
import { IconEye } from '@tabler/icons-react';
import React from 'react';
import { AdContent } from '~/components/Account/AdContent';
import { HiddenTagsSection } from '~/components/Account/HiddenTagsSection';
import { HiddenUsersSection } from '~/components/Account/HiddenUsersSection';
import { MatureContentSettings } from '~/components/Account/MatureContentSettings';
import {
SettingRow,
SettingsNote,
SettingsSection,
SettingsStack,
} from '~/components/Account/SettingsLayout';
import { BrowsingCategories } from '~/components/BrowsingMode/BrowsingCategories';
import { useCurrentUser } from '~/hooks/useCurrentUser';
import { useFeatureFlags } from '~/providers/FeatureFlagsProvider';
export function ContentPane() {
const features = useFeatureFlags();
const currentUser = useCurrentUser();
return (
<SettingsStack>
{features.canViewNsfw && (
<SettingsNote icon={<IconEye size={18} />}>
The eye button in the header overrides this for one session. These are the defaults it
returns to.
</SettingsNote>
)}
{features.canViewNsfw && (
<SettingsSection title="Mature content">
<MatureContentSettings flat />
</SettingsSection>
)}
<SettingsSection
title="Topics"
description="Selected topics appear less often while browsing."
>
<SettingRow block>
<BrowsingCategories />
</SettingRow>
</SettingsSection>
<HiddenTagsSection flat />
<HiddenUsersSection flat />
{currentUser?.isMember && <AdContent flat />}
</SettingsStack>
);
}
+124 -97
View File
@@ -1,27 +1,17 @@
import { Alert, Button, Card, Divider, Group, Stack, Switch, Text, Title } from '@mantine/core';
import {
Alert,
Button,
Card,
Divider,
Group,
Stack,
Switch,
Text,
ThemeIcon,
Title,
} from '@mantine/core';
import {
IconCircleCheck,
IconInfoCircle,
IconLock,
IconRefresh,
IconUserPlus,
IconUsers,
} from '@tabler/icons-react';
import type { ReactNode } from 'react';
import { useCreatorProgramRequirements } from '~/components/Buzz/CreatorProgramV2/CreatorProgram.util';
import { InfoPopover } from '~/components/InfoPopover/InfoPopover';
import { PlacementSpaceSection } from '~/components/Account/PlacementSpaceSection';
import { RemixGallerySettings } from '~/components/RemixGallery/RemixGallerySettings';
import { SettingRow, SettingsSection, UpsellPanel } from '~/components/Account/SettingsLayout';
import { useCurrentUserSettings, useMutateUserSettings } from '~/components/UserSettings/hooks';
import { useCurrentUser } from '~/hooks/useCurrentUser';
import { useFeatureFlags } from '~/providers/FeatureFlagsProvider';
@@ -38,7 +28,10 @@ import { useSyncAccount } from '~/hooks/useSyncAccount';
* above that gate and is NOT a membership benefit. The alert says "below this
* point" for that reason; it is a boundary in the card, not a description of it.
*/
export function CreatorControlsCard() {
export function CreatorControlsCard({
flat,
stickerFooter,
}: { flat?: boolean; stickerFooter?: ReactNode } = {}) {
const user = useCurrentUser();
const flags = useFeatureFlags();
const serverDomains = useServerDomains();
@@ -51,12 +44,115 @@ export function CreatorControlsCard() {
if (!user) return null;
// Renting out your own images is not a Creator Program benefit, so the two
// halves are gated apart. With neither, the card would be a bare heading.
if (!flags.creatorControls && !flags.stickerPlacement && !flags.remixGallery) return null;
if (!flags.creatorControls && !flags.stickerPlacement && !flags.remixGallery)
return <>{stickerFooter}</>;
const isActiveMember = !!requirements?.validMembership;
const membershipLapsed = !!requirements?.membershipLapsed;
const renewUrl = syncAccount(`//${serverDomains.green}/pricing`);
const metricSwitches = [
{
name: 'hideModelBuzz',
label: 'Hide tipped / earned Buzz',
description: "Others won't see Buzz earned on your models.",
checked: hideModelBuzz ?? false,
onChange: (checked: boolean) => mutateSetting({ hideModelBuzz: checked }),
},
{
name: 'hideModelDownloads',
label: 'Hide download count',
description: "Others won't see your download counts.",
checked: hideModelDownloads ?? false,
onChange: (checked: boolean) => mutateSetting({ hideModelDownloads: checked }),
},
{
name: 'hideModelGenerations',
label: 'Hide generation count',
description: "Others won't see your generation counts.",
checked: hideModelGenerations ?? false,
onChange: (checked: boolean) => mutateSetting({ hideModelGenerations: checked }),
},
...(flags.donationGoals
? [
{
name: 'hideDonationGoals',
label: 'Hide my donation goals from public view',
description: "Others won't see the progress or amount. The goal still works.",
checked: hideDonationGoals ?? false,
onChange: (checked: boolean) => mutateSetting({ hideDonationGoals: checked }),
},
]
: []),
];
const membershipUpsell = (
<UpsellPanel
icon={membershipLapsed ? <IconLock size={24} /> : <IconUsers size={24} />}
title={membershipLapsed ? 'Membership lapsed' : 'Creator Program members only'}
description={
membershipLapsed
? 'Renew your Creator Program membership to restore the controls below and the rest of your perks:'
: 'Gain more control over how your models are presented, plus the rest of the Creator Program:'
}
perks={['Hide your model metrics and donation goals', 'Earn real cash from your creations']}
action={
<Button
component="a"
href={membershipLapsed ? renewUrl : '/creator-program'}
variant="filled"
size="sm"
leftSection={membershipLapsed ? <IconRefresh size={16} /> : <IconUserPlus size={16} />}
className="w-fit"
>
{membershipLapsed ? 'Renew membership' : 'Join the Creator Program'}
</Button>
}
/>
);
if (flat)
return (
<div id="creator-controls" className="flex flex-col gap-8">
<PlacementSpaceSection flat footer={stickerFooter} />
<RemixGallerySettings flat />
{flags.creatorControls && (
<SettingsSection
title={
<Group gap={4} wrap="nowrap">
Metric visibility
<InfoPopover size="xs" iconProps={{ size: 14 }} width={300}>
<Text size="sm" maw={280} style={{ whiteSpace: 'normal' }}>
You and moderators still see your real stats on model pages and cards. On search
results you see the hidden state, same as the public.
</Text>
</InfoPopover>
</Group>
}
description="Creator Program members only. Reverts if your membership lapses."
>
{!isActiveMember && <SettingRow block>{membershipUpsell}</SettingRow>}
{metricSwitches.map((setting) => (
<SettingRow
key={setting.name}
label={setting.label}
description={setting.description}
control={
<Switch
name={setting.name}
aria-label={setting.label}
checked={setting.checked}
onChange={(e) => setting.onChange(e.target.checked)}
disabled={isLoadingSetting || !isActiveMember}
/>
}
/>
))}
</SettingsSection>
)}
</div>
);
return (
<Card withBorder id="creator-controls">
<Stack>
@@ -94,90 +190,21 @@ export function CreatorControlsCard() {
</Text>
</Alert>
) : (
<div className="flex flex-col items-center gap-3 rounded-lg border border-gray-2 bg-gray-0 p-6 text-center dark:border-dark-4 dark:bg-dark-5">
<ThemeIcon size={48} variant="light" color="gray" radius="xl">
{membershipLapsed ? <IconLock size={24} /> : <IconUsers size={24} />}
</ThemeIcon>
<Text fw={700} size="lg">
{membershipLapsed ? 'Membership lapsed' : 'Creator Program members only'}
</Text>
<Text size="sm" c="dimmed" maw={380}>
{membershipLapsed
? 'Renew your Creator Program membership to restore the controls below and the rest of your perks:'
: 'Gain more control over how your models are presented, plus the rest of the Creator Program:'}
</Text>
<Stack gap={6} align="flex-start" ta="left">
{[
'Hide your model metrics and donation goals',
'Earn real cash from your creations',
].map((perk) => (
<Group key={perk} gap={8} wrap="nowrap">
<IconCircleCheck
size={16}
className="shrink-0"
style={{ color: 'var(--mantine-color-green-6)' }}
/>
<Text size="sm">{perk}</Text>
</Group>
))}
</Stack>
<Button
component="a"
href={membershipLapsed ? renewUrl : '/creator-program'}
variant="filled"
size="sm"
leftSection={
membershipLapsed ? <IconRefresh size={16} /> : <IconUserPlus size={16} />
}
className="w-fit"
>
{membershipLapsed ? 'Renew membership' : 'Join the Creator Program'}
</Button>
</div>
membershipUpsell
)}
<Switch
name="hideModelBuzz"
label="Hide tipped / earned Buzz"
description="Others won't see the Buzz earned on your models."
checked={hideModelBuzz ?? false}
onChange={(e) => mutateSetting({ hideModelBuzz: e.target.checked })}
disabled={isLoadingSetting || !isActiveMember}
styles={{ track: { flex: '0 0 1em' } }}
/>
<Switch
name="hideModelDownloads"
label="Hide download count"
description="Others won't see how many times your models were downloaded."
checked={hideModelDownloads ?? false}
onChange={(e) => mutateSetting({ hideModelDownloads: e.target.checked })}
disabled={isLoadingSetting || !isActiveMember}
styles={{ track: { flex: '0 0 1em' } }}
/>
<Switch
name="hideModelGenerations"
label="Hide generation count"
description="Others won't see how many images were generated with your models."
checked={hideModelGenerations ?? false}
onChange={(e) => mutateSetting({ hideModelGenerations: e.target.checked })}
disabled={isLoadingSetting || !isActiveMember}
styles={{ track: { flex: '0 0 1em' } }}
/>
{flags.donationGoals && (
<>
<Divider label="Donation goals" />
<Switch
name="hideDonationGoals"
label="Hide my donation goals from public view"
description="Others won't see the progress bar or collected amount. The goal still works."
checked={hideDonationGoals ?? false}
onChange={(e) => mutateSetting({ hideDonationGoals: e.target.checked })}
disabled={isLoadingSetting || !isActiveMember}
styles={{ track: { flex: '0 0 1em' } }}
/>
</>
)}
{metricSwitches.map((setting) => (
<Switch
key={setting.name}
name={setting.name}
label={setting.label}
description={setting.description}
checked={setting.checked}
onChange={(e) => setting.onChange(e.target.checked)}
disabled={isLoadingSetting || !isActiveMember}
styles={{ track: { flex: '0 0 1em' } }}
/>
))}
</>
)}
</Stack>
+34 -15
View File
@@ -13,13 +13,14 @@ import {
ThemeIcon,
Alert,
} from '@mantine/core';
import { IconAlertTriangle } from '@tabler/icons-react';
import { IconAlertTriangle, IconTrash } from '@tabler/icons-react';
import { useAccountContext } from '~/components/CivitaiWrapped/AccountProvider';
import { PointerCard } from '~/components/Account/SettingsLayout';
import { useCurrentUser } from '~/hooks/useCurrentUser';
import { showErrorNotification } from '~/utils/notifications';
import { trpc } from '~/utils/trpc';
export function DeleteCard() {
export function DeleteCard({ flat }: { flat?: boolean } = {}) {
const currentUser = useCurrentUser();
const { logout } = useAccountContext();
const { data: subscriptions, isLoading: subscriptionsLoading } =
@@ -224,19 +225,37 @@ export function DeleteCard() {
</Stack>
</Modal>
{/* MAIN DELETE ACCOUNT BUTTON */}
<Card withBorder>
<Stack>
<Title order={2}>Delete account</Title>
<Text size="sm">
Once you delete your account, there is no going back. Please be certain when taking this
action.
</Text>
<Button variant="outline" color="red" onClick={handleDeleteClick}>
Delete your account
</Button>
</Stack>
</Card>
{flat ? (
<PointerCard
tone="danger"
icon={<IconAlertTriangle size={18} />}
title="Delete account"
description="You choose what happens to your models and images. This cannot be undone."
action={
<Button
color="red"
size="compact-sm"
leftSection={<IconTrash size={14} />}
onClick={handleDeleteClick}
>
Delete account
</Button>
}
/>
) : (
<Card withBorder>
<Stack>
<Title order={2}>Delete account</Title>
<Text size="sm">
Once you delete your account, there is no going back. Please be certain when taking
this action.
</Text>
<Button variant="outline" color="red" onClick={handleDeleteClick}>
Delete your account
</Button>
</Stack>
</Card>
)}
</>
);
}
+46 -1
View File
@@ -9,8 +9,15 @@ import { getTagDisplayName } from '~/libs/tags';
import { TagSort } from '~/server/common/enums';
import { trpc } from '~/utils/trpc';
import { LegacyActionIcon } from '~/components/LegacyActionIcon/LegacyActionIcon';
import { SettingsSection } from '~/components/Account/SettingsLayout';
export function HiddenTagsSection({ withTitle = true }: { withTitle?: boolean }) {
export function HiddenTagsSection({
withTitle = true,
flat,
}: {
withTitle?: boolean;
flat?: boolean;
}) {
const searchInputRef = useRef<HTMLInputElement>(null);
const [search, setSearch] = useState('');
const [debouncedSearch] = useDebouncedValue(search, 300);
@@ -42,6 +49,44 @@ export function HiddenTagsSection({ withTitle = true }: { withTitle?: boolean })
setSearch('');
};
const searchField = (
<Autocomplete
name="tag"
ref={searchInputRef}
placeholder="Search tags to hide"
data={modelTags}
value={search}
onChange={setSearch}
leftSection={isLoading ? <Loader size="xs" /> : <IconSearch size={14} />}
onOptionSubmit={(value: string) => {
const record = modelTags.find((x) => x.value === value);
if (!record) return;
handleToggleBlockedTag({ id: record.id, name: record.value });
searchInputRef.current?.focus();
}}
limit={10}
/>
);
if (flat)
return (
<SettingsSection
title="Hidden tags"
description="Content with these tags is hidden from you."
>
<div className="flex flex-col gap-3">
{searchField}
<BasicMasonryGrid
items={hiddenTags}
render={TagBadge}
maxHeight={250}
columnGutter={4}
columnWidth={140}
/>
</div>
</SettingsSection>
);
return (
<Card withBorder>
{withTitle && (
+68 -4
View File
@@ -1,4 +1,14 @@
import { Autocomplete, Badge, Card, Group, Loader, Portal, Select, Stack, Text } from '@mantine/core';
import {
Autocomplete,
Badge,
Card,
Group,
Loader,
Portal,
Select,
Stack,
Text,
} from '@mantine/core';
import { useDebouncedValue } from '@mantine/hooks';
import { IconSearch, IconX } from '@tabler/icons-react';
import { useMemo, useRef, useState } from 'react';
@@ -6,10 +16,11 @@ import { LegacyActionIcon } from '~/components/LegacyActionIcon/LegacyActionIcon
import { toHideableOptions } from '~/components/Account/hidden-users-options';
import { BasicMasonryGrid } from '~/components/MasonryGrid/BasicMasonryGrid';
import { useHiddenPreferencesData, useToggleHiddenPreferences } from '~/hooks/hidden-preferences';
import { SettingsSection } from '~/components/Account/SettingsLayout';
import { trpc } from '~/utils/trpc';
export function HiddenUsersSection() {
export function HiddenUsersSection({ flat }: { flat?: boolean } = {}) {
const searchInputRef = useRef<HTMLInputElement>(null);
const [search, setSearch] = useState('');
const [debouncedSearch] = useDebouncedValue(search, 300);
@@ -32,7 +43,7 @@ export function HiddenUsersSection() {
(b.username ?? '').localeCompare(a.username ?? '', undefined, { sensitivity: 'base' })
);
}
return users;
}, [hiddenUsers, sort]);
@@ -55,6 +66,59 @@ export function HiddenUsersSection() {
setSearch('');
};
const sortField = (
<Select
size="xs"
value={sort}
onChange={(val) => setSort(val ?? 'newest')}
data={[
{ label: 'Recently Added', value: 'newest' },
{ label: 'Oldest Added', value: 'oldest' },
{ label: 'A-Z', value: 'alphaAsc' },
{ label: 'Z-A', value: 'alphaDesc' },
]}
style={{ width: 160 }}
/>
);
const searchField = (
<Autocomplete
name="tag"
ref={searchInputRef}
placeholder="Search users to hide"
data={options}
value={search}
onChange={setSearch}
leftSection={isLoading && isFetching ? <Loader size="xs" /> : <IconSearch size={14} />}
onOptionSubmit={(value: string) => {
const { id } = options.find((x) => x.value === value) ?? {};
if (!id) return;
handleToggleBlocked({ id, username: value });
searchInputRef.current?.focus();
}}
/>
);
if (flat)
return (
<SettingsSection
title="Hidden users"
description="Their models, images and comments are hidden from you."
action={sortField}
>
<div className="flex flex-col gap-3">
{searchField}
<BasicMasonryGrid
items={sortedHiddenUsers}
render={UserBadge}
maxHeight={250}
columnGutter={4}
columnWidth={140}
/>
</div>
</SettingsSection>
);
return (
<Card withBorder>
<Card.Section withBorder inheritPadding py="xs">
@@ -70,7 +134,7 @@ export function HiddenUsersSection() {
{ label: 'A-Z', value: 'alphaAsc' },
{ label: 'Z-A', value: 'alphaDesc' },
]}
style={{ width: 120 }}
style={{ width: 160 }}
/>
</Group>
</Card.Section>
@@ -0,0 +1,71 @@
import { Container, Stack, Text, Title } from '@mantine/core';
import dynamic from 'next/dynamic';
import React from 'react';
import { AccountsCard } from '~/components/Account/AccountsCard';
import { ApiKeysCard } from '~/components/Account/ApiKeysCard';
import { ConnectedAppsCard } from '~/components/Account/ConnectedAppsCard';
import { ContentControlsCard } from '~/components/Account/ContentControlsCard';
import { CreatorControlsCard } from '~/components/Account/CreatorControlsCard';
import { DeleteCard } from '~/components/Account/DeleteCard';
import { GenerationSettingsCard } from '~/components/Account/GenerationSettingsCard';
import { MembershipGiftsCard } from '~/components/Account/MembershipGiftsCard';
import { ModerationCard } from '~/components/Account/ModerationCard';
import { OAuthAppsCard } from '~/components/Account/OAuthAppsCard';
import { PaymentMethodsCard } from '~/components/Account/PaymentMethodsCard';
import { ProfileCard } from '~/components/Account/ProfileCard';
import { RefreshSessionCard } from '~/components/Account/RefreshSessionCard';
import { SettingsCard } from '~/components/Account/SettingsCard';
import { SocialProfileCard } from '~/components/Account/SocialProfileCard';
import { StickerInventoryCard } from '~/components/Account/StickerInventoryCard';
import { StrikesCard } from '~/components/Account/StrikesCard';
import { SubscriptionCard } from '~/components/Account/SubscriptionCard';
import { UserPaymentConfigurationCard } from '~/components/Account/UserPaymentConfigurationCard';
import { useFeatureFlags } from '~/providers/FeatureFlagsProvider';
const NotificationsCard = dynamic(() => import('~/components/Account/NotificationsCard'));
/**
* The single-column page, kept intact as the `accountSettingsV2` fallback. Delete it with the
* flag; until then this and `AccountPanes` must offer the same set of cards, or turning the
* flag off loses whatever only the new shell mounts.
*/
export function LegacyAccountPage() {
const features = useFeatureFlags();
return (
<Container pb="md" size="xs">
<Stack>
<Stack gap={0}>
<Title order={1}>Manage Account</Title>
<Text c="dimmed" size="sm">
Take a moment to review your account information and preferences to personalize your
experience on the site
</Text>
</Stack>
<ProfileCard />
<SocialProfileCard />
<SettingsCard />
<ContentControlsCard />
<GenerationSettingsCard />
{features.canViewNsfw && <ModerationCard />}
{(features.creatorControls || features.stickerPlacement || features.remixGallery) && (
<CreatorControlsCard />
)}
<StickerInventoryCard />
<AccountsCard />
<UserPaymentConfigurationCard />
<SubscriptionCard />
<MembershipGiftsCard />
<PaymentMethodsCard />
<NotificationsCard />
{features.apiKeys && <ApiKeysCard />}
{features.oauthApps && <OAuthAppsCard />}
{features.oauthApps && <ConnectedAppsCard />}
{features.strikes && <StrikesCard />}
<RefreshSessionCard />
<DeleteCard />
</Stack>
</Container>
);
}
@@ -1,9 +1,10 @@
import { Text, Stack } from '@mantine/core';
import { Switch, Text, Stack } from '@mantine/core';
import { BrowsingLevelsStacked } from '~/components/BrowsingLevel/BrowsingLevelsStacked';
import { SettingRow } from '~/components/Account/SettingsLayout';
import { ToggleList } from '~/components/ToggleList/ToggleList';
import { useBrowsingSettings } from '~/providers/BrowserSettingsProvider';
export function MatureContentSettings() {
export function MatureContentSettings({ flat }: { flat?: boolean } = {}) {
const showNsfw = useBrowsingSettings((x) => x.showNsfw);
const blurNsfw = useBrowsingSettings((x) => x.blurNsfw);
const setState = useBrowsingSettings((x) => x.setState);
@@ -11,6 +12,32 @@ export function MatureContentSettings() {
const toggleBlurNsfw = () => setState((state) => ({ blurNsfw: !state.blurNsfw }));
const toggleShowNsfw = () => setState((state) => ({ showNsfw: !state.showNsfw }));
if (flat)
return (
<>
<SettingRow
label="Show mature content"
description="Confirms you are over 18."
control={<Switch checked={showNsfw} onChange={toggleShowNsfw} />}
/>
{showNsfw && (
<SettingRow
block
label="Browsing level"
description="Everything at or below your highest pick is shown."
>
<BrowsingLevelsStacked />
</SettingRow>
)}
<SettingRow
label="Blur mature content"
control={
<Switch checked={showNsfw && blurNsfw} onChange={toggleBlurNsfw} disabled={!showNsfw} />
}
/>
</>
);
return (
<Stack>
<ToggleList>
+13 -1
View File
@@ -13,6 +13,7 @@ import {
} from '@mantine/core';
import { IconGift } from '@tabler/icons-react';
import { useMemo, useState } from 'react';
import { PointerCard } from '~/components/Account/SettingsLayout';
import buzzClasses from '~/components/Buzz/buzz.module.scss';
import { NextLink as Link } from '~/components/NextLink/NextLink';
import { useFeatureFlags } from '~/providers/FeatureFlagsProvider';
@@ -97,7 +98,8 @@ type FilterType = 'all' | 'received' | 'sent';
export function MembershipGiftsCard({
compact,
showGiftAction = true,
}: { compact?: boolean; showGiftAction?: boolean } = {}) {
pointer,
}: { compact?: boolean; showGiftAction?: boolean; pointer?: boolean } = {}) {
const features = useFeatureFlags();
const [page, setPage] = useState(1);
const [filter, setFilter] = useState<FilterType>('all');
@@ -138,6 +140,16 @@ export function MembershipGiftsCard({
if (!features.giftMemberships || isLoading || rows.length === 0) return null;
if (pointer)
return (
<PointerCard
icon={<IconGift size={18} />}
title="Gift memberships"
description="Codes you have bought or been given, and who redeemed them."
href="/pricing/gift"
/>
);
const hasBoth = rows.some((r) => r.kind === 'received') && rows.some((r) => r.kind === 'sent');
const filtered = filter === 'all' ? rows : rows.filter((r) => r.kind === filter);
const totalPages = Math.ceil(filtered.length / pageSize);
@@ -0,0 +1,185 @@
import { Badge, Checkbox, Text, UnstyledButton } from '@mantine/core';
import type { Icon } from '@tabler/icons-react';
import {
IconBellOff,
IconBolt,
IconChevronRight,
IconCircleDot,
IconMessage,
IconPalette,
IconRefresh,
IconSettings,
IconTargetArrow,
IconTrophy,
IconUserPlus,
} from '@tabler/icons-react';
import clsx from 'clsx';
import React, { useState } from 'react';
import { NewsletterToggle } from '~/components/Account/NewsletterToggle';
import { SettingRow, SettingsSection, SettingsStack } from '~/components/Account/SettingsLayout';
import {
useNotificationSettings,
useToggleNotificationSetting,
} from '~/components/Notifications/useNotificationSettings';
import { SkeletonSwitch } from '~/components/SkeletonSwitch/SkeletonSwitch';
import { NotificationCategory } from '~/server/common/enums';
import {
notificationCategoryTypes,
notificationTypes,
optInNotificationTypes,
} from '~/server/notifications/utils.notifications';
const categoryIcons: Record<string, Icon> = {
Comment: IconMessage,
Update: IconRefresh,
Creator: IconPalette,
System: IconSettings,
Milestone: IconTrophy,
Bounty: IconTargetArrow,
Referral: IconUserPlus,
Buzz: IconBolt,
};
/**
* `Other` is the catch-all bucket, so it belongs under the named categories however processor
* registration happens to order them. Stable sort, so everything else keeps its order.
*/
const categoryEntries = Object.entries(notificationCategoryTypes).sort(
([a], [b]) => Number(a === NotificationCategory.Other) - Number(b === NotificationCategory.Other)
);
export function NotificationsPane() {
const { hasNotifications, hasCategory, notificationSettings, isLoading } =
useNotificationSettings();
const updateNotificationSettingMutation = useToggleNotificationSetting();
const [expanded, setExpanded] = useState<string | null>(null);
// Asymmetric on purpose. Turning everything OFF must also unsubscribe opt-in types, or a user who
// silences the site keeps receiving promos with no way back. Turning everything ON must NOT
// subscribe them: nobody reads "enable notifications" as "sign me up for shop promos".
const toggleAll = (toggle: boolean) => {
const type = toggle ? notificationTypes : [...notificationTypes, ...optInNotificationTypes];
updateNotificationSettingMutation.mutate({ toggle, type });
};
const toggleCategory = (category: string, toggle: boolean) => {
const categoryTypes = notificationCategoryTypes[category]
?.filter((x) => toggle === false || !x.optIn)
.map((x) => x.type);
if (!categoryTypes?.length) return;
updateNotificationSettingMutation.mutate({ toggle, type: categoryTypes });
};
const toggleType = (type: string, toggle: boolean) => {
updateNotificationSettingMutation.mutate({ toggle, type: [type] });
};
return (
<SettingsStack>
<SettingsSection title="Delivery">
<SettingRow
label="On-site notifications"
description="The bell in the header. Off silences every category below."
control={
<SkeletonSwitch
loading={isLoading}
checked={hasNotifications ?? false}
onChange={(e) => toggleAll(e.target.checked)}
/>
}
/>
{/* Default branch is a raw Group with the switch first, which the section's Switch
overrides can't reach. */}
<NewsletterToggle>
{({ subscribed, isLoading: newsletterLoading, setSubscribed }) => (
<SettingRow
label="Newsletter"
description="Product news by email. Separate from the categories below."
control={
<SkeletonSwitch
loading={newsletterLoading}
checked={subscribed}
onChange={(e) => setSubscribed(e.target.checked)}
/>
}
/>
)}
</NewsletterToggle>
</SettingsSection>
{!hasNotifications ? (
<div className="flex items-center gap-3 rounded-md border border-gray-3 bg-white p-4 dark:border-dark-4 dark:bg-dark-6">
<IconBellOff size={22} strokeWidth={2} className="shrink-0" />
<Text size="sm">All non-essential notifications are turned off</Text>
</div>
) : (
<div className="flex flex-col gap-1.5">
{categoryEntries.map(([category, settings]) => {
const isOpen = expanded === category;
const enabled = settings.filter((x) => notificationSettings[x.type]).length;
const categoryOn = hasCategory[category];
const CategoryIcon = categoryIcons[category] ?? IconCircleDot;
return (
<div
key={category}
className="overflow-hidden rounded-md border border-gray-3 bg-white dark:border-dark-4 dark:bg-dark-6"
>
<div
className={clsx(
'flex items-center gap-3 px-4 py-3',
isOpen && 'bg-gray-1 dark:bg-dark-5'
)}
>
<UnstyledButton
className="flex flex-1 items-center gap-3"
onClick={() => setExpanded(isOpen ? null : category)}
aria-expanded={isOpen}
>
<IconChevronRight
size={16}
className={clsx(
'shrink-0 text-gray-6 transition-transform dark:text-dark-2',
isOpen && 'rotate-90'
)}
/>
<CategoryIcon size={18} className="shrink-0 text-gray-6 dark:text-dark-2" />
<Text size="sm" fw={600} className="flex-1 text-left">
{category}
</Text>
<Badge size="sm" variant="light" color={enabled ? 'blue' : 'gray'}>
{enabled} of {settings.length}
</Badge>
</UnstyledButton>
<SkeletonSwitch
loading={isLoading}
checked={categoryOn}
onChange={(e) => toggleCategory(category, e.target.checked)}
/>
</div>
{isOpen && (
<div className="flex flex-col gap-2.5 p-4">
{!categoryOn && (
<Text size="xs" c="dimmed">
This category is off, so none of these are sent.
</Text>
)}
{settings.map(({ type, displayName }) => (
<Checkbox
key={type}
label={displayName}
checked={notificationSettings[type]}
disabled={isLoading || !categoryOn}
onChange={(e) => toggleType(type, e.target.checked)}
/>
))}
</div>
)}
</div>
);
})}
</div>
)}
</SettingsStack>
);
}
+34 -27
View File
@@ -3,7 +3,6 @@ import { useDisclosure } from '@mantine/hooks';
import { openConfirmModal } from '@mantine/modals';
import {
Text,
Card,
Stack,
Group,
Title,
@@ -25,6 +24,7 @@ import {
Alert,
Anchor,
} from '@mantine/core';
import { CardOrSection } from '~/components/Account/SettingsLayout';
import {
IconPlus,
IconTrash,
@@ -258,9 +258,7 @@ function SecretDisplay({
// Origin entries are exact-matched against the browser's `Origin` header, so
// they must be a bare scheme://host[:port]. We pre-validate on the client to
// give a fast inline error; the server enforces the same rule.
function parseOriginList(
text: string
): { value: string[]; error: string | null } {
function parseOriginList(text: string): { value: string[]; error: string | null } {
const lines = text
.split('\n')
.map((line) => line.trim())
@@ -563,9 +561,7 @@ function EditAppModal({
const [name, setName] = useState(client.name);
const [description, setDescription] = useState(client.description ?? '');
const [redirectUrisText, setRedirectUrisText] = useState(client.redirectUris.join('\n'));
const [allowedOriginsText, setAllowedOriginsText] = useState(
client.allowedOrigins.join('\n')
);
const [allowedOriginsText, setAllowedOriginsText] = useState(client.allowedOrigins.join('\n'));
const [tokenScope, setTokenScope] = useState(client.allowedScopes);
const [uriError, setUriError] = useState<string | null>(null);
const [originError, setOriginError] = useState<string | null>(null);
@@ -689,7 +685,7 @@ function EditAppModal({
);
}
export function OAuthAppsCard() {
export function OAuthAppsCard({ flat }: { flat?: boolean } = {}) {
const utils = trpc.useUtils();
const [registerOpened, { open: openRegister, close: closeRegister }] = useDisclosure(false);
const [editClient, setEditClient] = useState<{
@@ -769,26 +765,37 @@ export function OAuthAppsCard() {
});
};
const registerButton = (
<Button
size="compact-sm"
leftSection={<IconPlus size={14} stroke={1.5} />}
onClick={openRegister}
>
Register App
</Button>
);
return (
<>
<Card withBorder>
<Stack gap={0}>
<Group align="start" justify="space-between">
<Title order={2}>OAuth Applications</Title>
<Button
size="compact-sm"
leftSection={<IconPlus size={14} stroke={1.5} />}
onClick={openRegister}
>
Register App
</Button>
</Group>
<Text c="dimmed" size="sm">
Register OAuth applications to allow third-party integrations to access the Civitai API
on behalf of users.
</Text>
</Stack>
<Box mt="md" style={{ position: 'relative' }}>
<CardOrSection
flat={flat}
title="OAuth applications"
description="Let third-party apps call the API on a user's behalf."
action={flat ? registerButton : undefined}
>
{!flat && (
<Stack gap={0}>
<Group align="start" justify="space-between">
<Title order={2}>OAuth Applications</Title>
{registerButton}
</Group>
<Text c="dimmed" size="sm">
Register OAuth applications to allow third-party integrations to access the Civitai
API on behalf of users.
</Text>
</Stack>
)}
<Box mt={flat ? 0 : 'md'} style={{ position: 'relative' }}>
<LoadingOverlay visible={isLoading} />
{clients.length > 0 ? (
<Stack gap="sm">
@@ -923,7 +930,7 @@ export function OAuthAppsCard() {
</Paper>
)}
</Box>
</Card>
</CardOrSection>
<RegisterAppModal opened={registerOpened} onClose={closeRegister} />
+11 -15
View File
@@ -2,7 +2,6 @@ import type { GroupProps } from '@mantine/core';
import {
Accordion,
Button,
Card,
Center,
Divider,
Group,
@@ -17,6 +16,7 @@ import React from 'react';
import { formatDate } from '~/utils/date-helpers';
import { useMutateStripe, useUserPaymentMethods } from '~/components/Stripe/stripe.utils';
import { IconCreditCard, IconTrash } from '@tabler/icons-react';
import { CardOrSection } from '~/components/Account/SettingsLayout';
import { openConfirmModal } from '@mantine/modals';
import { StripePaymentMethodSetup } from '~/components/Stripe/StripePaymentMethodSetup';
import type { UserPaymentMethod } from '~/types/router';
@@ -121,7 +121,7 @@ const querySchema = z.object({
missingPaymentMethod: booleanString().optional(),
});
const StripePaymentMethods = () => {
const StripePaymentMethods = ({ flat }: { flat?: boolean }) => {
const { deletingPaymentMethod, deletePaymentMethod } = useMutateStripe();
const { userPaymentMethods, isLoading: isLoadingPaymentMethods } = useUserPaymentMethods();
const router = useRouter();
@@ -151,11 +151,9 @@ const StripePaymentMethods = () => {
};
return (
<Card withBorder>
<CardOrSection flat={flat} title="Payment methods" id="payment-methods">
<Stack>
<Title order={2} id="payment-methods">
Payment methods
</Title>
{!flat && <Title order={2}>Payment methods</Title>}
{result.success && result.data.missingPaymentMethod && (
<Text c="red" size="sm">
It looks like you are trying to upgrade your membership but we do not have a payment
@@ -215,11 +213,11 @@ const StripePaymentMethods = () => {
</Accordion.Item>
</Accordion>
</Stack>
</Card>
</CardOrSection>
);
};
const PaddlePaymentMethods = () => {
const PaddlePaymentMethods = ({ flat }: { flat?: boolean }) => {
const { managementUrls, isLoading } = useSubscriptionManagementUrls();
const { paddle } = usePaddle();
const currentUser = useCurrentUser();
@@ -260,11 +258,9 @@ const PaddlePaymentMethods = () => {
}
return (
<Card withBorder>
<CardOrSection flat={flat} title="Payment methods" id="payment-methods">
<Stack>
<Title order={2} id="payment-methods">
Payment methods
</Title>
{!flat && <Title order={2}>Payment methods</Title>}
<Divider label="Your payment methods" />
{isLoading && (
@@ -290,11 +286,11 @@ const PaddlePaymentMethods = () => {
</Stack>
)}
</Stack>
</Card>
</CardOrSection>
);
};
export function PaymentMethodsCard() {
export function PaymentMethodsCard({ flat }: { flat?: boolean } = {}) {
const paymentProvider = usePaymentProvider();
const { subscriptionLoading, subscriptionPaymentProvider } = useActiveSubscription();
@@ -309,7 +305,7 @@ export function PaymentMethodsCard() {
}
if (currentPaymentProvider === PaymentProvider.Paddle) {
return <PaddlePaymentMethods />;
return <PaddlePaymentMethods flat={flat} />;
}
return null;
@@ -10,9 +10,11 @@ import {
Stack,
Text,
} from '@mantine/core';
import type { ReactNode } from 'react';
import { useEffect, useState } from 'react';
import { CurrencyIcon } from '~/components/Currency/CurrencyIcon';
import { InfoPopover } from '~/components/InfoPopover/InfoPopover';
import { SettingsSection } from '~/components/Account/SettingsLayout';
import { useCurrentUser } from '~/hooks/useCurrentUser';
import { useFeatureFlags } from '~/providers/FeatureFlagsProvider';
import { Currency } from '~/shared/utils/prisma/enums';
@@ -52,7 +54,10 @@ const { defaultMode: DEFAULT_MODE, defaultPrice: DEFAULT_PRICE } = PLACEMENT_SUR
* rather than rounded on sight, because silently rewriting it is how a creator
* finds out from their earnings.
*/
export function PlacementSpaceSection() {
export function PlacementSpaceSection({
flat,
footer,
}: { flat?: boolean; footer?: ReactNode } = {}) {
const { pendingPlacements } = useQueryNotificationsCount();
const features = useFeatureFlags();
const currentUser = useCurrentUser();
@@ -123,13 +128,14 @@ export function PlacementSpaceSection() {
},
});
if (!enabled || !currentUser) return null;
// The sticker-inventory footer isn't gated on placement, so it must survive these early returns.
if (!enabled || !currentUser) return <>{footer}</>;
// `spaces` is undefined in every terminal state except success — in flight
// AND after the retries are exhausted — and undefined is indistinguishable
// from "no row". Rendering against either would seed the defaults and let one
// click write `review` over a space the creator had explicitly closed. A
// failed read is not permission to assume they have no preference.
if (spacesPending || spacesFailed) return null;
if (spacesPending || spacesFailed) return <>{footer}</>;
const cap = range?.max ?? 0;
const freeSlotCap = range?.freeSlotCap ?? 0;
@@ -176,23 +182,21 @@ export function PlacementSpaceSection() {
!stored && nextPrice === DEFAULT_PRICE ? undefined : nextPrice === '' ? null : nextPrice,
});
return (
<>
<Divider
label={
<Group gap={4} wrap="nowrap">
Stickers on your images
<InfoPopover size="xs" iconProps={{ size: 14 }} width={320}>
<Text size="sm" maw={300} style={{ whiteSpace: 'normal' }}>
Let other people pay to place a sticker on your work. You keep most of what they
pay, and you can decline anything you don&apos;t want. Individual posts and images
can override this.
</Text>
</InfoPopover>
</Group>
}
/>
const heading = (
<Group gap={4} wrap="nowrap">
Stickers on your images
<InfoPopover size="xs" iconProps={{ size: 14 }} width={320}>
<Text size="sm" maw={300} style={{ whiteSpace: 'normal' }}>
Let other people pay to place a sticker on your work. You keep most of what they pay, and
you can decline anything you don&apos;t want. Individual posts and images can override
this.
</Text>
</InfoPopover>
</Group>
);
const body = (
<>
<SegmentedControl
value={mode}
onChange={(value) => {
@@ -350,4 +354,21 @@ export function PlacementSpaceSection() {
)}
</>
);
if (flat)
return (
<SettingsSection title={heading}>
<div className="flex flex-col gap-4">
{body}
{footer}
</div>
</SettingsSection>
);
return (
<>
<Divider label={heading} />
{body}
</>
);
}
@@ -0,0 +1,97 @@
import React from 'react';
import {
AssistantPersonalitySelect,
AutoplayGifsToggle,
EarlyAdopterToggle,
HideBlueBuzzToggle,
ImageFormatSelect,
ModelFileFormatSelect,
ModelPrecisionSelect,
ModelQuantTypeSelect,
StickerMotionToggle,
SwipeGalleryCardsToggle,
ToggleableFeatures,
mediaToggleableFeatures,
otherToggleableFeatures,
} from '~/components/Account/SettingsCard';
import { SettingRow, SettingsSection, SettingsStack } from '~/components/Account/SettingsLayout';
import { GenerationSettings } from '~/components/Generation/GenerationSettings';
import { useCurrentUser } from '~/hooks/useCurrentUser';
import { useFeatureFlags } from '~/providers/FeatureFlagsProvider';
export function PreferencesPane() {
const flags = useFeatureFlags();
const user = useCurrentUser();
return (
<SettingsStack>
<SettingsSection title="Media playback">
<SettingRow block>
<AutoplayGifsToggle />
</SettingRow>
<SettingRow block>
<SwipeGalleryCardsToggle />
</SettingRow>
<SettingRow block>
<StickerMotionToggle />
</SettingRow>
{mediaToggleableFeatures.map((feature) => (
<SettingRow block key={feature.key}>
<ToggleableFeatures data={[feature]} />
</SettingRow>
))}
</SettingsSection>
<SettingsSection title="Generation">
<SettingRow block>
<GenerationSettings />
</SettingRow>
</SettingsSection>
<SettingsSection title="File preferences" description="Defaults for the download button.">
<SettingRow
label="Preferred image format"
description="Used on site and for downloads."
control={<ImageFormatSelect />}
/>
<SettingRow label="Preferred model format" control={<ModelFileFormatSelect />} />
<SettingRow
label="Preferred precision"
description="fp16 halves the size of most checkpoints."
control={<ModelPrecisionSelect />}
/>
{user?.filePreferences?.format === 'GGUF' && (
<SettingRow
label="Preferred quant type"
description="Q8_0 is the best quality, Q2_K the smallest."
control={<ModelQuantTypeSelect />}
/>
)}
</SettingsSection>
<SettingsSection title="Features">
{otherToggleableFeatures.map((feature) => (
<SettingRow block key={feature.key}>
<ToggleableFeatures data={[feature]} />
</SettingRow>
))}
{flags.assistant && (
<SettingRow
label="Assistant personality"
description="Available to subscribers."
control={<AssistantPersonalitySelect />}
/>
)}
{flags.buzz && (
<SettingRow block>
<HideBlueBuzzToggle />
</SettingRow>
)}
<SettingRow block>
<EarlyAdopterToggle />
</SettingRow>
</SettingsSection>
</SettingsStack>
);
}
+64 -22
View File
@@ -22,6 +22,7 @@ import { usernameInputSchema } from '~/server/schema/user.schema';
import { showSuccessNotification } from '~/utils/notifications';
import { trpc } from '~/utils/trpc';
import { openUserProfileEditModal } from '~/components/Dialog/triggers/user-profile-edit';
import { SettingsSection } from '~/components/Account/SettingsLayout';
const schema = z.object({
id: z.number(),
@@ -32,7 +33,7 @@ const emailChangeSchema = z.object({
newEmail: z.string().email('Please enter a valid email address'),
});
export function ProfileCard() {
export function ProfileCard({ flat }: { flat?: boolean } = {}) {
const queryUtils = trpc.useUtils();
const session = useCurrentUser();
const { data } = useSession();
@@ -40,7 +41,11 @@ export function ProfileCard() {
const currentUser = data?.user;
const { mutate, isPending: isLoading, error } = trpc.user.update.useMutation({
const {
mutate,
isPending: isLoading,
error,
} = trpc.user.update.useMutation({
async onSuccess(user) {
showSuccessNotification({ message: 'Your profile has been saved' });
await queryUtils.user.getById.invalidate({ id: user.id });
@@ -78,19 +83,19 @@ export function ProfileCard() {
mode: 'onChange',
});
return (
<Card withBorder>
<Form
form={form}
onSubmit={(data) => {
const { id, username } = data;
mutate({
id,
username,
});
}}
>
<Stack>
const formBody = (
<Form
form={form}
onSubmit={(data) => {
const { id, username } = data;
mutate({
id,
username,
});
}}
>
<Stack>
{!flat && (
<Group justify="space-between">
<Title order={2}>Account Info</Title>
<Button
@@ -104,11 +109,22 @@ export function ProfileCard() {
Customize profile
</Button>
</Group>
{error && (
<Alert color="red" variant="light">
{error.data?.code === 'CONFLICT' ? 'That username is already taken' : error.message}
</Alert>
)}
)}
{error && (
<Alert color="red" variant="light">
{error.data?.code === 'CONFLICT' ? 'That username is already taken' : error.message}
</Alert>
)}
{flat ? (
<div className="flex flex-col gap-4 sm:flex-row">
<div className="flex-1">
<InputText name="username" label="Username" required />
</div>
<div className="flex-1">
<TextInput label="Account email" value={currentUser?.email ?? ''} disabled readOnly />
</div>
</div>
) : (
<Grid>
<Grid.Col span={12}>
<InputText name="username" label="Username" required />
@@ -148,8 +164,34 @@ export function ProfileCard() {
</Button>
</Grid.Col>
</Grid>
</Stack>
</Form>
)}
{flat && (
<Group justify="flex-end" gap="sm">
<Button
variant="default"
size="compact-sm"
leftSection={<IconMail size={14} />}
onClick={openEmailModal}
>
Change email
</Button>
<Button
type="submit"
size="compact-sm"
loading={isLoading}
disabled={!form.formState.isDirty}
>
Save changes
</Button>
</Group>
)}
</Stack>
</Form>
);
return (
<Card withBorder={!flat} p={flat ? 0 : undefined} bg={flat ? 'transparent' : undefined}>
{flat ? <SettingsSection title="Account info">{formBody}</SettingsSection> : formBody}
{/* Email Change Modal */}
<Modal
+17 -7
View File
@@ -1,15 +1,25 @@
import { Button, Card, Stack, Text, Title } from '@mantine/core';
import { closeModal, openConfirmModal } from '@mantine/modals';
import { useAccountContext } from '~/components/CivitaiWrapped/AccountProvider';
import { useCurrentUser } from '~/hooks/useCurrentUser';
import { IconRefresh } from '@tabler/icons-react';
import { useRefreshSession } from '~/components/Stripe/memberships.util';
import { showErrorNotification } from '~/utils/notifications';
import { trpc } from '~/utils/trpc';
import { PointerCard } from '~/components/Account/SettingsLayout';
export function RefreshSessionCard() {
const currentUser = useCurrentUser();
export function RefreshSessionCard({ flat }: { flat?: boolean }) {
const { refreshSession } = useRefreshSession();
if (flat)
return (
<PointerCard
icon={<IconRefresh size={18} />}
title="Refresh my session"
description="Reloads your account data. Safe to run any time."
action={
<Button variant="default" size="compact-sm" onClick={refreshSession}>
Refresh
</Button>
}
/>
);
return (
<Card withBorder>
<Stack>
@@ -137,6 +137,8 @@ describe('SettingsCard — early-adopter toggle', () => {
// real explanatory copy, not just the label.
renderWithProviders(<SettingsCard />);
await expect.element(page.getByText(/before they roll out to everyone/i)).toBeInTheDocument();
await expect.element(page.getByText(/before they roll out/i)).toBeInTheDocument();
// The caveat is the half that makes it consent rather than an advert.
await expect.element(page.getByText(/rough or change without notice/i)).toBeInTheDocument();
});
});
+142 -12
View File
@@ -25,13 +25,25 @@ import { showErrorNotification, showSuccessNotification } from '~/utils/notifica
import { trpc } from '~/utils/trpc';
const validModelFormats = constants.modelFileFormats.filter((format) => format !== 'Other');
const normalizedToggleableFeatures = toggleableFeatures.filter(
export const normalizedToggleableFeatures = toggleableFeatures.filter(
(feature) => feature.key !== 'assistant'
);
const assistantToggleableFeatures = toggleableFeatures.filter(
export const assistantToggleableFeatures = toggleableFeatures.filter(
(feature) => feature.key === 'assistant'
);
/**
* Which pane section a toggleable flag belongs to. Anything not named here lands in Features, so a
* flag added to `featureFlags` shows up somewhere rather than silently disappearing from the pane.
*/
const mediaFeatureKeys: string[] = ['largerGenerationImages', 'nativeVideoControls'];
export const mediaToggleableFeatures = toggleableFeatures.filter((feature) =>
mediaFeatureKeys.includes(feature.key)
);
export const otherToggleableFeatures = toggleableFeatures.filter(
(feature) => !mediaFeatureKeys.includes(feature.key)
);
export function SettingsCard() {
const user = useCurrentUser();
const queryUtils = trpc.useUtils();
@@ -207,7 +219,7 @@ export function SettingsCard() {
);
}
function AutoplayGifsToggle() {
export function AutoplayGifsToggle() {
const autoplayGifs = useBrowsingSettings((x) => x.autoplayGifs);
const setState = useBrowsingSettings((x) => x.setState);
@@ -221,7 +233,7 @@ function AutoplayGifsToggle() {
);
}
function SwipeGalleryCardsToggle() {
export function SwipeGalleryCardsToggle() {
const { swipeGalleryCards } = useCurrentUserSettings();
const { mutate, isPending } = useMutateUserSettings();
@@ -229,7 +241,7 @@ function SwipeGalleryCardsToggle() {
<Switch
name="swipeGalleryCards"
label="Swipe between images on gallery cards"
description="Drag left or right on a gallery post to move through its images instead of using the arrows. May feel slower on long feeds or older devices."
description="Swipe through a post's images instead of using the arrows."
checked={swipeGalleryCards ?? false}
disabled={isPending}
onChange={(e) => mutate({ swipeGalleryCards: e.target.checked })}
@@ -238,7 +250,7 @@ function SwipeGalleryCardsToggle() {
);
}
function StickerMotionToggle() {
export function StickerMotionToggle() {
const features = useFeatureFlags();
const { disableStickerMotion } = useCurrentUserSettings();
const { mutate, isPending } = useMutateUserSettings();
@@ -252,7 +264,7 @@ function StickerMotionToggle() {
<Switch
name="stickerMotion"
label="Animate stickers placed on images"
description="Placed stickers pop in and drift gently. Turn this off to keep them still they stay visible either way. Already off if your device asks for reduced motion."
description="Off keeps them still; they stay visible either way."
// Stored as an opt-out so the default costs no row, and so a creator who
// never opens this page gets the animation rather than a silent no.
checked={!(disableStickerMotion ?? false)}
@@ -263,7 +275,7 @@ function StickerMotionToggle() {
);
}
function HideBlueBuzzToggle() {
export function HideBlueBuzzToggle() {
const { hideBlueBuzzInHeader } = useCurrentUserSettings();
const { mutate, isPending } = useMutateUserSettings();
@@ -271,7 +283,7 @@ function HideBlueBuzzToggle() {
<Switch
name="hideBlueBuzzInHeader"
label="Hide Blue Buzz in the header"
description="The header adds your Blue Buzz into one balance with the rest. Turn this on to leave it out and show only the rest. Your Blue Buzz is still yours to spend, and the account menu lists both either way."
description="Leaves it out of the header balance. You can still spend it."
checked={hideBlueBuzzInHeader ?? false}
disabled={isPending}
onChange={(e) => mutate({ hideBlueBuzzInHeader: e.target.checked })}
@@ -280,7 +292,7 @@ function HideBlueBuzzToggle() {
);
}
function EarlyAdopterToggle() {
export function EarlyAdopterToggle() {
const { isEarlyAdopter } = useCurrentUserSettings();
const currentUser = useCurrentUser();
// The value is carried on the SESSION (see user.schema `isEarlyAdopter`), and the server
@@ -298,7 +310,7 @@ function EarlyAdopterToggle() {
<Switch
name="isEarlyAdopter"
label="Join the early-adopter program"
description="Get in-progress features before they roll out to everyone. They may be rough, change without notice, or be withdrawn. Turn this off any time to go back to the standard experience."
description="Features before they roll out. They may be rough or change without notice."
checked={isEarlyAdopter ?? false}
disabled={isPending}
onChange={(e) => mutate({ isEarlyAdopter: e.target.checked })}
@@ -307,7 +319,7 @@ function EarlyAdopterToggle() {
);
}
function ToggleableFeatures({ data }: { data: typeof toggleableFeatures }) {
export function ToggleableFeatures({ data }: { data: typeof toggleableFeatures }) {
const flags = useFeatureFlags();
const queryUtils = trpc.useUtils();
const toggleFeatureFlagMutation = trpc.user.toggleFeature.useMutation({
@@ -357,3 +369,121 @@ function ToggleableFeatures({ data }: { data: typeof toggleableFeatures }) {
</>
);
}
/**
* The selects below are exported so the flat settings panes and the legacy card render the same
* control rather than two copies that can drift while the `accountSettingsV2` flag is alive.
* They carry no label of their own the pane's `SettingRow` supplies it.
*/
function useFilePreferenceUpdate() {
const user = useCurrentUser();
const queryUtils = trpc.useUtils();
const { mutate, isPending } = trpc.user.update.useMutation({
async onSuccess() {
await queryUtils.model.getAll.invalidate();
await user?.refresh();
showSuccessNotification({ message: 'User profile updated' });
},
});
const update = (filePreferences: Record<string, unknown>) => {
if (!user) return;
mutate({ id: user.id, filePreferences: { ...user.filePreferences, ...filePreferences } });
};
return { user, update, isPending };
}
export function ImageFormatSelect() {
const { user, update, isPending } = useFilePreferenceUpdate();
if (!user) return null;
return (
<Select
aria-label="Preferred image format"
data={[
{ value: 'optimized', label: 'Optimized (avif, webp)' },
{ value: 'metadata', label: 'Unoptimized (jpeg, png)' },
]}
value={user.filePreferences?.imageFormat ?? 'metadata'}
onChange={(value: string | null) => update({ imageFormat: value })}
disabled={isPending}
/>
);
}
export function ModelFileFormatSelect() {
const { user, update, isPending } = useFilePreferenceUpdate();
if (!user) return null;
return (
<Select
aria-label="Preferred model file format"
data={validModelFormats}
value={user.filePreferences?.format ?? 'SafeTensor'}
onChange={(value: string | null) => update({ format: value })}
disabled={isPending}
/>
);
}
export function ModelPrecisionSelect() {
const { user, update, isPending } = useFilePreferenceUpdate();
const { precisions } = useModelFileOptions();
if (!user) return null;
return (
<Select
aria-label="Preferred precision"
data={precisions.map((value) => ({ value, label: value.toUpperCase() }))}
value={user.filePreferences?.fp ?? 'fp16'}
onChange={(value: string | null) => update({ fp: value })}
disabled={isPending}
/>
);
}
/** Only meaningful for GGUF, which is the one format that ships quantised builds. */
export function ModelQuantTypeSelect() {
const { user, update, isPending } = useFilePreferenceUpdate();
const { quantTypes } = useModelFileOptions();
if (!user || user.filePreferences?.format !== 'GGUF') return null;
return (
<Select
aria-label="Preferred quant type"
data={quantTypes.filter((x) => x !== UNQUANTIZED_QUANT_TYPE)}
allowDeselect={false}
value={user.filePreferences?.quantType ?? 'Q4_K_M'}
onChange={(value: string | null) => update({ quantType: value })}
disabled={isPending}
/>
);
}
export function AssistantPersonalitySelect() {
const flags = useFeatureFlags();
const { assistantPersonality } = useCurrentUserSettings();
const { mutate, isPending } = useMutateUserSettings();
return (
<Tooltip
withArrow
label="Available to subscribers only"
disabled={flags.assistantPersonality}
offset={-10}
>
<div>
<Select
aria-label="Assistant personality"
disabled={isPending || !flags.assistantPersonality}
data={[
{ value: 'civbot', label: 'CivBot' },
{ value: 'civchan', label: 'CivChan' },
]}
value={assistantPersonality ?? 'civbot'}
onChange={(value: string | null) => {
if (flags.assistantPersonality)
mutate({ assistantPersonality: value as UserAssistantPersonality });
}}
/>
</div>
</Tooltip>
);
}
+325
View File
@@ -0,0 +1,325 @@
import { Card as MantineCard, Group, Stack, Text, ThemeIcon, Title } from '@mantine/core';
import { IconArrowUpRight, IconCircleCheck } from '@tabler/icons-react';
import clsx from 'clsx';
import React from 'react';
import { NextLink } from '~/components/NextLink/NextLink';
/**
* The settings panes deliberately carry no `Card`. Twenty-three bordered boxes stacked down a page
* is what made the old one read as a pile; sections separated by a rule and a heading give the same
* grouping without the chrome.
*/
export function SettingsSection({
title,
description,
action,
children,
className,
}: {
title?: React.ReactNode;
description?: React.ReactNode;
action?: React.ReactNode;
children: React.ReactNode;
className?: string;
}) {
return (
<section className={clsx('flex flex-col', className)}>
{(title || description || action) && (
<div className="flex flex-col gap-0.5 pb-2">
{(title || action) && (
<div className="flex items-center justify-between gap-3">
{title && (
<Text component="h2" className="text-base font-semibold leading-tight">
{title}
</Text>
)}
{action && <div className="shrink-0">{action}</div>}
</div>
)}
{description && (
<Text size="xs" c="dimmed" className="leading-snug">
{description}
</Text>
)}
</div>
)}
<div
className={clsx(
'flex flex-col gap-5 border-t border-gray-3 pt-4 dark:border-dark-4',
// Mantine puts a Switch's track before its label. Every other control in a settings row
// reads label-left / control-right, and a section that mixes both alignments is the thing
// the design review rejected. Reversing the body here keeps one implementation of each
// toggle instead of a settings-only copy. Static slot classes are load-bearing across this
// repo already — see the `getStaticClassNames` note in globals.css.
'[&_.mantine-Switch-body]:w-full [&_.mantine-Switch-body]:flex-row-reverse [&_.mantine-Switch-body]:items-center [&_.mantine-Switch-body]:justify-between [&_.mantine-Switch-body]:gap-6',
'[&_.mantine-Switch-labelWrapper]:flex-1',
// Mantine's label padding is the gap to the track. Reversed, it becomes an indent off
// the row column.
'[&_.mantine-Switch-description]:ps-0 [&_.mantine-Switch-label]:ps-0'
)}
>
{children}
</div>
</section>
);
}
/**
* `control` sits right of the label on a wide row and drops beneath it on a narrow one. Anything
* that needs the full width regardless a tag picker, a list should use `block` instead.
*/
export function SettingRow({
label,
description,
control,
block,
children,
}: {
label?: React.ReactNode;
description?: React.ReactNode;
control?: React.ReactNode;
block?: boolean;
children?: React.ReactNode;
}) {
if (block) {
return (
<div className="flex flex-col gap-2">
{(label || description) && (
<div className="flex flex-col gap-0.5">
{label && (
<Text size="sm" fw={500}>
{label}
</Text>
)}
{description && (
<Text size="xs" c="dimmed" className="leading-snug">
{description}
</Text>
)}
</div>
)}
{children ?? control}
</div>
);
}
return (
<div
className={clsx(
'flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-6',
// Stacking put the track under the description, where it read as the next row's.
'has-[.mantine-Switch-root]:flex-row has-[.mantine-Switch-root]:items-center has-[.mantine-Switch-root]:justify-between has-[.mantine-Switch-root]:gap-4'
)}
>
<div className="flex min-w-0 flex-col gap-0.5">
{label && (
<Text size="sm" fw={500}>
{label}
</Text>
)}
{description && (
<Text size="xs" c="dimmed" className="leading-snug">
{description}
</Text>
)}
</div>
{control && (
// `[&>*]:ml-auto` for block controls that shrink to content — a switch, or the
// fixed-width Skeleton around one.
<div
className={clsx(
'shrink-0 text-right sm:min-w-[180px] [&>*]:ml-auto',
// The section's Switch overrides target switches that ARE the row. A switch in
// `control` has no label, so they stretch it past the section's edge — undo them and
// let it shrink to the track.
'[&_.mantine-Switch-body]:!w-auto [&_.mantine-Switch-body]:!flex-row [&_.mantine-Switch-body]:!gap-0 [&_.mantine-Switch-root]:!w-fit'
)}
>
{control}
</div>
)}
</div>
);
}
/**
* Panes are a single column. Two columns were tried and dropped: without card edges there is no
* boundary telling you which column to read first, and the pairing only ever saved height on three
* of eight panes.
*/
export function SettingsStack({ children }: { children: React.ReactNode }) {
return <div className="flex flex-col gap-10">{children}</div>;
}
/**
* Cards that predate the flat panes take a `flat` prop rather than being forked: the legacy page
* still mounts them inside `Card` chrome while the flag is alive, and two copies of a settings form
* is exactly how one of them silently loses a field.
*/
export function CardOrSection({
flat,
title,
description,
action,
id,
children,
}: {
flat?: boolean;
title?: React.ReactNode;
description?: React.ReactNode;
action?: React.ReactNode;
id?: string;
children: React.ReactNode;
}) {
if (flat)
return (
<div id={id}>
<SettingsSection title={title} description={description} action={action}>
<Stack>{children}</Stack>
</SettingsSection>
</div>
);
return (
<MantineCard withBorder id={id}>
<Stack>
{title && <Title order={2}>{title}</Title>}
{children}
</Stack>
</MantineCard>
);
}
export function SettingsNote({
icon,
children,
}: {
icon?: React.ReactNode;
children: React.ReactNode;
}) {
return (
<div className="flex items-center gap-3 rounded-md bg-blue-1 px-4 py-3 dark:bg-blue-8/20">
{icon && <div className="shrink-0 text-blue-6">{icon}</div>}
<Text size="sm" className="leading-snug">
{children}
</Text>
</div>
);
}
/** A row that sends you somewhere else rather than changing anything here. */
export function PointerCard({
icon,
title,
description,
href,
action,
tone = 'default',
}: {
icon: React.ReactNode;
title: string;
description?: React.ReactNode;
href?: string;
action?: React.ReactNode;
tone?: 'default' | 'danger';
}) {
const danger = tone === 'danger';
const body = (
<>
<div className="flex items-center gap-3.5">
<div
className={clsx(
'flex size-9 shrink-0 items-center justify-center rounded',
danger
? 'bg-red-1 text-red-6 dark:bg-red-8/25'
: 'bg-blue-1 text-blue-6 dark:bg-blue-8/25'
)}
>
{icon}
</div>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<Text size="sm" fw={600}>
{title}
</Text>
{description && (
<Text size="xs" c="dimmed" className="leading-snug">
{description}
</Text>
)}
</div>
{!action && (
<IconArrowUpRight size={16} className="shrink-0 text-gray-6 dark:text-dark-2" />
)}
</div>
{action && <div className="shrink-0 self-end sm:self-auto">{action}</div>}
</>
);
const className = clsx(
'flex flex-col gap-3 rounded-md border p-4 no-underline sm:flex-row sm:items-center sm:gap-3.5',
'[&>div:first-child]:min-w-0 [&>div:first-child]:flex-1',
danger
? 'border-red-6 bg-red-1/50 dark:bg-red-8/10'
: 'border-gray-3 bg-white dark:border-dark-4 dark:bg-dark-6'
);
if (href)
return (
<NextLink href={href} className={clsx(className, 'hover:bg-gray-0 dark:hover:bg-dark-5')}>
{body}
</NextLink>
);
return <div className={className}>{body}</div>;
}
/**
* The gate a settings pane shows instead of controls the user cannot use yet. One implementation
* because the three sites that need it (metric visibility, membership, payouts) sit in different
* cards and would otherwise drift apart in tone.
*/
export function UpsellPanel({
icon,
title,
description,
perks,
action,
}: {
icon: React.ReactNode;
title: React.ReactNode;
description?: React.ReactNode;
perks?: React.ReactNode[];
action?: React.ReactNode;
}) {
return (
<div className="flex flex-col items-center gap-3 rounded-lg border border-gray-2 bg-gray-0 p-6 text-center dark:border-dark-4 dark:bg-dark-5">
<ThemeIcon size={48} variant="light" color="gray" radius="xl">
{icon}
</ThemeIcon>
<Text fw={700} size="lg">
{title}
</Text>
{description && (
<Text size="sm" c="dimmed" maw={380}>
{description}
</Text>
)}
{!!perks?.length && (
<Stack gap={6} align="flex-start" ta="left">
{perks.map((perk, index) => (
<Group key={index} gap={8} wrap="nowrap">
<IconCircleCheck
size={16}
className="shrink-0"
style={{ color: 'var(--mantine-color-green-6)' }}
/>
<Text size="sm">{perk}</Text>
</Group>
))}
</Stack>
)}
{action}
</div>
);
}
+72 -11
View File
@@ -1,14 +1,27 @@
import { Alert, Button, Card, Center, Divider, Group, Loader, Stack, Title } from '@mantine/core';
import {
Alert,
Button,
Card,
Center,
Divider,
Group,
Loader,
Stack,
Text,
Title,
} from '@mantine/core';
import { IconPlus } from '@tabler/icons-react';
import { LinkType } from '~/shared/utils/prisma/enums';
import React, { useState } from 'react';
import { SocialLink } from '~/components/Account/SocialLink';
import { SocialLinkModal } from '~/components/Account/SocialLinkModal';
import { SettingsSection } from '~/components/Account/SettingsLayout';
import { useCurrentUser } from '~/hooks/useCurrentUser';
import { sortDomainLinks } from '~/utils/domain-link';
import { trpc } from '~/utils/trpc';
export function SocialProfileCard() {
export function SocialProfileCard({ flat }: { flat?: boolean } = {}) {
const user = useCurrentUser();
const [selectedLink, setSelectedLink] = useState<{
@@ -17,7 +30,6 @@ export function SocialProfileCard() {
url?: string;
}>();
// const utils = trpc.useUtils();
const { data, isLoading } = trpc.userLink.getAll.useQuery(
{ userId: user?.id },
{
@@ -33,8 +45,11 @@ export function SocialProfileCard() {
if (!user) return null;
const linksFor = (type: LinkType) =>
type === LinkType.Social ? data?.social : data?.sponsorship;
const renderLinks = (type: LinkType) => {
const links = type === LinkType.Social ? data?.social : data?.sponsorship;
const links = linksFor(type);
return (
<Card withBorder>
<Card.Section withBorder p="sm">
@@ -67,15 +82,61 @@ export function SocialProfileCard() {
);
};
const renderGroup = (type: LinkType, label: string) => {
const links = linksFor(type);
return (
<div className="flex flex-col gap-2">
<Text size="xs" fw={600} c="dimmed" tt="uppercase" className="tracking-wide">
{label}
</Text>
{isLoading ? (
<Center p="sm">
<Loader size="sm" />
</Center>
) : !links?.length ? (
<Text size="sm" c="dimmed">
No {label.toLowerCase()} yet.
</Text>
) : (
<div className="flex flex-col">
{sortDomainLinks(links).map((link, index) => (
<React.Fragment key={link.id}>
<SocialLink link={link} setSelected={setSelectedLink} />
{index < links.length - 1 && <Divider p={0} my="xs" />}
</React.Fragment>
))}
</div>
)}
<Group justify="flex-end">
<Button
variant="default"
size="compact-sm"
leftSection={<IconPlus size={14} />}
onClick={() => setSelectedLink({ type })}
>
Add link
</Button>
</Group>
</div>
);
};
return (
<>
<Card withBorder>
<Stack>
<Title order={2}>Creator Profile</Title>
{renderLinks(LinkType.Social)}
{renderLinks(LinkType.Sponsorship)}
</Stack>
</Card>
{flat ? (
<SettingsSection title="Creator profile" description="Shown on your public profile.">
{renderGroup(LinkType.Social, 'Social links')}
{renderGroup(LinkType.Sponsorship, 'Sponsorship links')}
</SettingsSection>
) : (
<Card withBorder>
<Stack>
<Title order={2}>Creator Profile</Title>
{renderLinks(LinkType.Social)}
{renderLinks(LinkType.Sponsorship)}
</Stack>
</Card>
)}
<SocialLinkModal selected={selectedLink} onClose={() => setSelectedLink(undefined)} />
</>
);
@@ -1,17 +1,21 @@
import { Badge, Button, Card, Group, Loader, Popover, Stack, Text, Title } from '@mantine/core';
import { IconSticker } from '@tabler/icons-react';
import { useState } from 'react';
import { EdgeImage } from '~/components/EdgeMedia/EdgeImage';
import { NextLink as Link } from '~/components/NextLink/NextLink';
import { PointerCard } from '~/components/Account/SettingsLayout';
import { useOwnedSticker } from '~/components/Sticker/sticker.util';
import { StickerTopUp } from '~/components/Sticker/StickerTopUp';
import { useCurrentUser } from '~/hooks/useCurrentUser';
import { useFeatureFlags } from '~/providers/FeatureFlagsProvider';
import { stickerSurfaceLabels, STICKER_SIZE } from '~/shared/utils/sticker-token';
import { trpc } from '~/utils/trpc';
const { charged, free } = stickerSurfaceLabels();
export function StickerInventoryCard() {
export function StickerInventoryCard({ flat }: { flat?: boolean } = {}) {
const features = useFeatureFlags();
const currentUser = useCurrentUser();
const [toppingUp, setToppingUp] = useState<number | null>(null);
const { sticker, isLoading } = useOwnedSticker();
const { data: balanceRows } = trpc.cosmetic.getStickerBalances.useQuery(undefined, {
@@ -20,13 +24,29 @@ export function StickerInventoryCard() {
if (!features.stickers) return null;
// Without the Stickerbook page there is nowhere to point, so the inline list stays.
if (flat && features.stickerBook && currentUser?.username)
return (
<PointerCard
icon={<IconSticker size={18} />}
title="Your sticker inventory"
description="Browse and manage your stickers in the Stickerbook."
href={`/user/${currentUser.username}/sticker-book`}
/>
);
// null remaining = unlimited; a missing row means the balance hasn't loaded.
const balances = new Map((balanceRows ?? []).map((b) => [b.cosmeticId, b.remaining]));
return (
<Card withBorder id="stickers">
<Card
withBorder={!flat}
p={flat ? 0 : undefined}
bg={flat ? 'transparent' : undefined}
id="stickers"
>
<Stack>
<Title order={2}>Stickers</Title>
<Title order={flat ? 3 : 2}>Stickers</Title>
<Text size="sm" c="dimmed">
Stickers you own. A use is spent each time you place one in {charged.join(', ')}; using
them in {free.join(', ')} is free.
+108 -5
View File
@@ -1,15 +1,18 @@
import { Badge, Card, Divider, Group, Loader, Paper, Stack, Text, Title } from '@mantine/core';
import { IconCheck } from '@tabler/icons-react';
import { useCurrentUser } from '~/hooks/useCurrentUser';
import { strikeStatusColorScheme } from '~/server/schema/strike.schema';
import { accountStandingFromPoints, strikeStatusColorScheme } from '~/server/schema/strike.schema';
import { formatDate } from '~/utils/date-helpers';
import { getDisplayName } from '~/utils/string-helpers';
import { trpc } from '~/utils/trpc';
import { SettingRow, SettingsSection } from '~/components/Account/SettingsLayout';
import { UserScoreDisplay } from './UserScoreDisplay';
// The strike email links to `/user/account#strikes`, and the challenge/creator-program eligibility
// rows link to `#creator-score`. Both targets only render their `id` once data loads, so the
// browser's native hash scroll fires too early. Module-level so the ref identity stays stable.
// Legacy page only — the v2 shell maps these anchors to a section and drops the fragment
// (`legacyAnchorSections` in account-sections.ts), so these refs are dead on the pane.
function scrollIfHashed(hash: string) {
return (node: HTMLElement | null) => {
if (node && typeof window !== 'undefined' && window.location.hash === hash) {
@@ -21,7 +24,7 @@ function scrollIfHashed(hash: string) {
const scrollToStrikes = scrollIfHashed('#strikes');
const scrollToCreatorScore = scrollIfHashed('#creator-score');
export function StrikesCard() {
export function StrikesCard({ flat }: { flat?: boolean } = {}) {
const currentUser = useCurrentUser();
const scores = currentUser?.meta?.scores;
const { data: summary, isLoading: summaryLoading } = trpc.strike.getMyStrikeSummary.useQuery();
@@ -30,6 +33,13 @@ export function StrikesCard() {
});
if (summaryLoading) {
if (flat)
return (
<SettingsSection title="Account standing">
<Loader size="sm" />
</SettingsSection>
);
return (
<Card withBorder>
<Stack>
@@ -41,10 +51,102 @@ export function StrikesCard() {
}
const points = summary?.totalActivePoints ?? 0;
const standingColor = points === 0 ? 'green' : points === 1 ? 'yellow' : 'red';
const standingLabel = points === 0 ? 'Good Standing' : points === 1 ? 'Warning' : 'Restricted';
const { label: standingLabel, color: standingColor } = accountStandingFromPoints(points);
const strikes = strikesData?.strikes ?? [];
const standingBadges = (
<Group gap="xs" wrap="nowrap">
<Badge
color={standingColor}
size="md"
variant="light"
leftSection={points === 0 ? <IconCheck size={14} /> : undefined}
>
{standingLabel}
</Badge>
{points > 0 && (
<Badge color={standingColor} size="md" variant="light">
{summary?.activeStrikes} active &middot; {points} {points === 1 ? 'point' : 'points'}
</Badge>
)}
</Group>
);
const strikeList = strikesLoading ? (
<Loader size="sm" />
) : strikes.length === 0 ? null : (
<Stack gap="sm">
{strikes.map((strike) => (
<Paper key={strike.id} withBorder p="md" radius="md">
<Stack gap="xs">
<Group gap="xs" wrap="nowrap">
<div
role="img"
aria-label={strike.status === 'Active' ? 'Active strike' : 'Inactive strike'}
style={{
width: 10,
height: 10,
borderRadius: '50%',
backgroundColor:
strike.status === 'Active'
? 'var(--mantine-color-red-filled)'
: 'var(--mantine-color-gray-filled)',
flexShrink: 0,
}}
/>
<Text size="sm" fw={600} style={{ flex: 1 }}>
{getDisplayName(strike.reason)}
</Text>
<Badge
color={strikeStatusColorScheme[strike.status] ?? 'gray'}
size="sm"
variant="light"
>
{strike.points} {strike.points === 1 ? 'pt' : 'pts'}
</Badge>
</Group>
<Text size="sm" c="dimmed">
{strike.description}
</Text>
<Group justify="space-between">
<Text size="xs" c="dimmed">
Issued: {formatDate(strike.createdAt)}
</Text>
<Text size="xs" c="dimmed">
Expires: {formatDate(strike.expiresAt)}
</Text>
</Group>
</Stack>
</Paper>
))}
</Stack>
);
if (flat)
return (
<div id="strikes" ref={scrollToStrikes}>
<SettingsSection title="Account standing">
<div id="creator-score" ref={scrollToCreatorScore}>
<UserScoreDisplay scores={scores} flat abbreviate={false} />
</div>
<SettingRow
label="Strikes"
description={
strikesLoading
? undefined
: strikes.length === 0
? 'No active strikes.'
: `${strikes.length} active ${strikes.length === 1 ? 'strike' : 'strikes'}.`
}
control={standingBadges}
/>
{strikes.length > 0 && strikeList}
</SettingsSection>
</div>
);
return (
<Card withBorder id="strikes" ref={scrollToStrikes}>
<Stack gap="lg">
@@ -79,7 +181,8 @@ export function StrikesCard() {
</Badge>
{points > 0 && (
<Badge color={standingColor} size="md" variant="light">
{summary?.activeStrikes} active &middot; {points} {points === 1 ? 'point' : 'points'}
{summary?.activeStrikes} active &middot; {points}{' '}
{points === 1 ? 'point' : 'points'}
</Badge>
)}
</Group>
+47 -24
View File
@@ -1,17 +1,12 @@
import {
Button,
Card,
Stack,
Center,
Loader,
Title,
Text,
Group,
Box,
Divider,
} from '@mantine/core';
import { Button, Stack, Center, Loader, Title, Text, Group, Box, Divider } from '@mantine/core';
import { NextLink as Link } from '~/components/NextLink/NextLink';
import { IconAlertTriangle, IconExternalLink, IconSettings } from '@tabler/icons-react';
import {
IconAlertTriangle,
IconExternalLink,
IconRosetteDiscountCheck,
IconSettings,
IconUserCircle,
} from '@tabler/icons-react';
import { AlertWithIcon } from '~/components/AlertWithIcon/AlertWithIcon';
import { EdgeMedia } from '~/components/EdgeMedia/EdgeMedia';
import { getPlanDetails } from '~/components/Subscriptions/getPlanDetails';
@@ -33,10 +28,11 @@ import { PaymentProvider } from '~/shared/utils/prisma/enums';
import { useAvailableBuzz } from '~/components/Buzz/useAvailableBuzz';
import { useNextBuzzDelivery } from '~/hooks/useNextBuzzDelivery';
import { numberWithCommas } from '~/utils/number-helpers';
import { CardOrSection, UpsellPanel } from '~/components/Account/SettingsLayout';
import type { SubscriptionProductMetadata } from '~/server/schema/subscriptions.schema';
import type { BuzzSpendType } from '~/shared/constants/buzz.constants';
export function SubscriptionCard() {
export function SubscriptionCard({ flat }: { flat?: boolean } = {}) {
const [mainBuzzType] = useAvailableBuzz();
const otherBuzzType: BuzzSpendType = mainBuzzType === 'green' ? 'yellow' : 'green';
@@ -68,16 +64,43 @@ export function SubscriptionCard() {
if (subscription) rows.push({ sub: subscription, isCrossDomain: false });
if (otherSubscription) rows.push({ sub: otherSubscription, isCrossDomain: true });
// The legacy page hides the card entirely with no membership; the flat pane is a whole route, so
// hiding it leaves the section blank.
if (!isLoading && rows.length === 0) {
return null;
if (!flat) return null;
return (
<CardOrSection flat title="Membership" id="manage-subscription">
<UpsellPanel
icon={<IconUserCircle size={24} />}
title="No active membership"
description="You're on the free plan. A membership adds:"
perks={[
'A monthly Buzz allowance',
'Ad-free browsing',
'Exclusive Discord channels and early access',
]}
action={
<Button
component={Link}
href="/pricing"
variant="filled"
size="sm"
leftSection={<IconRosetteDiscountCheck size={16} />}
className="w-fit"
>
See membership plans
</Button>
}
/>
</CardOrSection>
);
}
return (
<Card withBorder>
<CardOrSection flat={flat} title="Membership" id="manage-subscription">
<Stack gap="md">
<Title id="manage-subscription" order={2}>
Membership
</Title>
{!flat && <Title order={2}>Membership</Title>}
{isLoading ? (
<Center p="xl">
<Loader />
@@ -100,7 +123,7 @@ export function SubscriptionCard() {
))
)}
</Stack>
</Card>
</CardOrSection>
);
}
@@ -209,7 +232,7 @@ function SubscriptionRow({
</Text>
</AlertWithIcon>
)}
<Group justify="space-between" wrap="nowrap" align="center" gap="sm">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
<Group wrap="nowrap" gap="sm" style={{ minWidth: 0, flex: 1 }}>
{image && (
<Box w={40} style={{ flexShrink: 0 }}>
@@ -236,7 +259,7 @@ function SubscriptionRow({
</Box>
</Group>
{priceText && (
<Group gap={6} wrap="nowrap">
<Group gap={6}>
<Text size="sm" c="dimmed" lh={1.2}>
{priceText}
</Text>
@@ -255,8 +278,8 @@ function SubscriptionRow({
)}
</Stack>
</Group>
{manageButton}
</Group>
<div className="shrink-0 self-end sm:self-auto">{manageButton}</div>
</div>
{nextBuzzDelivery && (
<Group gap={6} wrap="nowrap">
<Text size="xs" c="dimmed" lh={1.2}>
@@ -2,7 +2,6 @@ import type { ButtonProps } from '@mantine/core';
import {
Alert,
Button,
Card,
Center,
Checkbox,
Divider,
@@ -14,8 +13,9 @@ import {
Text,
Title,
} from '@mantine/core';
import { CardOrSection, UpsellPanel } from '~/components/Account/SettingsLayout';
import { trpc } from '../../utils/trpc';
import { IconExternalLink, IconInfoCircle } from '@tabler/icons-react';
import { IconExternalLink, IconInfoCircle, IconUserPlus, IconUsers } from '@tabler/icons-react';
import { CustomMarkdown } from '~/components/Markdown/CustomMarkdown';
import rehypeRaw from 'rehype-raw';
import { useState } from 'react';
@@ -30,6 +30,15 @@ import { LegacyActionIcon } from '~/components/LegacyActionIcon/LegacyActionIcon
const stripeConnectLoginUrl = 'https://connect.stripe.com/express_login';
const ProviderHeading = ({ flat, children }: { flat?: boolean; children: React.ReactNode }) =>
flat ? (
<Text size="sm" fw={500}>
{children}
</Text>
) : (
<Title order={3}>{children}</Title>
);
export const AcceptCodeOfConduct = ({ onAccepted }: { onAccepted: () => void }) => {
const dialog = useDialogContext();
const handleClose = dialog.onClose;
@@ -202,7 +211,7 @@ const FeatureIntroductionModal = dynamic(
() => import('~/components/FeatureIntroduction/FeatureIntroduction')
);
const StripeConnectConfigurationCard = () => {
const StripeConnectConfigurationCard = ({ flat }: { flat?: boolean }) => {
const { userPaymentConfiguration, isLoading } = useUserPaymentConfiguration();
if (!userPaymentConfiguration) return null;
@@ -211,10 +220,10 @@ const StripeConnectConfigurationCard = () => {
return (
<Stack>
<Group justify="space-between">
<Title order={3}>Stripe Connect</Title>
<ProviderHeading flat={flat}>Stripe Connect</ProviderHeading>
</Group>
<Text>
<Text size={flat ? 'xs' : undefined} c={flat ? 'dimmed' : undefined}>
We will no longer be supporting Stripe connect for payments. Please setup Tipalti in order
to receive payments.
</Text>
@@ -226,7 +235,7 @@ const StripeConnectConfigurationCard = () => {
<>
<Stack>
<Group justify="space-between">
<Title order={3}>Stripe Connect</Title>
<ProviderHeading flat={flat}>Stripe Connect</ProviderHeading>
<LegacyActionIcon
color="gray"
variant="subtle"
@@ -245,7 +254,7 @@ const StripeConnectConfigurationCard = () => {
</Group>
</Stack>
<Divider my="xl" />
{!flat && <Divider my="xl" />}
{isLoading ? (
<Center>
@@ -268,8 +277,9 @@ const StripeConnectConfigurationCard = () => {
);
};
const TipaltiConfigurationCard = () => {
const TipaltiConfigurationCard = ({ flat }: { flat?: boolean }) => {
const { userPaymentConfiguration } = useUserPaymentConfiguration();
const descProps = flat ? ({ size: 'xs', c: 'dimmed' } as const) : {};
if (!userPaymentConfiguration) return null;
@@ -277,14 +287,16 @@ const TipaltiConfigurationCard = () => {
return (
<Stack>
<Group justify="space-between">
<Title order={3}>Tipalti Account</Title>
<ProviderHeading flat={flat}>Tipalti</ProviderHeading>
</Group>
<Text>
<Text size={flat ? 'xs' : undefined} c={flat ? 'dimmed' : undefined}>
Tipalti is the new way to receive payments. We are slowly rolling invitations to Tipalti
to all creators. If you have not received an invitation yet, please be patient.
</Text>
<Text>A notification will be sent to you once you are invited to Tipalti.</Text>
<Text {...descProps}>
A notification will be sent to you once you are invited to Tipalti.
</Text>
</Stack>
);
}
@@ -293,17 +305,17 @@ const TipaltiConfigurationCard = () => {
<>
<Stack>
<Group justify="space-between">
<Title order={3}>Tipalti Account</Title>
<ProviderHeading flat={flat}>Tipalti</ProviderHeading>
</Group>
</Stack>
<Divider my="xs" />
{!flat && <Divider my="xs" />}
{userPaymentConfiguration?.tipaltiAccountStatus === TipaltiStatus.PendingOnboarding ||
userPaymentConfiguration?.tipaltiAccountStatus === TipaltiStatus.InternalValue ? (
<>
<Stack>
<Text>
<Text {...descProps}>
Your account requires setup. Click the button below to start/continue your setup
process.
</Text>
@@ -312,60 +324,99 @@ const TipaltiConfigurationCard = () => {
) : userPaymentConfiguration?.tipaltiAccountStatus === TipaltiStatus.Active ? (
<>
{userPaymentConfiguration?.tipaltiPaymentsEnabled ? (
<Text>
<Text {...descProps}>
Your account is set up and ready for withdrawals. Click below to make any adjustments
to your Tipalti account settings.
</Text>
) : (
<Stack>
<Text>
<Text {...descProps}>
Your account has been activated but you are still not able to withdraw. If you had a
failed payment, Tipalti will mark the account as not payable until you fix the
problem.
</Text>
<Text>
<Text {...descProps}>
If you have not had a failed payment, this might be due to document verification and
validation. You will be notified once this changes.
</Text>
<Text>If you think this is an error, please contact support.</Text>
<Text {...descProps}>If you think this is an error, please contact support.</Text>
</Stack>
)}
</>
) : (
<Text>
<Text {...descProps}>
We are unable to setup your account so that you can withdraw funds. You may contact
support if you think this is a mistake to get a better understanding of the issue.
</Text>
)}
<Divider my="xs" />
{!flat && <Divider my="xs" />}
{!isBlockedTipaltiStatus(userPaymentConfiguration?.tipaltiAccountStatus) && (
<Button
component="a"
href="/tipalti/setup"
target="_blank"
rel="nofollow noreferrer"
classNames={{ label: 'text-white' }}
fullWidth
>
Set up my Tipalti Account
</Button>
<Group justify={flat ? 'flex-end' : undefined}>
<Button
component="a"
href="/tipalti/setup"
target="_blank"
rel="nofollow noreferrer"
classNames={{ label: 'text-white' }}
size={flat ? 'compact-sm' : undefined}
fullWidth={!flat}
>
Set up my Tipalti Account
</Button>
</Group>
)}
</>
);
};
export function UserPaymentConfigurationCard() {
export function UserPaymentConfigurationCard({ flat }: { flat?: boolean } = {}) {
const { userPaymentConfiguration, isLoading } = useUserPaymentConfiguration();
// A payment configuration is created on joining the Creator Program, so its absence is the
// not-a-member case rather than an error. The legacy page drops the card; the flat pane keeps the
// section and says how to get one.
if (!isLoading && !userPaymentConfiguration) {
return null;
if (!flat) return null;
return (
<CardOrSection
flat
title="Payouts"
description="Where your earnings and withdrawals land."
id="payments"
>
<UpsellPanel
icon={<IconUsers size={24} />}
title="Creator Program members only"
description="Set up payouts once you join the Creator Program:"
perks={['Earn real cash from your creations', 'Withdraw your earnings to your bank']}
action={
<Button
component="a"
href="/creator-program"
variant="filled"
size="sm"
leftSection={<IconUserPlus size={16} />}
className="w-fit"
>
Join the Creator Program
</Button>
}
/>
</CardOrSection>
);
}
return (
<Card withBorder id="payments">
<CardOrSection
flat={flat}
title={flat ? 'Payouts' : undefined}
description={flat ? 'Where your earnings and withdrawals land.' : undefined}
id="payments"
>
{isLoading && (
<Stack>
<Loader />
@@ -373,11 +424,11 @@ export function UserPaymentConfigurationCard() {
)}
{userPaymentConfiguration?.stripeAccountId && (
<>
<StripeConnectConfigurationCard />
<StripeConnectConfigurationCard flat={flat} />
<Divider my="xl" />
</>
)}
<TipaltiConfigurationCard />
</Card>
<TipaltiConfigurationCard flat={flat} />
</CardOrSection>
);
}
+12 -4
View File
@@ -46,7 +46,8 @@ const reportCategories = [
label: 'Against',
color: 'red',
icon: IconFlag,
tooltip: 'Points deducted for content this user posted that was removed for Terms of Service violations',
tooltip:
'Points deducted for content this user posted that was removed for Terms of Service violations',
},
{
key: 'reportsActioned' as const,
@@ -60,13 +61,18 @@ const reportCategories = [
export function UserScoreDisplay({
scores,
showReports = false,
flat = false,
abbreviate = true,
}: {
scores: Scores | null | undefined;
showReports?: boolean;
/** Drop the panel chrome when the caller already provides it. */
flat?: boolean;
abbreviate?: boolean;
}) {
if (!scores) {
return (
<Paper withBorder p="md" radius="md">
<Paper withBorder={!flat} p={flat ? 0 : 'md'} radius="md">
<Text size="sm" c="dimmed" ta="center">
Score not yet available
</Text>
@@ -86,7 +92,7 @@ export function UserScoreDisplay({
const reportsAgainstScore = scores.reportsAgainst ?? 0;
return (
<Paper withBorder p="md" radius="md">
<Paper withBorder={!flat} p={flat ? 0 : 'md'} radius="md">
<Stack gap="md">
<Stack gap={4} align="center">
<Tooltip label={`${Math.round(total).toLocaleString()} points`} withArrow>
@@ -97,7 +103,9 @@ export function UserScoreDisplay({
lh={1.2}
style={{ cursor: 'help' }}
>
{abbreviateNumber(total, { decimals: 1 })}
{abbreviate
? abbreviateNumber(total, { decimals: 1 })
: Math.round(total).toLocaleString()}
</Text>
</Tooltip>
<Text size="sm" c="dimmed">
@@ -0,0 +1,115 @@
import fs from 'fs';
import path from 'path';
import { describe, expect, it } from 'vitest';
import {
accountSections,
getAccountSectionHref,
getOverviewHref,
legacyAnchorSections,
resolveAccountSection,
resolveLegacyAnchor,
searchAccountSections,
} from '~/components/Account/account-sections';
describe('account section registry', () => {
it('has unique ids and paths', () => {
const ids = accountSections.map((section) => section.id);
const paths = accountSections.map((section) => section.path);
expect(new Set(ids).size).toBe(ids.length);
expect(new Set(paths).size).toBe(paths.length);
});
it('resolves the index to Overview and unknown slugs to undefined', () => {
expect(resolveAccountSection(undefined)?.id).toBe('overview');
expect(resolveAccountSection('')?.id).toBe('overview');
expect(resolveAccountSection('billing')?.id).toBe('billing');
expect(resolveAccountSection('not-a-section')).toBeUndefined();
});
// Deleting the alias resolves `/user/account/overview` to a 404, not to the overview.
it('resolves the overview alias, and it is not the index href', () => {
expect(resolveAccountSection('overview')?.id).toBe('overview');
expect(getOverviewHref()).toBe('/user/account/overview');
expect(getAccountSectionHref(accountSections[0])).toBe('/user/account');
});
it('builds hrefs without a trailing slash on the index', () => {
expect(getAccountSectionHref(accountSections[0])).toBe('/user/account');
expect(getAccountSectionHref(accountSections[1])).toBe('/user/account/profile');
});
it('matches on keywords, not just labels', () => {
// "tipalti" appears in no section label; without keyword search this returns nothing.
expect(searchAccountSections('tipalti').map((s) => s.id)).toEqual(['billing']);
expect(searchAccountSections('hidden tags').map((s) => s.id)).toEqual(['content']);
expect(searchAccountSections('zzzz')).toEqual([]);
});
});
describe('legacy anchor redirects', () => {
const expected: Record<string, string> = {
'#payments': 'billing',
'#payment-methods': 'billing',
'#manage-subscription': 'billing',
'#strikes': 'profile',
'#creator-score': 'profile',
'#accounts': 'security',
'#api-keys': 'security',
'#notification-settings': 'notifications',
};
it.each(Object.entries(expected))('%s resolves to the %s pane', (hash, sectionId) => {
expect(resolveLegacyAnchor(hash)?.id).toBe(sectionId);
});
it('tolerates a missing # and mixed case', () => {
expect(resolveLegacyAnchor('payments')?.id).toBe('billing');
expect(resolveLegacyAnchor('#Payment-Methods')?.id).toBe('billing');
});
it('returns undefined for an unmapped anchor', () => {
expect(resolveLegacyAnchor('#nope')).toBeUndefined();
expect(resolveLegacyAnchor('')).toBeUndefined();
});
it('points every mapped anchor at a section that exists', () => {
for (const sectionId of Object.values(legacyAnchorSections)) {
expect(accountSections.some((section) => section.id === sectionId)).toBe(true);
}
});
/**
* The map is only load-bearing while it covers what the codebase actually emits. Stripe and
* Tipalti `return_url`s and already-delivered strike emails cannot be edited after the fact, so
* an anchor added here without a map entry lands on Overview and the user never reaches the
* thing the link promised.
*/
it('covers every /user/account#anchor emitted anywhere in src', () => {
const srcDir = path.resolve(__dirname, '../../..');
const found = new Set<string>();
const walk = (dir: string) => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
walk(full);
continue;
}
// Tests and mocks describe links rather than emit them.
if (!/\.(ts|tsx)$/.test(entry.name)) continue;
if (/\.(test|spec)\.tsx?$/.test(entry.name) || full.includes('__tests__')) continue;
const contents = fs.readFileSync(full, 'utf8');
for (const match of contents.matchAll(/\/user\/account(?:\?[^'"`#\s]*)?#([a-zA-Z-]+)/g)) {
found.add(match[1].toLowerCase());
}
}
};
walk(srcDir);
expect(found.size).toBeGreaterThan(0);
const uncovered = [...found].filter((anchor) => !(anchor in legacyAnchorSections));
expect(uncovered).toEqual([]);
});
});
+195
View File
@@ -0,0 +1,195 @@
import type { Icon } from '@tabler/icons-react';
import {
IconAdjustmentsHorizontal,
IconBell,
IconCreditCard,
IconEye,
IconLayoutDashboard,
IconPalette,
IconShieldLock,
IconUser,
} from '@tabler/icons-react';
export const accountSectionGroups = [
{ id: 'account', label: 'Account' },
{ id: 'content', label: 'Content' },
{ id: 'access', label: 'Billing & access' },
] as const;
export type AccountSectionGroupId = (typeof accountSectionGroups)[number]['id'];
export type AccountSection = {
id: string;
/** URL segment under `/user/account`. Empty string is the index. */
path: string;
label: string;
icon: Icon;
group: AccountSectionGroupId;
/**
* What the search box matches on. Labels alone only ever find the section you already
* know the name of, which is the case that needed no search.
*/
keywords: string[];
};
export const accountSections: AccountSection[] = [
{
id: 'overview',
path: '',
label: 'Overview',
icon: IconLayoutDashboard,
group: 'account',
keywords: ['membership', 'buzz', 'standing', 'email', 'summary'],
},
{
id: 'profile',
path: 'profile',
label: 'Profile & Account',
icon: IconUser,
group: 'account',
keywords: [
'username',
'email',
'social links',
'sponsorship',
'creator score',
'strikes',
'standing',
'delete account',
'refresh session',
],
},
{
id: 'preferences',
path: 'preferences',
label: 'Preferences',
icon: IconAdjustmentsHorizontal,
group: 'account',
keywords: [
'autoplay',
'gifs',
'image format',
'model format',
'precision',
'quant',
'assistant',
'civbot',
'chats',
'blue buzz',
'early adopter',
'video controls',
'advanced mode',
'air',
],
},
{
id: 'notifications',
path: 'notifications',
label: 'Notifications',
icon: IconBell,
group: 'account',
keywords: ['email notifications', 'on-site', 'comments', 'milestones', 'buzz', 'moderation'],
},
{
id: 'content',
path: 'content',
label: 'Content & Browsing',
icon: IconEye,
group: 'content',
keywords: ['mature', 'nsfw', 'browsing level', 'blur', 'hidden tags', 'hidden users'],
},
{
id: 'creator',
path: 'creator',
label: 'Creator',
icon: IconPalette,
group: 'content',
keywords: [
'stickers',
'placement',
'remix gallery',
'donation goals',
'download count',
'generation count',
'earned buzz',
],
},
{
id: 'billing',
path: 'billing',
label: 'Membership & Billing',
icon: IconCreditCard,
group: 'access',
keywords: [
'subscription',
'membership',
'payment methods',
'cards',
'payouts',
'stripe',
'tipalti',
'gift',
],
},
{
id: 'security',
path: 'security',
label: 'Security & Apps',
icon: IconShieldLock,
group: 'access',
keywords: ['sign in', 'connected accounts', 'api keys', 'oauth', 'connected apps'],
},
];
export function searchAccountSections(query: string) {
const trimmed = query.trim().toLowerCase();
if (!trimmed) return accountSections;
return accountSections.filter(
(section) =>
section.label.toLowerCase().includes(trimmed) ||
section.keywords.some((keyword) => keyword.includes(trimmed))
);
}
export const defaultAccountSection = accountSections[0];
/** On mobile the index renders the section MENU, so the overview needs a URL of its own. */
export const overviewSectionPath = 'overview';
export function getAccountSectionHref(section: AccountSection) {
return section.path ? `/user/account/${section.path}` : '/user/account';
}
export function getOverviewHref() {
return `/user/account/${overviewSectionPath}`;
}
export function resolveAccountSection(path: string | undefined) {
if (!path) return defaultAccountSection;
if (path === overviewSectionPath) return defaultAccountSection;
return accountSections.find((section) => section.path === path);
}
/**
* Anchors that already point into this page from places we cannot edit Stripe and Tipalti
* `return_url`s stored provider-side, and strike emails already delivered. A fragment never
* reaches the server, so these cannot be handled by a `next.config` redirect; the shell maps
* them on mount instead. Removing an entry silently strands whatever still emits it.
*/
export const legacyAnchorSections: Record<string, string> = {
accounts: 'security',
'api-keys': 'security',
'creator-score': 'profile',
'manage-subscription': 'billing',
'notification-settings': 'notifications',
'payment-methods': 'billing',
payments: 'billing',
strikes: 'profile',
};
export function resolveLegacyAnchor(hash: string) {
const key = hash.replace(/^#/, '').toLowerCase();
const sectionId = legacyAnchorSections[key];
if (!sectionId) return undefined;
return accountSections.find((section) => section.id === sectionId);
}
+3
View File
@@ -205,6 +205,9 @@ export function SubNav({
return (
<div
{...props}
// Read by anything that pins itself below the subnav: it keeps its layout box while hidden,
// so a fixed offset leaves a gap once it retracts. See `useStickyTop` in AccountLayout.
data-subnav=""
className={clsx(
'sticky inset-x-0 top-0 z-50 mb-3 bg-gray-1 shadow transition-transform dark:bg-dark-6',
className
@@ -1,9 +1,8 @@
import { Text, Stack, Checkbox } from '@mantine/core';
import { Chip, Group } from '@mantine/core';
import { useQueryHiddenPreferences, useToggleHiddenPreferences } from '~/hooks/hidden-preferences';
import { toggleableBrowsingCategories } from '~/shared/constants/browsingLevel.constants';
export function BrowsingCategories() {
// const { classes, cx } = useStyles();
const { data, isLoading } = useQueryHiddenPreferences();
const toggleHiddenTagsMutation = useToggleHiddenPreferences();
@@ -14,54 +13,25 @@ export function BrowsingCategories() {
};
return (
<Stack>
<Group gap="xs">
{toggleableBrowsingCategories.map((category) => {
const checked = category.relatedTags.every((tag) =>
data.hiddenTags.find((hidden) => hidden.id === tag.id)
);
return (
<Checkbox
<Chip
key={category.title}
size="sm"
radius="sm"
checked={checked}
onChange={(e) => toggle(e.target.checked, category.relatedTags)}
disabled={isLoading}
label={
<Text size="sm" fw={500}>
{category.title}
</Text>
}
/>
onChange={(value) => toggle(value, category.relatedTags)}
>
{category.title}
</Chip>
);
})}
</Stack>
</Group>
);
// return (
// <Paper p={0} className={classes.root} withBorder>
// {toggleableBrowsingCategories.map((category) => {
// const checked = category.relatedTags.every((tag) =>
// data.hiddenTags.find((hidden) => hidden.id === tag.id)
// );
// return (
// <Group
// justify="space-between"
// key={category.title}
// className={cx({ [classes.active]: checked })}
// py="sm"
// px="md"
// onClick={() => toggle(!checked, category.relatedTags)}
// >
// <Text fw={500}>{category.title}</Text>
// <Switch
// checked={checked}
// onChange={(e) => toggle(e.target.checked, category.relatedTags)}
// disabled={isLoading}
// />
// </Group>
// );
// })}
// </Paper>
// );
}
@@ -15,6 +15,7 @@ import {
} from '@mantine/core';
import { useEffect, useState } from 'react';
import { InfoPopover } from '~/components/InfoPopover/InfoPopover';
import { SettingsSection } from '~/components/Account/SettingsLayout';
import { PlacementFreeSlotSlider } from '~/components/Placement/PlacementFreeSlotSlider';
import { PlacementPriceSlider } from '~/components/Placement/PlacementPriceSlider';
import { useCurrentUser } from '~/hooks/useCurrentUser';
@@ -35,7 +36,7 @@ import { trpc } from '~/utils/trpc';
* There is no "accept all" here. A sticker comes from a moderated catalog; a
* remix gallery accepts arbitrary user media, so every submission is reviewed.
*/
export function RemixGallerySettings() {
export function RemixGallerySettings({ flat }: { flat?: boolean } = {}) {
const features = useFeatureFlags();
const currentUser = useCurrentUser();
const utils = trpc.useUtils();
@@ -122,23 +123,21 @@ export function RemixGallerySettings() {
price: nextPrice === '' ? null : nextPrice,
});
return (
<>
<Divider
label={
<Group gap={4} wrap="nowrap">
Remix galleries on your images
<InfoPopover size="xs" iconProps={{ size: 14 }} width={340}>
<Text size="sm" maw={320} style={{ whiteSpace: 'normal' }}>
Let other people pay to feature their remixes on your work. You review every
submission and decide what a remix means on your own images. Turning this on does
not share your prompt hidden prompts stay hidden.
</Text>
</InfoPopover>
</Group>
}
/>
const heading = (
<Group gap={4} wrap="nowrap">
Remix galleries on your images
<InfoPopover size="xs" iconProps={{ size: 14 }} width={340}>
<Text size="sm" maw={320} style={{ whiteSpace: 'normal' }}>
Let other people pay to feature their remixes on your work. You review every submission
and decide what a remix means on your own images. Turning this on does not share your
prompt hidden prompts stay hidden.
</Text>
</InfoPopover>
</Group>
);
const body = (
<>
<SegmentedControl
value={mode}
onChange={(value) => {
@@ -304,4 +303,18 @@ export function RemixGallerySettings() {
otherwise would tell a creator to fix something that is working. */}
</>
);
if (flat)
return (
<SettingsSection title={heading}>
<div className="flex flex-col gap-4">{body}</div>
</SettingsSection>
);
return (
<>
<Divider label={heading} />
{body}
</>
);
}
-104
View File
@@ -1,104 +0,0 @@
import { Container, Stack, Title, Text } from '@mantine/core';
import React from 'react';
import { AccountsCard } from '~/components/Account/AccountsCard';
import { ApiKeysCard } from '~/components/Account/ApiKeysCard';
import { OAuthAppsCard } from '~/components/Account/OAuthAppsCard';
import { ConnectedAppsCard } from '~/components/Account/ConnectedAppsCard';
import { SocialProfileCard } from '~/components/Account/SocialProfileCard';
import { DeleteCard } from '~/components/Account/DeleteCard';
import { ProfileCard } from '~/components/Account/ProfileCard';
import { SettingsCard } from '~/components/Account/SettingsCard';
import { MembershipGiftsCard } from '~/components/Account/MembershipGiftsCard';
import { SubscriptionCard } from '~/components/Account/SubscriptionCard';
import { Meta } from '~/components/Meta/Meta';
import { useFeatureFlags } from '~/providers/FeatureFlagsProvider';
import { createServerSideProps } from '~/server/utils/server-side-helpers';
import { ModerationCard } from '~/components/Account/ModerationCard';
import { useCurrentUser } from '~/hooks/useCurrentUser';
import { PaymentMethodsCard } from '~/components/Account/PaymentMethodsCard';
import { UserPaymentConfigurationCard } from '~/components/Account/UserPaymentConfigurationCard';
import { ContentControlsCard } from '~/components/Account/ContentControlsCard';
import { CreatorControlsCard } from '~/components/Account/CreatorControlsCard';
import { RefreshSessionCard } from '~/components/Account/RefreshSessionCard';
import { StrikesCard } from '~/components/Account/StrikesCard';
import { StickerInventoryCard } from '~/components/Account/StickerInventoryCard';
import { GenerationSettingsCard } from '~/components/Account/GenerationSettingsCard';
import dynamic from 'next/dynamic';
const NotificationsCard = dynamic(() => import('~/components/Account/NotificationsCard'));
export default function Account() {
const features = useFeatureFlags();
const currentUser = useCurrentUser();
return (
<>
<Meta title="Manage your Account - Civitai" deIndex />
<Container pb="md" size="xs">
<Stack>
<Stack gap={0}>
<Title order={1}>Manage Account</Title>
<Text c="dimmed" size="sm">
Take a moment to review your account information and preferences to personalize your
experience on the site
</Text>
</Stack>
<ProfileCard />
<SocialProfileCard />
<SettingsCard />
<ContentControlsCard />
<GenerationSettingsCard />
{features.canViewNsfw && <ModerationCard />}
{/* Any one flag mounts the card; each section gates itself inside.
ANDing them here put the sticker controls behind `creator-controls`,
which is dark by default and whose Flipt flag does not exist so
nobody could price their space, and the review queue nothing else
links to became unreachable. The same omission hid remix galleries
entirely: the settings section was shipped, mounted, and gated by a
flag this condition did not mention. */}
{(features.creatorControls || features.stickerPlacement || features.remixGallery) && (
<CreatorControlsCard />
)}
<StickerInventoryCard />
<AccountsCard />
<UserPaymentConfigurationCard />
{/* No `subscriptionId` guard: a Buzz-purchased membership writes a
CustomerSubscription row but never sets User.subscriptionId, so gating here
kept the card unmounted no matter what the query returned. The card already
self-hides when it resolves no subscriptions. */}
<SubscriptionCard />
<MembershipGiftsCard />
<PaymentMethodsCard />
{/* {buzz && <UserReferralCodesCard />} */}
<NotificationsCard />
{features.apiKeys && <ApiKeysCard />}
{features.oauthApps && <OAuthAppsCard />}
{features.oauthApps && <ConnectedAppsCard />}
{features.strikes && <StrikesCard />}
<RefreshSessionCard />
<DeleteCard />
</Stack>
</Container>
</>
);
}
export const getServerSideProps = createServerSideProps({
useSSG: true,
useSession: true,
resolver: async ({ ssg, session }) => {
if (!session?.user || session.user.bannedAt)
return {
redirect: {
destination: '/',
permanent: false,
},
};
await ssg?.account.getAll.prefetch();
if (session?.user?.subscriptionId) await ssg?.subscriptions.getUserSubscription.prefetch();
},
});
+68
View File
@@ -0,0 +1,68 @@
import { useRouter } from 'next/router';
import React, { useEffect } from 'react';
import { AccountLayout } from '~/components/Account/AccountLayout';
import { AccountPane, accountPaneCopy } from '~/components/Account/AccountPanes';
import { LegacyAccountPage } from '~/components/Account/LegacyAccountPage';
import { resolveAccountSection } from '~/components/Account/account-sections';
import { Meta } from '~/components/Meta/Meta';
import { NotFound } from '~/components/AppLayout/NotFound';
import { useFeatureFlags } from '~/providers/FeatureFlagsProvider';
import { createServerSideProps } from '~/server/utils/server-side-helpers';
export default function Account() {
const router = useRouter();
const features = useFeatureFlags();
const segments = router.query.section;
const slug = Array.isArray(segments) ? segments[0] : segments;
// A sub-path only means anything while the shell is on. After a rollback, bookmarks made during
// the ramp would otherwise render the whole legacy page under a URL promising one section.
useEffect(() => {
if (features.accountSettingsV2 || !router.isReady || !slug) return;
router.replace('/user/account');
}, [features.accountSettingsV2, router.isReady, slug, router]);
const content = () => {
if (!features.accountSettingsV2) return <LegacyAccountPage />;
// A second segment means a URL this shell has no pane for; treating it as the parent
// section would render the wrong thing under a URL the user can bookmark.
if (Array.isArray(segments) && segments.length > 1) return <NotFound />;
const section = resolveAccountSection(slug);
if (!section) return <NotFound />;
const copy = accountPaneCopy[section.id];
return (
<AccountLayout section={section} title={copy.title} isIndex={!slug}>
<AccountPane sectionId={section.id} />
</AccountLayout>
);
};
return (
<>
<Meta title="Manage your Account - Civitai" deIndex />
{content()}
</>
);
}
export const getServerSideProps = createServerSideProps({
useSSG: true,
useSession: true,
resolver: async ({ ssg, session }) => {
if (!session?.user || session.user.bannedAt)
return {
redirect: {
destination: '/',
permanent: false,
},
};
await ssg?.account.getAll.prefetch();
if (session?.user?.subscriptionId) await ssg?.subscriptions.getUserSubscription.prefetch();
},
});
+15
View File
@@ -9,6 +9,21 @@ export const strikeStatusColorScheme: Record<string, MantineColor> = {
Voided: 'yellow',
};
/**
* Derived from active strike POINTS, not the strike count a single strike can carry several.
*/
export function accountStandingFromPoints(points: number): {
label: string;
/** No "standing" noun, for a caption that already carries it. */
short: string;
color: MantineColor;
good: boolean;
} {
if (points === 0) return { label: 'Good standing', short: 'Good', color: 'green', good: true };
if (points === 1) return { label: 'Warning', short: 'Warning', color: 'yellow', good: false };
return { label: 'Restricted', short: 'Restricted', color: 'red', good: false };
}
/**
* Sanitized, user-facing label per StrikeReason. Mirrors `publicBanReasonLabel`
* in `banReasonDetails`: this is the only reason text emailed to the user (the
@@ -121,6 +121,14 @@ const featureFlags = createFeatureFlags({
// the A/B (no flag-off cohort) and shipping the deferral fleet-wide unmeasured. OFF =
// byte-identical to today. Measured via RUM `exp_gen_tab_defer_view`. Instant safe rollback.
genTabDeferView: { availability: [], fliptKey: 'gen-tab-defer-view' },
// The two-pane /user/account shell. `['mod']` is the staging cohort while it bakes — it is the
// static fallback only, so it decides nothing while Flipt answers; the `account-settings-v2`
// rollout is still the on-switch, and OFF serves the legacy single-column page byte-identically.
// NOT `['public']`: that reads true whenever the Flipt key is missing or Flipt is unreachable,
// which would cut every user over during an outage — and this page is the landing target for
// Stripe and Tipalti `return_url`s, so a bad cutover strands payout onboarding rather than merely
// looking wrong. Instant rollback = set the threshold to 0.
accountSettingsV2: { availability: ['mod'], fliptKey: 'account-settings-v2' },
// Serialize-perf: LAZY per-post image load on `image.getImagesAsPostsInfinite` (the #2
// producer of oversized/event-loop-freezing tRPC responses). Model galleries carry
// multi-image showcase posts (17% have >12 images; p90/p99 ≈ 20). When ON the server
@@ -123,6 +123,7 @@ const pathnamesTokens = [
'/user/[username]/collections',
'/user/[username]',
'/user/account',
'/user/account/[[...section]]',
'/games/knights-of-new-order',
'/user/earn-potential',
'/user/[username]/manage-categories',
+1 -1
View File
@@ -1,7 +1,7 @@
type UserMetaScores = { scores?: { total?: number } };
/**
* The Creator Score, as `/user/account` displays it `User.meta.scores.total`, the sum of the six
* The Creator Score, as the account Profile pane displays it `User.meta.scores.total`, the sum of the six
* per-category scores the nightly job writes.
*
* Read it through here rather than reaching for a per-category score. Every gate that says "creator