feat(web): rework profile + auth visuals to reference bar
Elevate the Profile (Миний) tab and login/register/forgot screens to match TransactionList's IconChip + Card row pattern: - Profile: avatar medallion (initial + brand tint) + name/email header, IconChip menu rows (Тохиргоо/Ангилал/Захиалга) with chevrons, IconChip + EmptyState connected-banks list. - Categories/Subscriptions: IconChip rows via categoryStyle, EmptyState for empty lists, chevron-left back buttons. - Auth: shared AuthHeader with a tinted monogram + wordmark lockup, more comfortable field/CTA spacing in AuthForm. Visual only — no changes to hooks, mutations, or auth actions.
This commit is contained in:
parent
e0c07be232
commit
059b7e13d5
10 changed files with 336 additions and 277 deletions
|
|
@ -1,45 +1,15 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { AuthForm } from "@/features/auth/AuthForm";
|
import { AuthForm } from "@/features/auth/AuthForm";
|
||||||
import { authStrings } from "@/features/auth/strings";
|
import { AuthHeader } from "@/features/auth/AuthHeader";
|
||||||
|
|
||||||
// Ports ios/Mercury/Features/Auth/ForgotPasswordView.swift: back chevron,
|
// Ports ios/Mercury/Features/Auth/ForgotPasswordView.swift: back chevron,
|
||||||
// MERCURY wordmark, explanatory subtitle (rendered by AuthForm), the email
|
// MERCURY wordmark, explanatory subtitle (rendered by AuthForm), the email
|
||||||
// field, and the "Сэргээх холбоос авах" CTA.
|
// field, and the "Сэргээх холбоос авах" CTA.
|
||||||
export default function ForgotPasswordPage() {
|
export default function ForgotPasswordPage() {
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<AuthHeader />
|
||||||
type="button"
|
|
||||||
onClick={() => router.back()}
|
|
||||||
aria-label="Буцах"
|
|
||||||
style={{
|
|
||||||
background: "none",
|
|
||||||
border: "none",
|
|
||||||
alignSelf: "flex-start",
|
|
||||||
marginTop: 24,
|
|
||||||
fontSize: 20,
|
|
||||||
cursor: "pointer",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
‹
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div style={{ height: 72 }} />
|
|
||||||
|
|
||||||
<h1
|
|
||||||
style={{
|
|
||||||
textAlign: "center",
|
|
||||||
fontWeight: 700,
|
|
||||||
letterSpacing: 1,
|
|
||||||
fontSize: 24,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{authStrings.wordmark}
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<div style={{ flex: 1, minHeight: 40 }} />
|
<div style={{ flex: 1, minHeight: 40 }} />
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { AuthForm } from "@/features/auth/AuthForm";
|
import { AuthForm } from "@/features/auth/AuthForm";
|
||||||
import { authStrings } from "@/features/auth/strings";
|
import { AuthHeader } from "@/features/auth/AuthHeader";
|
||||||
|
|
||||||
// Ports ios/Mercury/Features/Auth/LoginView.swift: back chevron, MERCURY
|
// Ports ios/Mercury/Features/Auth/LoginView.swift: back chevron, MERCURY
|
||||||
// wordmark, then the login form (email + password + forgot link + CTAs).
|
// wordmark, then the login form (email + password + forgot link + CTAs).
|
||||||
|
|
@ -11,34 +11,7 @@ export default function LoginPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<AuthHeader />
|
||||||
type="button"
|
|
||||||
onClick={() => router.back()}
|
|
||||||
aria-label="Буцах"
|
|
||||||
style={{
|
|
||||||
background: "none",
|
|
||||||
border: "none",
|
|
||||||
alignSelf: "flex-start",
|
|
||||||
marginTop: 24,
|
|
||||||
fontSize: 20,
|
|
||||||
cursor: "pointer",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
‹
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div style={{ height: 72 }} />
|
|
||||||
|
|
||||||
<h1
|
|
||||||
style={{
|
|
||||||
textAlign: "center",
|
|
||||||
fontWeight: 700,
|
|
||||||
letterSpacing: 1,
|
|
||||||
fontSize: 24,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{authStrings.wordmark}
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<div style={{ flex: 1, minHeight: 40 }} />
|
<div style={{ flex: 1, minHeight: 40 }} />
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { AuthForm } from "@/features/auth/AuthForm";
|
import { AuthForm } from "@/features/auth/AuthForm";
|
||||||
import { authStrings } from "@/features/auth/strings";
|
import { AuthHeader } from "@/features/auth/AuthHeader";
|
||||||
|
|
||||||
// Ports ios/Mercury/Features/Auth/RegisterView.swift: back chevron, MERCURY
|
// Ports ios/Mercury/Features/Auth/RegisterView.swift: back chevron, MERCURY
|
||||||
// wordmark, then the registration form (email + password + confirm + CTAs).
|
// wordmark, then the registration form (email + password + confirm + CTAs).
|
||||||
|
|
@ -11,34 +11,7 @@ export default function RegisterPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<AuthHeader />
|
||||||
type="button"
|
|
||||||
onClick={() => router.back()}
|
|
||||||
aria-label="Буцах"
|
|
||||||
style={{
|
|
||||||
background: "none",
|
|
||||||
border: "none",
|
|
||||||
alignSelf: "flex-start",
|
|
||||||
marginTop: 24,
|
|
||||||
fontSize: 20,
|
|
||||||
cursor: "pointer",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
‹
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div style={{ height: 72 }} />
|
|
||||||
|
|
||||||
<h1
|
|
||||||
style={{
|
|
||||||
textAlign: "center",
|
|
||||||
fontWeight: 700,
|
|
||||||
letterSpacing: 1,
|
|
||||||
fontSize: 24,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{authStrings.wordmark}
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<div style={{ flex: 1, minHeight: 40 }} />
|
<div style={{ flex: 1, minHeight: 40 }} />
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -137,8 +137,8 @@ export function AuthForm({
|
||||||
|
|
||||||
if (step === "twoFactor") {
|
if (step === "twoFactor") {
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleCodeSubmit} className="flex flex-col gap-3">
|
<form onSubmit={handleCodeSubmit} className="flex flex-col gap-5">
|
||||||
<p className="text-center text-sm" style={{ color: "var(--mercury-subtle, #6b7280)" }}>
|
<p className="text-center text-sm" style={{ color: "var(--mercury-subtle, #6b7280)", margin: 0 }}>
|
||||||
{authStrings.twoFactor.subtitle}
|
{authStrings.twoFactor.subtitle}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
@ -162,13 +162,14 @@ export function AuthForm({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleFormSubmit} className="flex flex-col gap-3">
|
<form onSubmit={handleFormSubmit} className="flex flex-col gap-5">
|
||||||
{mode === "forgot" && (
|
{mode === "forgot" && (
|
||||||
<p className="text-center text-sm" style={{ color: "var(--mercury-subtle, #6b7280)" }}>
|
<p className="text-center text-sm" style={{ color: "var(--mercury-subtle, #6b7280)", margin: 0 }}>
|
||||||
{authStrings.forgot.subtitle}
|
{authStrings.forgot.subtitle}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
<TextFieldRoot value={email} onValueChange={setEmail} name="email">
|
<TextFieldRoot value={email} onValueChange={setEmail} name="email">
|
||||||
<TextFieldInput
|
<TextFieldInput
|
||||||
type="email"
|
type="email"
|
||||||
|
|
@ -209,19 +210,21 @@ export function AuthForm({
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onNavigateForgot}
|
onClick={onNavigateForgot}
|
||||||
className="self-end text-sm"
|
className="self-end text-sm"
|
||||||
style={{ color: "var(--mercury-subtle, #6b7280)", background: "none", border: "none" }}
|
style={{ color: "var(--mercury-subtle, #6b7280)", background: "none", border: "none", padding: 0 }}
|
||||||
>
|
>
|
||||||
{authStrings.login.forgot}
|
{authStrings.login.forgot}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<FormError message={errorMessage} />
|
<FormError message={errorMessage} />
|
||||||
{infoMessage && (
|
{infoMessage && (
|
||||||
<p className="text-sm" style={{ color: "var(--mercury-subtle, #6b7280)" }}>
|
<p className="text-sm" style={{ color: "var(--mercury-subtle, #6b7280)", margin: 0 }}>
|
||||||
{infoMessage}
|
{infoMessage}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3" style={{ marginTop: 4 }}>
|
||||||
<MercuryButton type="submit" variant="primary" loading={isSubmitting}>
|
<MercuryButton type="submit" variant="primary" loading={isSubmitting}>
|
||||||
{mode === "login"
|
{mode === "login"
|
||||||
? authStrings.login.submit
|
? authStrings.login.submit
|
||||||
|
|
@ -240,6 +243,7 @@ export function AuthForm({
|
||||||
{authStrings.register.loginLink}
|
{authStrings.register.loginLink}
|
||||||
</MercuryButton>
|
</MercuryButton>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -247,7 +251,7 @@ export function AuthForm({
|
||||||
function FormError({ message }: { message?: string }) {
|
function FormError({ message }: { message?: string }) {
|
||||||
if (!message) return null;
|
if (!message) return null;
|
||||||
return (
|
return (
|
||||||
<p role="alert" className="text-sm" style={{ color: "var(--seed-color-fg-critical)" }}>
|
<p role="alert" className="text-sm" style={{ color: "var(--seed-color-fg-critical)", margin: 0 }}>
|
||||||
{message}
|
{message}
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
71
src/features/auth/AuthHeader.tsx
Normal file
71
src/features/auth/AuthHeader.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Icon } from "@/ds";
|
||||||
|
import { authStrings } from "./strings";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared header for the three auth screens (login / register / forgot): a
|
||||||
|
* back chevron pinned top-left, then a MERCURY wordmark lockup — a tinted
|
||||||
|
* monogram badge stacked above the bold, letter-spaced wordmark — centered
|
||||||
|
* below it. Purely presentational; navigation still goes through
|
||||||
|
* `router.back()`, unchanged from the markup this replaces in each page.
|
||||||
|
*/
|
||||||
|
export function AuthHeader() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", alignItems: "center" }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => router.back()}
|
||||||
|
aria-label="Буцах"
|
||||||
|
style={{
|
||||||
|
alignSelf: "flex-start",
|
||||||
|
marginTop: 24,
|
||||||
|
display: "grid",
|
||||||
|
placeItems: "center",
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: 12,
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
color: "var(--seed-color-fg-neutral)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name="chevron-left" size={22} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 48, display: "flex", flexDirection: "column", alignItems: "center", gap: 14 }}>
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
style={{
|
||||||
|
width: 52,
|
||||||
|
height: 52,
|
||||||
|
borderRadius: 16,
|
||||||
|
display: "grid",
|
||||||
|
placeItems: "center",
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: 800,
|
||||||
|
color: "var(--mercury-on-brand)",
|
||||||
|
background: "var(--mercury-brand-yellow)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
M
|
||||||
|
</span>
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
textAlign: "center",
|
||||||
|
fontWeight: 800,
|
||||||
|
letterSpacing: 3,
|
||||||
|
fontSize: 24,
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{authStrings.wordmark}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,8 @@ import {
|
||||||
Skeleton,
|
Skeleton,
|
||||||
} from "@seed-design/react";
|
} from "@seed-design/react";
|
||||||
import { MercuryButton } from "@/ds/MercuryButton";
|
import { MercuryButton } from "@/ds/MercuryButton";
|
||||||
|
import { EmptyState, Icon, IconChip } from "@/ds";
|
||||||
|
import { categoryStyle } from "@/ds/categoryStyle";
|
||||||
import { useCategories } from "@/api/hooks/reads";
|
import { useCategories } from "@/api/hooks/reads";
|
||||||
import { useCategoryMutations } from "@/api/hooks/mutations";
|
import { useCategoryMutations } from "@/api/hooks/mutations";
|
||||||
import type { Category } from "@/api/schemas";
|
import type { Category } from "@/api/schemas";
|
||||||
|
|
@ -105,9 +107,9 @@ export function CategoriesManager({ onBack }: CategoriesManagerProps) {
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onBack}
|
onClick={onBack}
|
||||||
aria-label={profileStrings.categories.back}
|
aria-label={profileStrings.categories.back}
|
||||||
style={{ background: "none", border: "none", fontSize: 20, cursor: "pointer" }}
|
style={{ display: "grid", placeItems: "center", background: "none", border: "none", cursor: "pointer", color: "var(--seed-color-fg-neutral)" }}
|
||||||
>
|
>
|
||||||
←
|
<Icon name="chevron-left" size={22} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<h1 style={{ fontSize: 16, fontWeight: 600, margin: 0, flex: 1 }}>{profileStrings.categories.title}</h1>
|
<h1 style={{ fontSize: 16, fontWeight: 600, margin: 0, flex: 1 }}>{profileStrings.categories.title}</h1>
|
||||||
|
|
@ -124,7 +126,7 @@ export function CategoriesManager({ onBack }: CategoriesManagerProps) {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isLoading && groups.length === 0 && (
|
{!isLoading && groups.length === 0 && (
|
||||||
<p style={{ fontSize: 14, color: "var(--seed-color-fg-muted, #6b7280)" }}>{profileStrings.categories.empty}</p>
|
<EmptyState icon="list" title={profileStrings.categories.empty} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{groups.map((group) => (
|
{groups.map((group) => (
|
||||||
|
|
@ -132,6 +134,7 @@ export function CategoriesManager({ onBack }: CategoriesManagerProps) {
|
||||||
<CategoryRow
|
<CategoryRow
|
||||||
icon={group.icon}
|
icon={group.icon}
|
||||||
name={group.name}
|
name={group.name}
|
||||||
|
kind="expense"
|
||||||
heading
|
heading
|
||||||
onEdit={() => setSheet({ mode: "edit", category: { name: group.name, kind: "expense", depth: 0, icon: group.icon } })}
|
onEdit={() => setSheet({ mode: "edit", category: { name: group.name, kind: "expense", depth: 0, icon: group.icon } })}
|
||||||
onDelete={() => setDeleteTarget(group.name)}
|
onDelete={() => setDeleteTarget(group.name)}
|
||||||
|
|
@ -204,20 +207,23 @@ export function CategoriesManager({ onBack }: CategoriesManagerProps) {
|
||||||
function CategoryRow({
|
function CategoryRow({
|
||||||
icon,
|
icon,
|
||||||
name,
|
name,
|
||||||
|
kind,
|
||||||
heading,
|
heading,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
}: {
|
}: {
|
||||||
icon?: string | null;
|
icon?: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
|
kind?: string;
|
||||||
heading?: boolean;
|
heading?: boolean;
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const style = categoryStyle(name, kind === "income");
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||||
<span aria-hidden style={{ fontSize: heading ? 20 : 16 }}>{icon || "🏷️"}</span>
|
<IconChip icon={style.icon} tint={style.tint} fg={style.fg} size={heading ? 40 : 32} />
|
||||||
<span style={{ fontSize: heading ? 16 : 14, fontWeight: heading ? 600 : 400, flex: 1 }}>{name}</span>
|
<span style={{ fontSize: heading ? 16 : 14, fontWeight: heading ? 700 : 500, flex: 1 }}>{style.name}</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onEdit}
|
onClick={onEdit}
|
||||||
|
|
@ -247,24 +253,26 @@ function CategoryChip({
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const style = categoryStyle(category.name, category.kind === "income");
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: 6,
|
gap: 6,
|
||||||
padding: "6px 10px",
|
padding: "6px 10px 6px 6px",
|
||||||
borderRadius: 999,
|
borderRadius: 999,
|
||||||
background: "var(--seed-color-bg-layer-default)",
|
background: style.tint,
|
||||||
|
color: style.fg,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span aria-hidden>{category.icon || "🏷️"}</span>
|
<IconChip icon={style.icon} tint="transparent" fg={style.fg} size={22} />
|
||||||
<span style={{ fontSize: 13 }}>{category.name}</span>
|
<span style={{ fontSize: 13, fontWeight: 600 }}>{style.name}</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onEdit}
|
onClick={onEdit}
|
||||||
aria-label={`${profileStrings.categories.editAction}: ${category.name}`}
|
aria-label={`${profileStrings.categories.editAction}: ${category.name}`}
|
||||||
style={{ background: "none", border: "none", cursor: "pointer", fontSize: 12, color: "var(--seed-color-fg-muted, #6b7280)" }}
|
style={{ background: "none", border: "none", cursor: "pointer", fontSize: 12, color: "inherit", opacity: 0.7 }}
|
||||||
>
|
>
|
||||||
✎
|
✎
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -315,9 +323,9 @@ function CategoryEditor({
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
aria-label={profileStrings.categories.cancel}
|
aria-label={profileStrings.categories.cancel}
|
||||||
style={{ background: "none", border: "none", fontSize: 20, cursor: "pointer" }}
|
style={{ display: "grid", placeItems: "center", background: "none", border: "none", cursor: "pointer", color: "var(--seed-color-fg-neutral)" }}
|
||||||
>
|
>
|
||||||
←
|
<Icon name="chevron-left" size={22} />
|
||||||
</button>
|
</button>
|
||||||
<h1 style={{ fontSize: 16, fontWeight: 600, margin: 0 }}>
|
<h1 style={{ fontSize: 16, fontWeight: 600, margin: 0 }}>
|
||||||
{editing ? profileStrings.categories.edit : profileStrings.categories.add}
|
{editing ? profileStrings.categories.edit : profileStrings.categories.add}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import { useConnections } from "@/api/hooks/reads";
|
import { useConnections } from "@/api/hooks/reads";
|
||||||
import { Skeleton } from "@seed-design/react";
|
import { Skeleton } from "@seed-design/react";
|
||||||
|
import { EmptyState, IconChip, SectionHeader } from "@/ds";
|
||||||
import { profileStrings } from "./strings";
|
import { profileStrings } from "./strings";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -11,31 +12,28 @@ import { profileStrings } from "./strings";
|
||||||
*/
|
*/
|
||||||
export function ConnectedBanks() {
|
export function ConnectedBanks() {
|
||||||
const { data: connections, isLoading } = useConnections();
|
const { data: connections, isLoading } = useConnections();
|
||||||
|
const empty = !isLoading && (!connections || connections.length === 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section aria-labelledby="connected-banks-heading">
|
<section aria-labelledby="connected-banks-heading">
|
||||||
<h2
|
<div id="connected-banks-heading">
|
||||||
id="connected-banks-heading"
|
<SectionHeader title={profileStrings.banks.title} />
|
||||||
style={{ fontSize: 12, color: "var(--seed-color-fg-subtle, #6b7280)", margin: "0 0 12px" }}
|
</div>
|
||||||
>
|
|
||||||
{profileStrings.banks.title}
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 8, marginTop: 12 }}>
|
||||||
<Skeleton height="44px" />
|
<Skeleton height="44px" />
|
||||||
<Skeleton height="44px" />
|
<Skeleton height="44px" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isLoading && (!connections || connections.length === 0) && (
|
{empty && (
|
||||||
<p style={{ fontSize: 14, color: "var(--seed-color-fg-muted, #6b7280)" }}>
|
<EmptyState icon="bank" title={profileStrings.banks.empty} hint={profileStrings.banks.manageNote} compact />
|
||||||
{profileStrings.banks.empty}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isLoading && connections && connections.length > 0 && (
|
{!isLoading && connections && connections.length > 0 && (
|
||||||
<ul style={{ listStyle: "none", margin: 0, padding: 0, display: "flex", flexDirection: "column", gap: 4 }}>
|
<>
|
||||||
|
<ul style={{ listStyle: "none", margin: "8px 0 0", padding: 0, display: "flex", flexDirection: "column" }}>
|
||||||
{connections.map((c) => (
|
{connections.map((c) => (
|
||||||
<li
|
<li
|
||||||
key={c.bank}
|
key={c.bank}
|
||||||
|
|
@ -43,14 +41,26 @@ export function ConnectedBanks() {
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
|
gap: 12,
|
||||||
padding: "10px 0",
|
padding: "10px 0",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ display: "flex", flexDirection: "column" }}>
|
<div style={{ display: "flex", alignItems: "center", gap: 12, minWidth: 0 }}>
|
||||||
<span style={{ fontSize: 14, fontWeight: 600 }}>{c.bank}</span>
|
<IconChip icon="bank" tint="var(--seed-color-bg-neutral-subtle, #eef0f2)" fg="var(--seed-color-fg-neutral)" />
|
||||||
<span style={{ fontSize: 12, color: "var(--seed-color-fg-muted, #6b7280)" }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 2, minWidth: 0 }}>
|
||||||
{c.username}
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: 700,
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{c.bank}
|
||||||
</span>
|
</span>
|
||||||
|
<span style={{ fontSize: 12, color: "var(--seed-color-fg-muted, #6b7280)" }}>{c.username}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{c.courierManaged && (
|
{c.courierManaged && (
|
||||||
<span
|
<span
|
||||||
|
|
@ -58,6 +68,7 @@ export function ConnectedBanks() {
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
padding: "4px 8px",
|
padding: "4px 8px",
|
||||||
borderRadius: 999,
|
borderRadius: 999,
|
||||||
|
flexShrink: 0,
|
||||||
background: "var(--seed-color-bg-layer-default)",
|
background: "var(--seed-color-bg-layer-default)",
|
||||||
color: "var(--seed-color-fg-muted, #6b7280)",
|
color: "var(--seed-color-fg-muted, #6b7280)",
|
||||||
}}
|
}}
|
||||||
|
|
@ -68,11 +79,12 @@ export function ConnectedBanks() {
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
|
||||||
|
|
||||||
<p style={{ fontSize: 12, color: "var(--seed-color-fg-muted, #6b7280)", marginTop: 8 }}>
|
<p style={{ fontSize: 12, color: "var(--seed-color-fg-muted, #6b7280)", marginTop: 8 }}>
|
||||||
{profileStrings.banks.manageNote}
|
{profileStrings.banks.manageNote}
|
||||||
</p>
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,8 @@
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Card, HideAmountsToggle, MercuryButton } from "@/ds";
|
import { Card, HideAmountsToggle, Icon, IconChip, MercuryButton } from "@/ds";
|
||||||
|
import type { IconName } from "@/ds";
|
||||||
import { useMe, useSettings } from "@/api/hooks/reads";
|
import { useMe, useSettings } from "@/api/hooks/reads";
|
||||||
import { profileStrings } from "./strings";
|
import { profileStrings } from "./strings";
|
||||||
import { SettingsForm } from "./SettingsForm";
|
import { SettingsForm } from "./SettingsForm";
|
||||||
|
|
@ -24,6 +25,7 @@ export function ProfileView() {
|
||||||
|
|
||||||
const emailLocal = me?.email ? me.email.split("@")[0] : "";
|
const emailLocal = me?.email ? me.email.split("@")[0] : "";
|
||||||
const displayName = settings?.holderName || emailLocal || me?.email || "";
|
const displayName = settings?.holderName || emailLocal || me?.email || "";
|
||||||
|
const initial = (displayName || me?.email || "?").trim().charAt(0).toUpperCase() || "?";
|
||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
setLoggingOut(true);
|
setLoggingOut(true);
|
||||||
|
|
@ -45,23 +47,57 @@ export function ProfileView() {
|
||||||
<HideAmountsToggle label={profileStrings.display.hideAmounts} />
|
<HideAmountsToggle label={profileStrings.display.hideAmounts} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card style={{ display: "flex", alignItems: "center", gap: 14 }}>
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
<span
|
||||||
<span style={{ fontSize: 14, fontWeight: 600 }}>{displayName}</span>
|
aria-hidden
|
||||||
|
style={{
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
flexShrink: 0,
|
||||||
|
borderRadius: 28,
|
||||||
|
display: "grid",
|
||||||
|
placeItems: "center",
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: 800,
|
||||||
|
color: "var(--mercury-on-brand)",
|
||||||
|
background: "var(--mercury-brand-yellow)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{initial}
|
||||||
|
</span>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 2, minWidth: 0 }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 700,
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{displayName}
|
||||||
|
</span>
|
||||||
{me?.email && (
|
{me?.email && (
|
||||||
<span style={{ fontSize: 12, color: "var(--seed-color-fg-muted, #6b7280)" }}>
|
<span
|
||||||
{profileStrings.account.handlePrefix}
|
style={{
|
||||||
{emailLocal}
|
fontSize: 13,
|
||||||
|
color: "var(--seed-color-fg-muted, #6b7280)",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{me.email}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card style={{ display: "flex", flexDirection: "column" }}>
|
||||||
<nav style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
<nav style={{ display: "flex", flexDirection: "column" }}>
|
||||||
<MenuRow label={profileStrings.menu.settings} onClick={() => setShowSettings(true)} />
|
<MenuRow icon="wrench" label={profileStrings.menu.settings} onClick={() => setShowSettings(true)} />
|
||||||
<MenuLink label={profileStrings.menu.categories} href="/profile/categories" />
|
<MenuLink icon="list" label={profileStrings.menu.categories} href="/profile/categories" />
|
||||||
<MenuLink label={profileStrings.menu.subscriptions} href="/profile/subscriptions" />
|
<MenuLink icon="sparkles" label={profileStrings.menu.subscriptions} href="/profile/subscriptions" />
|
||||||
</nav>
|
</nav>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|
@ -76,49 +112,46 @@ export function ProfileView() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MenuRow({ label, onClick }: { label: string; onClick: () => void }) {
|
const menuRowStyle: React.CSSProperties = {
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClick}
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
padding: "12px 0",
|
gap: 12,
|
||||||
|
width: "100%",
|
||||||
|
padding: "10px 0",
|
||||||
background: "none",
|
background: "none",
|
||||||
border: "none",
|
border: "none",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
font: "inherit",
|
font: "inherit",
|
||||||
color: "inherit",
|
color: "inherit",
|
||||||
textAlign: "left",
|
textAlign: "left",
|
||||||
}}
|
textDecoration: "none",
|
||||||
>
|
};
|
||||||
<span style={{ fontSize: 14 }}>{label}</span>
|
|
||||||
<span aria-hidden style={{ color: "var(--seed-color-fg-placeholder, #9ca3af)" }}>
|
function MenuRowContent({ icon, label }: { icon: IconName; label: string }) {
|
||||||
›
|
return (
|
||||||
</span>
|
<>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 12, minWidth: 0 }}>
|
||||||
|
<IconChip icon={icon} tint="var(--seed-color-bg-neutral-subtle, #eef0f2)" fg="var(--seed-color-fg-neutral)" />
|
||||||
|
<span style={{ fontSize: 15, fontWeight: 600 }}>{label}</span>
|
||||||
|
</div>
|
||||||
|
<Icon name="chevron-right" size={18} style={{ color: "var(--seed-color-fg-placeholder, #9ca3af)" }} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenuRow({ icon, label, onClick }: { icon: IconName; label: string; onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<button type="button" onClick={onClick} style={menuRowStyle}>
|
||||||
|
<MenuRowContent icon={icon} label={label} />
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MenuLink({ label, href }: { label: string; href: string }) {
|
function MenuLink({ icon, label, href }: { icon: IconName; label: string; href: string }) {
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link href={href} style={menuRowStyle}>
|
||||||
href={href}
|
<MenuRowContent icon={icon} label={label} />
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
padding: "12px 0",
|
|
||||||
color: "inherit",
|
|
||||||
textDecoration: "none",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ fontSize: 14 }}>{label}</span>
|
|
||||||
<span aria-hidden style={{ color: "var(--seed-color-fg-placeholder, #9ca3af)" }}>
|
|
||||||
›
|
|
||||||
</span>
|
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
Skeleton,
|
Skeleton,
|
||||||
} from "@seed-design/react";
|
} from "@seed-design/react";
|
||||||
import { MercuryButton } from "@/ds/MercuryButton";
|
import { MercuryButton } from "@/ds/MercuryButton";
|
||||||
|
import { Icon } from "@/ds";
|
||||||
import { useSettings } from "@/api/hooks/reads";
|
import { useSettings } from "@/api/hooks/reads";
|
||||||
import { useSaveSettings } from "@/api/hooks/mutations";
|
import { useSaveSettings } from "@/api/hooks/mutations";
|
||||||
import type { Settings } from "@/api/schemas";
|
import type { Settings } from "@/api/schemas";
|
||||||
|
|
@ -126,9 +127,9 @@ export function SettingsForm({ onBack }: SettingsFormProps) {
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onBack}
|
onClick={onBack}
|
||||||
aria-label={profileStrings.settings.back}
|
aria-label={profileStrings.settings.back}
|
||||||
style={{ background: "none", border: "none", fontSize: 20, cursor: "pointer" }}
|
style={{ display: "grid", placeItems: "center", background: "none", border: "none", cursor: "pointer", color: "var(--seed-color-fg-neutral)" }}
|
||||||
>
|
>
|
||||||
←
|
<Icon name="chevron-left" size={22} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<h1 style={{ fontSize: 16, fontWeight: 600, margin: 0 }}>{profileStrings.settings.title}</h1>
|
<h1 style={{ fontSize: 16, fontWeight: 600, margin: 0 }}>{profileStrings.settings.title}</h1>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { TextFieldRoot, TextFieldInput, Skeleton } from "@seed-design/react";
|
import { TextFieldRoot, TextFieldInput, Skeleton } from "@seed-design/react";
|
||||||
import { MercuryButton } from "@/ds/MercuryButton";
|
import { MercuryButton } from "@/ds/MercuryButton";
|
||||||
|
import { EmptyState, Icon, IconChip } from "@/ds";
|
||||||
import { tugrik } from "@/ds/money";
|
import { tugrik } from "@/ds/money";
|
||||||
import { useSubscriptions } from "@/api/hooks/reads";
|
import { useSubscriptions } from "@/api/hooks/reads";
|
||||||
import { useSubscriptionMutations } from "@/api/hooks/mutations";
|
import { useSubscriptionMutations } from "@/api/hooks/mutations";
|
||||||
|
|
@ -58,9 +59,9 @@ export function SubscriptionsView({ onBack }: SubscriptionsViewProps) {
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onBack}
|
onClick={onBack}
|
||||||
aria-label={profileStrings.subscriptions.back}
|
aria-label={profileStrings.subscriptions.back}
|
||||||
style={{ background: "none", border: "none", fontSize: 20, cursor: "pointer" }}
|
style={{ display: "grid", placeItems: "center", background: "none", border: "none", cursor: "pointer", color: "var(--seed-color-fg-neutral)" }}
|
||||||
>
|
>
|
||||||
←
|
<Icon name="chevron-left" size={22} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<h1 style={{ fontSize: 16, fontWeight: 600, margin: 0, flex: 1 }}>{profileStrings.subscriptions.title}</h1>
|
<h1 style={{ fontSize: 16, fontWeight: 600, margin: 0, flex: 1 }}>{profileStrings.subscriptions.title}</h1>
|
||||||
|
|
@ -77,7 +78,7 @@ export function SubscriptionsView({ onBack }: SubscriptionsViewProps) {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isLoading && all.length === 0 && (
|
{!isLoading && all.length === 0 && (
|
||||||
<p style={{ fontSize: 14, color: "var(--seed-color-fg-muted, #6b7280)" }}>{profileStrings.subscriptions.empty}</p>
|
<EmptyState icon="sparkles" title={profileStrings.subscriptions.empty} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isLoading && detected.length > 0 && (
|
{!isLoading && detected.length > 0 && (
|
||||||
|
|
@ -136,14 +137,27 @@ function SubscriptionRow({
|
||||||
destructive?: boolean;
|
destructive?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "8px 0" }}>
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, padding: "8px 0" }}>
|
||||||
<div style={{ display: "flex", flexDirection: "column" }}>
|
<div style={{ display: "flex", alignItems: "center", gap: 12, minWidth: 0 }}>
|
||||||
<span style={{ fontSize: 14, fontWeight: 600 }}>{sub.label}</span>
|
<IconChip icon="sparkles" tint="var(--seed-color-bg-neutral-subtle, #eef0f2)" fg="var(--seed-color-fg-neutral)" size={36} />
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", minWidth: 0 }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 700,
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sub.label}
|
||||||
|
</span>
|
||||||
<span style={{ fontSize: 12, color: "var(--seed-color-fg-muted, #6b7280)" }}>
|
<span style={{ fontSize: 12, color: "var(--seed-color-fg-muted, #6b7280)" }}>
|
||||||
{tugrik(sub.monthly)} / {sub.cadence}
|
{tugrik(sub.monthly)} / {sub.cadence}
|
||||||
{sub.nextDue ? ` · ${sub.nextDue}` : ""}
|
{sub.nextDue ? ` · ${sub.nextDue}` : ""}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onAction}
|
onClick={onAction}
|
||||||
|
|
@ -204,9 +218,9 @@ function ManualSubscriptionForm({
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
aria-label={profileStrings.subscriptions.cancel}
|
aria-label={profileStrings.subscriptions.cancel}
|
||||||
style={{ background: "none", border: "none", fontSize: 20, cursor: "pointer" }}
|
style={{ display: "grid", placeItems: "center", background: "none", border: "none", cursor: "pointer", color: "var(--seed-color-fg-neutral)" }}
|
||||||
>
|
>
|
||||||
←
|
<Icon name="chevron-left" size={22} />
|
||||||
</button>
|
</button>
|
||||||
<h1 style={{ fontSize: 16, fontWeight: 600, margin: 0 }}>{profileStrings.subscriptions.addManual}</h1>
|
<h1 style={{ fontSize: 16, fontWeight: 600, margin: 0 }}>{profileStrings.subscriptions.addManual}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue