chore: save pending changes

This commit is contained in:
2026-09-12 19:59:10 +03:00
parent 6b310db0b8
commit ae06f6e6e0
16 changed files with 403 additions and 42 deletions
@@ -22,6 +22,7 @@ const card: BankCard = {
name: "Daily",
bankName: null,
lastFour: null,
paymentSystem: null,
openingBalance: { currency: "RUB", value: "100" },
openingDate: "2026-09-01",
balance: { currency: "RUB", value: "100" },
+1
View File
@@ -19,6 +19,7 @@ export {
moneyCents,
positiveMoneySchema,
} from "./model/form-validation";
export { BankCardFace } from "./ui/BankCardFace";
export { BankCardForm } from "./ui/BankCardForm";
export { BankFeedback } from "./ui/BankFeedback";
export { BankOperationIcon } from "./ui/BankOperationIcon";
@@ -71,6 +71,11 @@ const bankCardSchema = z.object({
(value) => !value || /^\d{4}$/.test(value),
validationMessage("bank.validation.lastFour"),
),
paymentSystem: z
.enum(["", "mir", "visa", "mastercard"], {
errorMap: validationMessage("bank.validation.paymentSystem"),
})
.optional(),
openingBalance: moneySchema,
openingDate: bankDateSchema,
});
@@ -0,0 +1,160 @@
.face {
--card-base: #242b35;
--card-light: #485666;
position: relative;
isolation: isolate;
overflow: hidden;
box-sizing: border-box;
width: 100%;
max-width: 28rem;
min-height: 15rem;
padding: clamp(1.1rem, 3vw, 1.6rem);
border-radius: 1.25rem;
color: #fff;
background: linear-gradient(125deg, var(--card-light), var(--card-base) 75%);
box-shadow:
inset 0 1px 0 #ffffff40,
0 8px 18px #18233118;
}
.face::before {
content: "";
position: absolute;
z-index: -1;
width: 24rem;
height: 24rem;
border: 1px solid #ffffff20;
border-radius: 50%;
right: -13rem;
top: -9rem;
box-shadow:
0 0 0 2.5rem #ffffff08,
0 0 0 5rem #ffffff06;
}
.face[data-tone="green"] {
--card-base: #07543c;
--card-light: #21844a;
}
.face[data-tone="yellow"] {
--card-base: #edbe26;
--card-light: #ffe478;
color: #252316;
}
.face[data-tone="red"] {
--card-base: #980f28;
--card-light: #d63244;
}
.face[data-tone="blue"] {
--card-base: #073f86;
--card-light: #226db8;
}
.top,
.bottom {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
}
.bank {
font-size: 1.05rem;
font-weight: 750;
overflow-wrap: anywhere;
}
.archive {
font-size: 0.7rem;
border: 1px solid currentColor;
border-radius: 2rem;
padding: 0.2rem 0.5rem;
flex-shrink: 0;
}
.hardware {
display: flex;
align-items: center;
gap: 0.7rem;
margin: 1.4rem 0 1.1rem;
}
.chip {
display: block;
width: 2.5rem;
height: 1.9rem;
border: 1px solid #a99054;
border-radius: 0.4rem;
background:
linear-gradient(
90deg,
transparent 30%,
#9b875a 31%,
transparent 33%,
transparent 66%,
#9b875a 67%,
transparent 69%
),
linear-gradient(
0deg,
transparent 32%,
#9b875a 33%,
transparent 36%,
transparent 65%,
#9b875a 66%,
transparent 69%
),
linear-gradient(135deg, #e5d6a6, #bfaa75 50%, #f1e6c4);
}
.number {
font-family: var(--mantine-font-family-monospace);
font-size: clamp(1rem, 2.3vw, 1.35rem);
letter-spacing: 0.06em;
white-space: nowrap;
margin-bottom: 1.25rem;
}
.details {
min-width: 0;
}
.name {
font-size: 0.8rem;
opacity: 0.9;
overflow-wrap: anywhere;
}
.balance {
font-size: clamp(1.2rem, 2.5vw, 1.55rem);
font-weight: 650;
overflow-wrap: anywhere;
margin-top: 0.2rem;
}
.network {
flex-shrink: 0;
font-weight: 850;
font-style: italic;
font-size: 1.35rem;
}
.network[data-network="mastercard"] {
font-size: 0.65rem;
font-weight: 600;
font-style: normal;
text-align: center;
}
.circles {
display: block;
width: 3.3rem;
height: 2rem;
margin: 0 auto 0.15rem;
position: relative;
}
.circles::before,
.circles::after {
content: "";
position: absolute;
top: 0;
width: 2rem;
height: 2rem;
border-radius: 50%;
}
.circles::before {
left: 0;
background: #eb302b;
}
.circles::after {
right: 0;
background: #ffad19;
opacity: 0.9;
}
@@ -0,0 +1,67 @@
import { SimpleGrid } from "@mantine/core";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { BankCardFace } from "./BankCardFace";
const meta = {
title: "Product/BankCards/CardAppearance",
component: BankCardFace,
tags: ["autodocs"],
args: {
card: {
id: "fictional",
name: "Для повседневных покупок",
bankName: "Сбербанк",
lastFour: "4829",
paymentSystem: "mir",
openingBalance: { value: "0", currency: "RUB" },
openingDate: "2026-01-01",
balance: { value: "128450.75", currency: "RUB" },
archivedAt: null,
version: 1,
},
},
} satisfies Meta<typeof BankCardFace>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Banks: Story = {
render: (args) => (
<SimpleGrid cols={{ base: 1, sm: 2 }} maw={920}>
{["Сбербанк", "Т-Банк", "Альфа-Банк", "ВТБ", null].map(
(bankName, index) => (
<BankCardFace
key={bankName ?? "unknown"}
card={{
...args.card,
bankName,
paymentSystem:
(["mir", "mastercard", "visa", "mir", null] as const)[index] ??
null,
}}
/>
),
)}
</SimpleGrid>
),
};
export const MissingDetails: Story = {
args: {
card: {
...meta.args.card,
bankName: null,
lastFour: null,
paymentSystem: null,
},
},
};
export const LongArchived: Story = {
args: {
card: {
...meta.args.card,
name: "Карта с очень длинным пользовательским названием для повседневных покупок",
bankName: "Банк с очень длинным названием без пробелов".repeat(2),
archivedAt: "2026-09-12",
paymentSystem: "mastercard",
balance: { value: "184467440737095516.14", currency: "RUB" },
},
},
};
@@ -0,0 +1,67 @@
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { formatMoney } from "@/shared/lib";
import type { BankCard } from "../api/queries";
import classes from "./BankCardFace.module.css";
type BankCardFaceProps = { card: BankCard };
const BankCardFace: FC<BankCardFaceProps> = (props) => {
const { card } = props;
const { t } = useTranslation();
const bank = card.bankName?.toLocaleLowerCase("ru") ?? "";
const tone = /сбер|sber/.test(bank)
? "green"
: /тинькофф|т[ -]?банк|t[ -]?bank|tinkoff/.test(bank)
? "yellow"
: /альфа|alfa/.test(bank)
? "red"
: /втб|vtb|газпром|gazprom/.test(bank)
? "blue"
: "graphite";
return (
<div className={classes.face} data-tone={tone}>
<div className={classes.top}>
<span className={classes.bank}>{card.bankName || t("bank.card")}</span>
{card.archivedAt && (
<span className={classes.archive}>{t("bank.archived")}</span>
)}
</div>
<div className={classes.hardware} aria-hidden="true">
<span className={classes.chip} />
<svg
aria-hidden="true"
width="24"
height="30"
viewBox="0 0 24 30"
fill="none"
>
<path
d="M7 11a8 8 0 0 1 0 8m5-12a15 15 0 0 1 0 16m5-20a23 23 0 0 1 0 24"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
</div>
<div
className={classes.number}
>{`•••• •••• •••• ${card.lastFour || "••••"}`}</div>
<div className={classes.bottom}>
<div className={classes.details}>
<div className={classes.name}>{card.name}</div>
<div className={classes.balance}>{formatMoney(card.balance)}</div>
</div>
{card.paymentSystem && (
<span className={classes.network} data-network={card.paymentSystem}>
{card.paymentSystem === "mastercard" && (
<span className={classes.circles} aria-hidden="true" />
)}
{t(`bank.paymentSystems.${card.paymentSystem}`)}
</span>
)}
</div>
</div>
);
};
export { BankCardFace };
@@ -14,6 +14,7 @@ const card: BankCard = {
name: "Основная",
bankName: "Банк",
lastFour: "1234",
paymentSystem: null,
openingBalance: { currency: "RUB", value: "100" },
balance: { currency: "RUB", value: "50" },
openingDate: "2026-01-01",
@@ -62,8 +63,30 @@ describe("BankCardForm", () => {
name: "Основная",
bankName: null,
lastFour: null,
paymentSystem: null,
});
});
it("loads and saves the selected payment system", async () => {
const close = setup({ ...card, paymentSystem: "mir" });
expect(
screen.getByRole("combobox", { name: /Платёжная система/ }),
).toHaveValue("МИР");
fireEvent.click(
screen.getByRole("combobox", { name: /Платёжная система/ }),
);
fireEvent.click(await screen.findByRole("option", { name: "Visa" }));
let body: unknown;
server.use(
http.patch("*/api/v1/bank-cards/a", async ({ request }) => {
body = await request.json();
return HttpResponse.json({ ...card, paymentSystem: "visa" });
}),
);
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
await waitFor(() => expect(close).toHaveBeenCalled());
expect(body).toMatchObject({ paymentSystem: "visa" });
});
it("retains an invalid opening date draft and explains conflict with saved history", async () => {
const close = setup(card);
fireEvent.change(screen.getByLabelText(/Дата начала/), {
@@ -33,6 +33,7 @@ const BankCardForm: FC<BankCardFormProps> = (props) => {
name: props.card?.name ?? "",
bankName: props.card?.bankName ?? "",
lastFour: props.card?.lastFour ?? "",
paymentSystem: props.card?.paymentSystem ?? "",
openingBalance: props.card?.openingBalance.value ?? "0",
openingDate: props.card?.openingDate ?? bankToday(),
},
@@ -57,6 +58,7 @@ const BankCardForm: FC<BankCardFormProps> = (props) => {
name: values.name,
bankName: values.bankName || null,
lastFour: values.lastFour || null,
paymentSystem: values.paymentSystem || null,
};
const financial = {
openingBalance: {
@@ -1,4 +1,4 @@
import { Stack, Text, TextInput } from "@mantine/core";
import { Select, Stack, Text, TextInput } from "@mantine/core";
import { DateInput } from "@mantine/dates";
import type { FC } from "react";
import { Controller, useFormContext } from "react-hook-form";
@@ -37,6 +37,26 @@ const BankCardFields: FC<BankCardFieldsProps> = (props) => {
{...register("lastFour")}
error={errors.lastFour?.message}
/>
<Controller
control={control}
name="paymentSystem"
render={({ field }) => (
<Select
{...field}
value={field.value || null}
onChange={(value) => field.onChange(value ?? "")}
label={t("bank.paymentSystem")}
placeholder={t("bank.paymentSystemUnknown")}
clearable
data={["mir", "visa", "mastercard"].map((value) => ({
value,
label: t(`bank.paymentSystems.${value}`),
}))}
comboboxProps={{ withinPortal: false }}
error={errors.paymentSystem?.message}
/>
)}
/>
<TextInput
label={t("bank.openingBalance")}
required