Ctrl K
ComunidadGuías…

src/features/account/profile-page.tsx

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { Link } from '@tanstack/react-router'
import { AlignLeft, ChevronsUpDown, Globe2, LockKeyhole, Mail, MapPin, Plus, Trash2, UserRound } from 'lucide-react'
import { type FormEvent, useEffect, useState } from 'react'
import { toast } from 'sonner'
import { Field, SelectInput, TextArea, TextInput } from '@/components/product-form'
import { Button } from '@/components/ui/button'
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import {
  changeAccountPassword,
  confirmAccountEmailChange,
  requestAccountEmailChange,
  updateAccountProfile,
  uploadAccountAvatar,
} from '@/lib/api/account'
import { i18n, normalizeLanguage, type SupportedLanguage, tt } from '@/lib/i18n'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from './account-drawer'
import { AccountSettingsNavigation } from './account-navigation'
import { AccountPageHeader, AccountRow, AccountRows } from './account-page'
import { useAccountCenterLayout } from './account-surface'
import { IdentityMark } from './identity-mark'
import { SettingsAction, UnavailableSection } from './primitives'
import { ProfileDialogs } from './profile-dialogs'
import { accountQueryKeys, useAccountMutation, useLinkedAccounts } from './queries'
import { defaultAccountCenterSettings } from './settings'
import type { MutationHandler, UserProfile } from './types'
import { accountTimeZones, readAccountTimeZone, saveAccountTimeZone } from './utils'

export function AccountProfilePage() {
  const { accountCenter, profile } = useAccountCenterLayout()
  const mutate = useAccountMutation()
  const [action, setAction] = useState<'language' | 'timezone' | null>(null)
  const [language, setLanguage] = useState<SupportedLanguage>(() => normalizeLanguage(i18n.language))
  const [timezone, setTimezone] = useState(readAccountTimeZone)
  return (
    <>
      <AccountPageHeader description={tt('Manage your profile and sign-in security.')} title={tt('Account settings')} />
      <AccountSettingsNavigation section="profile" />
      <div className="accountProfileSettings">
        <section className="accountSettingsSurface">
          {profile && accountCenter.profileEditingEnabled ? (
            <ProfileSections accountCenter={accountCenter} profile={profile} mutate={mutate} />
          ) : (
            <UnavailableSection message={tt('Profile editing is disabled for this account center.')} />
          )}
        </section>
        <details className="accountSecondarySettings">
          <summary>{tt('Preferences')}</summary>
          <AccountRows>
            <AccountRow
              action={
                <Button onClick={() => setAction('language')} size="sm" variant="outline">
                  {tt('Change')}
                </Button>
              }
              label={tt('Language')}
              value={language === 'zh' ? tt('Simplified Chinese') : tt('English')}
            />
            <AccountRow
              action={
                <Button onClick={() => setAction('timezone')} size="sm" variant="outline">
                  {tt('Change')}
                </Button>
              }
              label={tt('Time zone')}
              value={timezone}
            />
          </AccountRows>
        </details>
      </div>
      <ProfileActionDialog
        action={action}
        language={language}
        onClose={() => setAction(null)}
        onLanguageChange={setLanguage}
        onTimezoneChange={setTimezone}
        timezone={timezone}
      />
    </>
  )
}

function ProfileActionDialog({
  action,
  language,
  onClose,
  onLanguageChange,
  onTimezoneChange,
  timezone,
}: {
  action: 'language' | 'timezone' | null
  language: SupportedLanguage
  onClose: () => void
  onLanguageChange: (value: SupportedLanguage) => void
  onTimezoneChange: (value: string) => void
  timezone: string
}) {
  const [draftLanguage, setDraftLanguage] = useState(language)
  const [draftTimezone, setDraftTimezone] = useState(timezone)
  useEffect(() => {
    if (!action) return
    setDraftLanguage(language)
    setDraftTimezone(timezone)
  }, [action, language, timezone])
  const title = action === 'language' ? tt('Change language') : tt('Change time zone')
  async function save() {
    if (action === 'language') {
      await i18n.changeLanguage(draftLanguage)
      onLanguageChange(draftLanguage)
    }
    if (action === 'timezone') {
      saveAccountTimeZone(draftTimezone)
      onTimezoneChange(draftTimezone)
    }
    toast.success(tt('Preferences updated'))
    onClose()
  }
  return (
    <Dialog onOpenChange={(open) => !open && onClose()} open={action !== null}>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>{title}</DialogTitle>
          <DialogDescription>
            {tt('Choose how dates, times, and interface copy are presented in this browser.')}
          </DialogDescription>
        </DialogHeader>
        <div className="py-4">
          {action === 'language' ? (
            <Field label={tt('Language')}>
              <SelectInput
                name="language"
                onChange={(event) => setDraftLanguage(event.target.value as SupportedLanguage)}
                value={draftLanguage}
              >
                <option value="en">English</option>
                <option value="zh">简体中文</option>
              </SelectInput>
            </Field>
          ) : null}
          {action === 'timezone' ? <TimeZonePicker onChange={setDraftTimezone} value={draftTimezone} /> : null}
        </div>
        <DialogFooter>
          <Button onClick={onClose} variant="outline">
            {tt('Cancel')}
          </Button>
          <Button onClick={() => void save()}>{tt('Save')}</Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  )
}

function TimeZonePicker({ onChange, value }: { onChange: (value: string) => void; value: string }) {
  const [open, setOpen] = useState(false)
  return (
    <Field label={tt('Time zone')}>
      <Popover onOpenChange={setOpen} open={open}>
        <PopoverTrigger asChild>
          <Button
            aria-label={tt('Time zone')}
            aria-expanded={open}
            className="w-full justify-between font-normal"
            role="combobox"
            variant="outline"
          >
            <span className="truncate">{value}</span>
            <ChevronsUpDown className="size-4 shrink-0 opacity-50" />
          </Button>
        </PopoverTrigger>
        <PopoverContent align="start" className="w-[var(--radix-popover-trigger-width)] p-0">
          <Command>
            <CommandInput placeholder={tt('Search time zones…')} />
            <CommandList>
              <CommandEmpty>{tt('No matching time zones.')}</CommandEmpty>
              <CommandGroup>
                {accountTimeZones.map((option) => (
                  <CommandItem
                    data-checked={option === value}
                    key={option}
                    onSelect={() => {
                      onChange(option)
                      setOpen(false)
                    }}
                    value={option}
                  >
                    {option}
                  </CommandItem>
                ))}
              </CommandGroup>
            </CommandList>
          </Command>
        </PopoverContent>
      </Popover>
    </Field>
  )
}

export function ProfilePasswordPanel({ profile }: { profile: UserProfile }) {
  const mutate = useAccountMutation()
  const accountCenter = defaultAccountCenterSettings
  return <ProfileSections accountCenter={accountCenter} mode="password" profile={profile} mutate={mutate} />
}

function ProfileSections({
  accountCenter,
  mode = 'profile-account',
  profile,
  mutate,
}: {
  accountCenter: typeof defaultAccountCenterSettings
  mode?: 'profile-account' | 'password'
  profile: UserProfile
  mutate: MutationHandler
}) {
  const [dialog, setDialog] = useState<'avatar' | 'displayName' | 'username' | 'email' | 'password' | null>(null)
  const [publicProfileOpen, setPublicProfileOpen] = useState(false)
  const [displayName, setDisplayName] = useState(profile.displayName)
  const [username, setUsername] = useState(profile.username ?? '')
  const [avatarAssetId, setAvatarAssetId] = useState(profile.avatarAssetId ?? '')
  const [avatarPreview, setAvatarPreview] = useState(profile.image ?? '')
  const [email, setEmail] = useState(profile.email)
  const [bio, setBio] = useState(profile.bio ?? '')
  const [location, setLocation] = useState(profile.location ?? '')
  const [links, setLinks] = useState(() => publicProfileLinkDrafts(profile.links))
  const [emailOtp, setEmailOtp] = useState('')
  const [emailStep, setEmailStep] = useState<'request' | 'confirm'>('request')
  const [currentPassword, setCurrentPassword] = useState('')
  const [newPassword, setNewPassword] = useState('')
  const [confirmPassword, setConfirmPassword] = useState('')
  const [passwordError, setPasswordError] = useState<string | null>(null)
  const [revokeSessions, setRevokeSessions] = useState(true)
  const linkedAccountsQuery = useLinkedAccounts(publicProfileOpen && accountCenter.connectedAccountsEnabled)
  const projectableAccounts = (linkedAccountsQuery.data?.items ?? []).filter(
    (account) => account.providerId !== 'credential',
  )
  const availableAccounts = projectableAccounts.filter(
    (account) => !links.some((link) => link.type === 'linked-account' && link.accountId === account.id),
  )
  useEffect(() => {
    setDisplayName(profile.displayName)
    setUsername(profile.username ?? '')
    setAvatarAssetId(profile.avatarAssetId ?? '')
    setAvatarPreview(profile.image ?? '')
    setEmail(profile.email)
    setBio(profile.bio ?? '')
    setLocation(profile.location ?? '')
    setLinks(publicProfileLinkDrafts(profile.links))
    setEmailOtp('')
    setEmailStep('request')
  }, [profile])
  function closeDialog() {
    setDialog(null)
    setDisplayName(profile.displayName)
    setUsername(profile.username ?? '')
    setAvatarAssetId(profile.avatarAssetId ?? '')
    setAvatarPreview(profile.image ?? '')
    setEmail(profile.email)
    setBio(profile.bio ?? '')
    setLocation(profile.location ?? '')
    setLinks(publicProfileLinkDrafts(profile.links))
    setEmailOtp('')
    setEmailStep('request')
    setCurrentPassword('')
    setNewPassword('')
    setConfirmPassword('')
    setPasswordError(null)
  }
  async function saveProfile(event: FormEvent) {
    event.preventDefault()
    const input =
      dialog === 'avatar'
        ? { avatarAssetId: avatarAssetId || null }
        : dialog === 'displayName'
          ? { displayName }
          : dialog === 'username'
            ? { username: username || null }
            : null
    if (!input) return
    const result = await mutate('Profile updated.', () => updateAccountProfile(input), {
      invalidate: [accountQueryKeys.profile],
    })
    if (result) setDialog(null)
  }
  async function savePublicProfile(event: FormEvent) {
    event.preventDefault()
    const result = await mutate(
      'Public profile updated.',
      () =>
        updateAccountProfile({
          bio: bio || null,
          location: location || null,
          links: links.map(({ key: _key, ...link }) => link),
        }),
      { invalidate: [accountQueryKeys.profile] },
    )
    if (result) setPublicProfileOpen(false)
  }
  async function changeEmail(event: FormEvent) {
    event.preventDefault()
    if (emailStep === 'request') {
      const result = await mutate('Verification code sent.', () => requestAccountEmailChange({ email }))
      if (result) {
        setEmailOtp('')
        setEmailStep('confirm')
      }
      return
    }
    const result = await mutate('Email changed.', () => confirmAccountEmailChange({ email, otp: emailOtp }), {
      invalidate: [accountQueryKeys.profile],
    })
    if (result) {
      setEmailOtp('')
      setEmailStep('request')
      setDialog(null)
    }
  }
  async function changePassword(event: FormEvent) {
    event.preventDefault()
    setPasswordError(null)
    if (newPassword !== confirmPassword) {
      setPasswordError(tt('New passwords do not match.'))
      return
    }
    const result = await mutate(
      'Password changed.',
      () => changeAccountPassword({ currentPassword, newPassword, revokeOtherSessions: revokeSessions }),
      { invalidate: [accountQueryKeys.sessions], onError: setPasswordError },
    )
    if (result) {
      setCurrentPassword('')
      setNewPassword('')
      setConfirmPassword('')
      setPasswordError(null)
      setDialog(null)
    }
  }
  function uploadAvatar(file: File | undefined) {
    if (!file) return
    return mutate('Avatar uploaded.', () => uploadAccountAvatar(file)).then((response) => {
      if (response) {
        setAvatarAssetId(response.asset.id)
        setAvatarPreview(response.asset.publicUrl)
      }
      return response
    })
  }
  return (
    <>
      {mode === 'profile-account' ? (
        <>
          <ProfileIdentityRows accountCenter={accountCenter} profile={profile} setDialog={setDialog} />

          <section className="settingsPanel accountPublicProfileRows">
            <header className="accountInlineHeading">
              <h2>{tt('Public profile')}</h2>
            </header>
            <SettingsAction
              icon={<AlignLeft />}
              title={tt('Bio')}
              meta={profile.bio || tt('Not set')}
              action={
                <Button variant="outline" onClick={() => setPublicProfileOpen(true)}>
                  {tt('Edit')}
                </Button>
              }
            />
            <SettingsAction
              icon={<MapPin />}
              title={tt('Location')}
              meta={profile.location || tt('Not set')}
              action={
                <Button variant="outline" onClick={() => setPublicProfileOpen(true)}>
                  {tt('Edit')}
                </Button>
              }
            />
            <SettingsAction
              action={
                <Button
                  aria-label={tt('Edit public profile')}
                  onClick={() => setPublicProfileOpen(true)}
                  size="sm"
                  type="button"
                  variant="outline"
                >
                  {tt('Edit')}
                </Button>
              }
              icon={<Globe2 size={18} />}
              meta={tt('Bio, location, and links shown on your public profile.')}
              title={tt('Public profile')}
              value={
                profile.username ? (
                  <Link
                    className="underline-offset-4 hover:underline"
                    params={{ username: profile.username }}
                    to="/u/$username"
                  >
                    /u/{profile.username}
                  </Link>
                ) : (
                  tt('Set a username to publish')
                )
              }
            />
          </section>
          <ProfileIdentifierRows accountCenter={accountCenter} profile={profile} setDialog={setDialog} />
        </>
      ) : null}
      {accountCenter.passwordChangeEnabled && mode === 'password' ? (
        <section className="settingsPanel">
          <SettingsAction
            action={
              <Button
                aria-label={tt('Change password')}
                onClick={() => setDialog('password')}
                size="sm"
                type="button"
                variant="outline"
              >
                {tt('Change')}
              </Button>
            }
            icon={<LockKeyhole size={18} />}
            meta={tt('Sign in with your account password.')}
            title={tt('Password')}
            value={tt('Configured')}
          />
        </section>
      ) : null}
      <ProfileDialogs
        revokeSessions={revokeSessions}
        setRevokeSessions={setRevokeSessions}
        avatarPreview={avatarPreview}
        changeEmail={changeEmail}
        changePassword={changePassword}
        closeDialog={closeDialog}
        confirmPassword={confirmPassword}
        currentPassword={currentPassword}
        dialog={dialog}
        displayName={displayName}
        email={email}
        emailOtp={emailOtp}
        emailStep={emailStep}
        newPassword={newPassword}
        passwordError={passwordError}
        profile={profile}
        saveProfile={saveProfile}
        setConfirmPassword={setConfirmPassword}
        setCurrentPassword={setCurrentPassword}
        setDisplayName={setDisplayName}
        setEmail={setEmail}
        setEmailOtp={setEmailOtp}
        setEmailStep={setEmailStep}
        setNewPassword={setNewPassword}
        setUsername={setUsername}
        uploadAvatar={uploadAvatar}
        username={username}
      />
      <Dialog onOpenChange={setPublicProfileOpen} open={publicProfileOpen}>
        <DialogContent className="sm:max-w-xl">
          <form onSubmit={savePublicProfile}>
            <DialogHeader>
              <DialogTitle>{tt('Edit public profile')}</DialogTitle>
              <DialogDescription>{tt('Only these fields are visible to external visitors.')}</DialogDescription>
            </DialogHeader>
            <div className="grid gap-5 py-5">
              <Field label={tt('Bio')}>
                <TextArea maxLength={500} onChange={(event) => setBio(event.target.value)} rows={4} value={bio} />
              </Field>
              <Field label={tt('Location')}>
                <TextInput maxLength={100} onChange={(event) => setLocation(event.target.value)} value={location} />
              </Field>
              <div className="grid gap-3">
                <div className="flex items-center justify-between gap-3">
                  <strong className="text-sm">{tt('Links & identities')}</strong>
                  <div className="flex flex-wrap gap-2">
                    <Button
                      disabled={links.length >= 10}
                      onClick={() =>
                        setLinks([...links, { key: crypto.randomUUID(), type: 'website', label: '', url: '' }])
                      }
                      size="sm"
                      type="button"
                      variant="outline"
                    >
                      <Plus /> {tt('Add website')}
                    </Button>
                    {accountCenter.connectedAccountsEnabled ? (
                      <Button
                        disabled={links.length >= 10 || availableAccounts.length === 0}
                        onClick={() => {
                          const account = availableAccounts[0]!
                          setLinks([
                            ...links,
                            {
                              key: crypto.randomUUID(),
                              type: 'linked-account',
                              accountId: account.id,
                              providerId: account.providerId,
                              label: providerLabel(account.providerId),
                              url: '',
                            },
                          ])
                        }}
                        size="sm"
                        type="button"
                        variant="outline"
                      >
                        <Plus /> {tt('Add linked account')}
                      </Button>
                    ) : null}
                  </div>
                </div>
                {linkedAccountsQuery.error ? (
                  <p className="text-sm text-destructive">{tt('Unable to load linked accounts.')}</p>
                ) : null}
                {links.map((link, index) => (
                  <div className="grid gap-2 sm:grid-cols-[140px_120px_minmax(0,1fr)_40px]" key={link.key}>
                    {link.type === 'linked-account' && accountCenter.connectedAccountsEnabled ? (
                      <SelectInput
                        aria-label={tt('Linked account')}
                        onChange={(event) => {
                          const account = projectableAccounts.find((candidate) => candidate.id === event.target.value)!
                          setLinks(
                            links.map((item, itemIndex) =>
                              itemIndex === index && item.type === 'linked-account'
                                ? {
                                    ...item,
                                    accountId: account.id,
                                    providerId: account.providerId,
                                    label: providerLabel(account.providerId),
                                  }
                                : item,
                            ),
                          )
                        }}
                        value={link.accountId}
                      >
                        {projectableAccounts.map((account) => (
                          <option
                            disabled={links.some(
                              (item, itemIndex) =>
                                itemIndex !== index && item.type === 'linked-account' && item.accountId === account.id,
                            )}
                            key={account.id}
                            value={account.id}
                          >
                            {providerLabel(account.providerId)}
                          </option>
                        ))}
                      </SelectInput>
                    ) : link.type === 'linked-account' ? (
                      <span className="flex items-center text-sm text-muted-foreground">
                        {providerLabel(link.providerId)}
                      </span>
                    ) : (
                      <span className="flex items-center text-sm text-muted-foreground">{tt('Website')}</span>
                    )}
                    <TextInput
                      aria-label={tt('Link label')}
                      maxLength={40}
                      onChange={(event) =>
                        setLinks(
                          links.map((item, itemIndex) =>
                            itemIndex === index ? { ...item, label: event.target.value } : item,
                          ),
                        )
                      }
                      placeholder={tt('Label')}
                      required
                      value={link.label}
                    />
                    <TextInput
                      aria-label={tt('Link URL')}
                      onChange={(event) =>
                        setLinks(
                          links.map((item, itemIndex) =>
                            itemIndex === index ? { ...item, url: event.target.value } : item,
                          ),
                        )
                      }
                      placeholder={
                        link.type === 'linked-account' ? 'https://provider.example/your-profile' : 'https://example.com'
                      }
                      required
                      type="url"
                      value={link.url}
                    />
                    <Button
                      aria-label={tt('Remove link')}
                      onClick={() => setLinks(links.filter((_, itemIndex) => itemIndex !== index))}
                      size="icon"
                      type="button"
                      variant="ghost"
                    >
                      <Trash2 />
                    </Button>
                  </div>
                ))}
              </div>
            </div>
            <DialogFooter>
              <Button onClick={() => setPublicProfileOpen(false)} type="button" variant="outline">
                {tt('Cancel')}
              </Button>
              <Button type="submit">{tt('Save')}</Button>
            </DialogFooter>
          </form>
        </DialogContent>
      </Dialog>
    </>
  )
}

function ProfileIdentityRows({
  accountCenter,
  profile,
  setDialog,
}: {
  accountCenter: typeof defaultAccountCenterSettings
  profile: UserProfile
  setDialog: (dialog: 'avatar' | 'displayName') => void
}) {
  return (
    <div className="accountProfileHero">
      <button
        type="button"
        aria-label={tt('Edit avatar')}
        disabled={!accountCenter.avatarEditable}
        onClick={() => setDialog('avatar')}
      >
        <IdentityMark name={profile.displayName} image={profile.image} />
      </button>
      <div>
        <h2>{profile.displayName}</h2>
        <span>{profile.username ? `@${profile.username}` : profile.email}</span>
      </div>
      {accountCenter.displayNameEditable ? (
        <Button variant="outline" onClick={() => setDialog('displayName')}>
          {tt('Edit display name')}
        </Button>
      ) : null}
    </div>
  )
}

function publicProfileLinkDrafts(links: UserProfile['links']) {
  return links.map((link) => ({ ...link, key: crypto.randomUUID() }))
}

function providerLabel(providerId: string) {
  const brands: Record<string, string> = { github: 'GitHub', google: 'Google', linkedin: 'LinkedIn' }
  if (brands[providerId]) return brands[providerId]
  return providerId
    .split(/[-_]/)
    .filter(Boolean)
    .map((part) => `${part[0]?.toUpperCase() ?? ''}${part.slice(1)}`)
    .join(' ')
}

function ProfileIdentifierRows({
  accountCenter,
  profile,
  setDialog,
}: {
  accountCenter: typeof defaultAccountCenterSettings
  profile: UserProfile
  setDialog: (dialog: 'username' | 'email') => void
}) {
  if (!accountCenter.usernameEditable && !accountCenter.emailChangeEnabled) return null
  return (
    <section className="settingsPanel">
      {accountCenter.usernameEditable ? (
        <SettingsAction
          action={
            <Button
              aria-label={tt('Edit username')}
              onClick={() => setDialog('username')}
              size="sm"
              type="button"
              variant="outline"
            >
              {tt('Edit')}
            </Button>
          }
          icon={<UserRound size={18} />}
          meta={tt('Public account handle.')}
          title={tt('Username')}
          value={profile.username ? `@${profile.username}` : tt('No username set')}
        />
      ) : null}
      {accountCenter.emailChangeEnabled ? (
        <SettingsAction
          action={
            <Button
              aria-label={tt('Edit email')}
              onClick={() => setDialog('email')}
              size="sm"
              type="button"
              variant="outline"
            >
              {tt('Edit')}
            </Button>
          }
          icon={<Mail size={18} />}
          meta={tt('Used for sign-in and account notifications.')}
          status={profile.emailVerified ? tt('Verified') : tt('Unverified')}
          title={tt('Email')}
          value={profile.email}
        />
      ) : null}
    </section>
  )
}