diff --git a/src/entities/bank-card/api/banking.test.tsx b/src/entities/bank-card/api/banking.test.tsx index 0a180b7..39d3374 100644 --- a/src/entities/bank-card/api/banking.test.tsx +++ b/src/entities/bank-card/api/banking.test.tsx @@ -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" }, diff --git a/src/entities/bank-card/index.ts b/src/entities/bank-card/index.ts index 16cd0b6..ec22e8d 100644 --- a/src/entities/bank-card/index.ts +++ b/src/entities/bank-card/index.ts @@ -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"; diff --git a/src/entities/bank-card/model/form-validation.ts b/src/entities/bank-card/model/form-validation.ts index dd3d7a2..e1b60da 100644 --- a/src/entities/bank-card/model/form-validation.ts +++ b/src/entities/bank-card/model/form-validation.ts @@ -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, }); diff --git a/src/entities/bank-card/ui/BankCardFace.module.css b/src/entities/bank-card/ui/BankCardFace.module.css new file mode 100644 index 0000000..63fd0e0 --- /dev/null +++ b/src/entities/bank-card/ui/BankCardFace.module.css @@ -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; +} diff --git a/src/entities/bank-card/ui/BankCardFace.stories.tsx b/src/entities/bank-card/ui/BankCardFace.stories.tsx new file mode 100644 index 0000000..b4e8840 --- /dev/null +++ b/src/entities/bank-card/ui/BankCardFace.stories.tsx @@ -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; +export default meta; +type Story = StoryObj; +export const Banks: Story = { + render: (args) => ( + + {["Сбербанк", "Т-Банк", "Альфа-Банк", "ВТБ", null].map( + (bankName, index) => ( + + ), + )} + + ), +}; +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" }, + }, + }, +}; diff --git a/src/entities/bank-card/ui/BankCardFace.tsx b/src/entities/bank-card/ui/BankCardFace.tsx new file mode 100644 index 0000000..4ff80a1 --- /dev/null +++ b/src/entities/bank-card/ui/BankCardFace.tsx @@ -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 = (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 ( +
+
+ {card.bankName || t("bank.card")} + {card.archivedAt && ( + {t("bank.archived")} + )} +
+ +
{`•••• •••• •••• ${card.lastFour || "••••"}`}
+
+
+
{card.name}
+
{formatMoney(card.balance)}
+
+ {card.paymentSystem && ( + + {card.paymentSystem === "mastercard" && ( + + )} +
+
+ ); +}; + +export { BankCardFace }; diff --git a/src/entities/bank-card/ui/BankCardForm.test.tsx b/src/entities/bank-card/ui/BankCardForm.test.tsx index 98ce96c..5bf9777 100644 --- a/src/entities/bank-card/ui/BankCardForm.test.tsx +++ b/src/entities/bank-card/ui/BankCardForm.test.tsx @@ -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(/Дата начала/), { diff --git a/src/entities/bank-card/ui/BankCardForm.tsx b/src/entities/bank-card/ui/BankCardForm.tsx index 883e637..d2104be 100644 --- a/src/entities/bank-card/ui/BankCardForm.tsx +++ b/src/entities/bank-card/ui/BankCardForm.tsx @@ -33,6 +33,7 @@ const BankCardForm: FC = (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 = (props) => { name: values.name, bankName: values.bankName || null, lastFour: values.lastFour || null, + paymentSystem: values.paymentSystem || null, }; const financial = { openingBalance: { diff --git a/src/entities/bank-card/ui/BankCardForm.view.tsx b/src/entities/bank-card/ui/BankCardForm.view.tsx index b5ef8bf..1a616d8 100644 --- a/src/entities/bank-card/ui/BankCardForm.view.tsx +++ b/src/entities/bank-card/ui/BankCardForm.view.tsx @@ -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 = (props) => { {...register("lastFour")} error={errors.lastFour?.message} /> + ( +