mirror of
https://github.com/civitai/civitai.git
synced 2026-09-20 22:08:18 +08:00
169e918a8c
## What Completes the `/user/account` redesign behind the `accountSettingsV2` flag. The earlier commits on this branch built the two-pane shell and converted three panes; this finishes the other five, then polishes the result against the Pencil canvas (`designs/user-account.pen`). **OFF serves the legacy single-column page byte-identically.** See "Rollout" below. ## Panes | Pane | Change | |---|---| | Overview | Tier badge art in the Membership tile (links `/user/membership`); Standing replaces the creator-score figure; username renders its nameplate + badge cosmetics; identity card stacks on mobile | | Profile & Account | `ProfileCard` / `SocialProfileCard` flattened; Account standing shows the exact score; session refresh and delete are pointer rows, grouped | | Preferences | Regrouped to Media playback / Generation / File preferences / Features; image format moved to File preferences; assistant folded into Features | | Content & Browsing | Eye callout; mature-content rows; Topics as chips; hidden tags/users flattened | | Creator | Placement, remix and metric-visibility sections; sticker inventory pointer moved inside Stickers | | Membership & Billing | Subscription / payment methods / payouts flattened; gifts point at `/pricing/gift`; membership row stacks on mobile; empty states when the user has neither a membership nor a Creator Program payout config | | Security & Apps | Sign-in methods, API keys, OAuth apps, connected apps flattened; create buttons on the section heading | | Notifications | Delivery section; per-category icons; more room in an open category; `Other` sorted last | ## Decisions worth a reviewer's attention - **Cards take a `flat` prop rather than being forked.** The legacy page mounts the same components while the flag is alive; two copies of a settings form is how one of them silently loses a field. - **One rule per section.** Eight rows had nine dividers and read as a table. Rows are spaced instead. - **`/user/account/overview` is a new URL.** On mobile the index renders the section *menu*, so an overview reachable only at the index has no way in. `AccountLayout` takes `isIndex` from the route now; inferring it from `section.path` rendered the menu at both URLs. Covered by a test — deleting the alias 404s that URL. - **Standing thresholds moved to `accountStandingFromPoints`** (`strike.schema.ts`). Two surfaces show standing and it derives from active *points*, not the strike count. - **The sticker-inventory pointer survives its host section's bail paths.** It is not gated on placement, so nesting it inside that section would drop it whenever the placement controls cannot render (flag off, or a failed spaces read). - **`BrowsingCategories` switched to chips outright**, including the legacy card, rather than growing a variant prop — one rendering, no fork. - **First use of a Tailwind `has-[…]` variant in this repo** (`SettingRow`, to keep switch rows inline at every width). Tailwind is 3.4.17, so it is supported. - **Billing empty states are `flat`-only.** `SubscriptionCard` and `UserPaymentConfigurationCard` both returned `null` with nothing to show, which left the whole pane blank. They now offer the plans / the Creator Program instead — but only in the flat panes, so the legacy page keeps hiding them and stays byte-identical. Both reuse the metric-visibility upsell, extracted as `UpsellPanel`. ## Verification Typecheck clean; no new lint warnings. Covering suites green: `account-sections` (17), `strike.service` + `process-strikes` (75), the four Account browser suites (24), and the notification suites (79). `SettingsCard.earlyAdopter.browser` needed one assertion updated — it pinned the literal early-adopter copy. Kept its intent (the opt-in must explain itself) and split it into the promise and the caveat rather than loosening it. Walked every pane at 1440px and 390px as a subscribed Creator Program account, and the billing/notification changes on a free account with no Creator Program. ## Not in this PR - `designs/user-account.pen` has uncommitted local changes that predate this work; left alone deliberately. ## Rollout `accountSettingsV2` → Flipt key `account-settings-v2`. `availability: ['mod']` is the STATIC FALLBACK only — it decides nothing while Flipt answers, so it matters solely during a Flipt outage, where mods get the new shell and everyone else keeps the legacy page. The Flipt rollout is the on-switch: `account-settings-v2` is `enabled: false` with no rollouts today, so the page is off for everyone until a segment or threshold rollout is merged in `flipt-state`. Instant rollback = drop that rollout / set the threshold to 0. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_013NnY26APwddt5dySmmubkZ Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013NnY26APwddt5dySmmubkZ
243 lines
6.8 KiB
TypeScript
243 lines
6.8 KiB
TypeScript
import {
|
|
Alert,
|
|
Button,
|
|
Card,
|
|
Grid,
|
|
Group,
|
|
Stack,
|
|
Title,
|
|
Text,
|
|
TextInput,
|
|
Popover,
|
|
Modal,
|
|
} from '@mantine/core';
|
|
import { IconPencilMinus, IconInfoSquareRounded, IconMail } from '@tabler/icons-react';
|
|
import { useDisclosure } from '@mantine/hooks';
|
|
import * as z from 'zod';
|
|
|
|
import { useSession } from '~/providers/SessionProvider';
|
|
import { useCurrentUser } from '~/hooks/useCurrentUser';
|
|
import { Form, InputText, useForm } from '~/libs/form';
|
|
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(),
|
|
username: usernameInputSchema,
|
|
});
|
|
|
|
const emailChangeSchema = z.object({
|
|
newEmail: z.string().email('Please enter a valid email address'),
|
|
});
|
|
|
|
export function ProfileCard({ flat }: { flat?: boolean } = {}) {
|
|
const queryUtils = trpc.useUtils();
|
|
const session = useCurrentUser();
|
|
const { data } = useSession();
|
|
const [emailModalOpened, { open: openEmailModal, close: closeEmailModal }] = useDisclosure();
|
|
|
|
const currentUser = data?.user;
|
|
|
|
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 });
|
|
await queryUtils.userProfile.get.invalidate();
|
|
await session?.refresh();
|
|
},
|
|
});
|
|
|
|
const {
|
|
mutate: requestEmailChange,
|
|
isPending: isEmailChangeLoading,
|
|
error: emailChangeError,
|
|
} = trpc.user.requestEmailChange.useMutation({
|
|
onSuccess: () => {
|
|
showSuccessNotification({
|
|
message:
|
|
'Verification email sent! Please check your inbox and click the verification link.',
|
|
});
|
|
closeEmailModal();
|
|
emailForm.reset();
|
|
},
|
|
});
|
|
|
|
const form = useForm({
|
|
schema,
|
|
mode: 'onChange',
|
|
defaultValues: {
|
|
...data?.user,
|
|
},
|
|
shouldUnregister: false,
|
|
});
|
|
|
|
const emailForm = useForm({
|
|
schema: emailChangeSchema,
|
|
mode: 'onChange',
|
|
});
|
|
|
|
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
|
|
leftSection={<IconPencilMinus size={16} />}
|
|
onClick={() => {
|
|
openUserProfileEditModal();
|
|
}}
|
|
style={{ fontSize: 14, fontWeight: 600, lineHeight: 1.5 }}
|
|
size="compact-sm"
|
|
>
|
|
Customize profile
|
|
</Button>
|
|
</Group>
|
|
)}
|
|
{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 />
|
|
</Grid.Col>
|
|
<Grid.Col span={12}>
|
|
<Stack gap="xs">
|
|
<Group gap="sm">
|
|
<Text className="font-medium" size="sm">
|
|
Account Email
|
|
</Text>
|
|
<Button
|
|
variant="subtle"
|
|
size="compact-xs"
|
|
leftSection={<IconMail size={14} />}
|
|
onClick={openEmailModal}
|
|
>
|
|
Change Email
|
|
</Button>
|
|
</Group>
|
|
<TextInput
|
|
value={currentUser?.email ?? ''}
|
|
disabled
|
|
styles={{
|
|
root: { flex: 1 },
|
|
}}
|
|
/>
|
|
</Stack>
|
|
</Grid.Col>
|
|
<Grid.Col span={12}>
|
|
<Button
|
|
type="submit"
|
|
loading={isLoading}
|
|
disabled={!form.formState.isDirty}
|
|
fullWidth
|
|
>
|
|
Save
|
|
</Button>
|
|
</Grid.Col>
|
|
</Grid>
|
|
)}
|
|
{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
|
|
opened={emailModalOpened}
|
|
onClose={closeEmailModal}
|
|
title="Change Email Address"
|
|
size="md"
|
|
>
|
|
<Form
|
|
form={emailForm}
|
|
onSubmit={(data) => {
|
|
requestEmailChange({ newEmail: data.newEmail });
|
|
}}
|
|
>
|
|
<Stack>
|
|
{emailChangeError && (
|
|
<Alert color="red" variant="light">
|
|
{emailChangeError.message}
|
|
</Alert>
|
|
)}
|
|
<Text size="sm" c="dimmed">
|
|
Enter your new email address. We’ll send you a verification link to confirm the
|
|
change. Verification codes expire in 15 minutes.
|
|
</Text>
|
|
<InputText
|
|
name="newEmail"
|
|
label="New Email Address"
|
|
placeholder="Enter your new email"
|
|
required
|
|
/>
|
|
<Group justify="flex-end" gap="sm">
|
|
<Button variant="outline" onClick={closeEmailModal}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
type="submit"
|
|
loading={isEmailChangeLoading}
|
|
disabled={!emailForm.formState.isValid}
|
|
>
|
|
Send Verification Email
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
</Form>
|
|
</Modal>
|
|
</Card>
|
|
);
|
|
}
|