feat(frontend): add card-to-iban interface

This commit is contained in:
2026-09-21 15:08:41 +03:30
parent ff76c69ab1
commit 3bbdae60db
11 changed files with 602 additions and 0 deletions

37
frontend/app/app.vue Normal file
View File

@@ -0,0 +1,37 @@
<script setup lang="ts">
const config = useRuntimeConfig()
</script>
<template>
<div class="min-h-dvh">
<header class="border-border bg-surface h-14 border-b shadow-[0_1px_2px_rgba(0,0,0,.03)]">
<div class="mx-auto flex h-full w-full max-w-[1120px] items-center px-4 sm:px-6 lg:px-8">
<BrandLogo />
</div>
</header>
<main class="mx-auto w-full max-w-[760px] px-4 py-10 sm:px-6 sm:py-14">
<section aria-labelledby="page-title">
<p class="text-brand mb-2 text-sm font-bold">سرویس تبدیل کارت به شبا</p>
<h1 id="page-title" class="text-foreground text-2xl leading-[1.5] font-extrabold sm:text-3xl">
شماره شبا را سریع و مطمئن پیدا کنید
</h1>
<p class="text-muted mt-3 max-w-[620px] text-sm leading-8 sm:text-[15px]">
استعلام مستقیماً از سرویس زرین‌هاب انجام می‌شود. شماره کامل کارت در گزارش‌ها یا پاسخ عمومی نگهداری نمی‌شود.
</p>
</section>
<div class="mt-7 sm:mt-8">
<CardConverter :api-base-url="config.public.apiBaseUrl" />
</div>
<p class="text-muted mt-5 flex items-start gap-2 text-xs leading-6">
<svg viewBox="0 0 24 24" class="mt-0.5 size-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10Z" />
<path d="m9 12 2 2 4-4" />
</svg>
برای حفاظت از اطلاعات بانکی، فقط نسخه پوشیده‌شده شماره کارت در سوابق فنی ثبت می‌شود.
</p>
</main>
</div>
</template>

View File

@@ -0,0 +1,105 @@
@import 'tailwindcss';
@font-face {
font-family: 'IRANYekanX';
src: url('/fonts/IRANYekanXVFaNumVF.woff2') format('woff2-variations');
font-weight: 100 1000;
font-display: swap;
}
:root {
color-scheme: light;
--background: #f5f5f6;
--surface: #ffffff;
--foreground: #19191a;
--muted: #71717a;
--border: #e4e4e7;
--input: #d4d4d8;
--brand: #0a33ff;
--brand-hover: #0828cc;
--on-brand: #ffffff;
--accent: #eef1ff;
--success: #248061;
--danger: #c93637;
}
@theme inline {
--font-sans: 'IRANYekanX', Tahoma, sans-serif;
--color-background: var(--background);
--color-surface: var(--surface);
--color-foreground: var(--foreground);
--color-muted: var(--muted);
--color-border: var(--border);
--color-input: var(--input);
--color-brand: var(--brand);
--color-brand-hover: var(--brand-hover);
--color-on-brand: var(--on-brand);
--color-accent: var(--accent);
--color-success: var(--success);
--color-danger: var(--danger);
}
* {
box-sizing: border-box;
}
html {
min-width: 320px;
min-height: 100%;
direction: rtl;
background: var(--background);
font-family: 'IRANYekanX', Tahoma, sans-serif;
}
body {
min-width: 320px;
min-height: 100%;
margin: 0;
background: var(--background);
color: var(--foreground);
font-family: inherit;
line-height: 1.8;
}
button,
input {
font: inherit;
}
button {
cursor: pointer;
}
button:disabled {
cursor: not-allowed;
}
:focus-visible {
outline: 1px solid var(--brand);
outline-offset: 2px;
}
input:focus-visible {
outline: none;
}
::selection {
background: #ffd400;
color: #19191a;
}
.card {
border-radius: 8px;
box-shadow: 0 1px 2px rgb(0 0 0 / 5%);
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}

View File

@@ -0,0 +1,17 @@
<template>
<span class="brand-logo" role="img" aria-label="زرین‌پال" />
</template>
<style scoped>
.brand-logo {
display: block;
width: 124px;
height: 31px;
flex: 0 0 auto;
overflow: hidden;
background-image: url('/brand/zarinpal-logo-sheet.jpg');
background-repeat: no-repeat;
background-position: -12px -10px;
background-size: 144px 144px;
}
</style>

View File

@@ -0,0 +1,81 @@
import { flushPromises, mount } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import CardConverter from './CardConverter.vue'
afterEach(() => {
vi.unstubAllGlobals()
})
describe('CardConverter', () => {
it('shows client validation without calling the API', async () => {
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
const wrapper = mount(CardConverter, {
props: { apiBaseUrl: 'http://127.0.0.1:8080' },
})
await wrapper.get('input').setValue('۱۲۳')
await wrapper.get('form').trigger('submit')
expect(fetchMock).not.toHaveBeenCalled()
expect(wrapper.get('[role="alert"]').text()).toContain('۱۶ رقم')
})
it('renders the returned iban after a successful request', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
data: { iban: 'IR820540102680020817909002' },
requestId: 'request-test',
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
),
)
const wrapper = mount(CardConverter, {
props: { apiBaseUrl: 'http://127.0.0.1:8080' },
attachTo: document.body,
})
await wrapper.get('input').setValue('۶۰۳۷ ۹۹۷۵ ۱۲۳۴ ۵۶۷۸')
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(wrapper.text()).toContain('IR82 0540 1026 8002 0817 9090 02')
expect(document.activeElement?.getAttribute('data-result')).toBe('iban')
wrapper.unmount()
})
it('renders the safe API error message', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
error: {
code: 'conversion_rejected',
message: 'تبدیل این شماره کارت انجام نشد.',
},
requestId: 'request-test',
}),
{ status: 422, headers: { 'Content-Type': 'application/json' } },
),
),
)
const wrapper = mount(CardConverter, {
props: { apiBaseUrl: 'http://127.0.0.1:8080' },
})
await wrapper.get('input').setValue('6037997512345678')
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(wrapper.get('[role="alert"]').text()).toContain(
'تبدیل این شماره کارت انجام نشد.',
)
})
})

View File

@@ -0,0 +1,298 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref } from 'vue'
import { formatCard, formatIBAN, normalizeCard, validCard } from '../utils/card'
type SuccessResponse = {
data: { iban: string }
requestId: string
}
type ErrorResponse = {
error?: { code?: string; message?: string }
requestId?: string
}
const props = defineProps<{
apiBaseUrl: string
}>()
const cardInput = ref('')
const iban = ref('')
const inputInvalid = ref(false)
const submitting = ref(false)
const resultElement = ref<HTMLElement | null>(null)
const toast = ref<{ message: string; tone: 'success' | 'error' } | null>(null)
let toastTimer: ReturnType<typeof setTimeout> | undefined
const formattedIBAN = computed(() => formatIBAN(iban.value))
function dismissToast() {
if (toastTimer) {
clearTimeout(toastTimer)
toastTimer = undefined
}
toast.value = null
}
function showToast(message: string, tone: 'success' | 'error') {
dismissToast()
toast.value = { message, tone }
toastTimer = setTimeout(dismissToast, 4000)
}
function clearFeedback() {
inputInvalid.value = false
dismissToast()
}
function onCardInput(event: Event) {
const input = event.target as HTMLInputElement
const cursor = input.selectionStart ?? input.value.length
const digitsBeforeCursor = normalizeCard(input.value.slice(0, cursor)).replace(/\D/g, '').length
const formatted = formatCard(input.value)
cardInput.value = formatted
input.value = formatted
clearFeedback()
nextTick(() => {
let nextCursor = 0
let seenDigits = 0
while (nextCursor < formatted.length && seenDigits < digitsBeforeCursor) {
if (/\d/.test(formatted[nextCursor] ?? '')) {
seenDigits++
}
nextCursor++
}
input.setSelectionRange(nextCursor, nextCursor)
})
}
async function submit() {
clearFeedback()
iban.value = ''
const card = normalizeCard(cardInput.value)
if (!validCard(card)) {
inputInvalid.value = true
showToast('شماره کارت باید شامل ۱۶ رقم باشد.', 'error')
return
}
submitting.value = true
try {
const response = await fetch(
`${props.apiBaseUrl.replace(/\/$/, '')}/api/v1/card-to-iban`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ card }),
},
)
const payload = (await response.json()) as SuccessResponse & ErrorResponse
if (!response.ok) {
showToast(
payload.error?.message ?? 'در حال حاضر امکان تبدیل شماره کارت وجود ندارد.',
'error',
)
return
}
if (!payload.data?.iban) {
showToast('پاسخ سرویس کامل نبود. دوباره تلاش کنید.', 'error')
return
}
iban.value = payload.data.iban
showToast('استعلام با موفقیت انجام شد.', 'success')
await nextTick()
resultElement.value?.focus()
} catch {
showToast('ارتباط با سرویس برقرار نشد. دوباره تلاش کنید.', 'error')
} finally {
submitting.value = false
}
}
async function copyIBAN() {
try {
await navigator.clipboard.writeText(iban.value)
showToast('شماره شبا کپی شد.', 'success')
} catch {
showToast('کپی خودکار انجام نشد.', 'error')
}
}
onBeforeUnmount(dismissToast)
</script>
<template>
<Teleport to="body">
<Transition name="toast">
<div
v-if="toast"
dir="rtl"
class="bg-surface fixed inset-x-4 top-4 z-50 flex items-center gap-3 rounded-lg border px-4 py-3 text-sm shadow-lg sm:right-auto sm:left-6 sm:w-[360px]"
:class="toast.tone === 'success' ? 'border-success/30' : 'border-danger/30'"
:role="toast.tone === 'error' ? 'alert' : 'status'"
:aria-live="toast.tone === 'error' ? 'assertive' : 'polite'"
>
<span
class="grid size-8 shrink-0 place-items-center rounded-full"
:class="toast.tone === 'success' ? 'bg-success/10 text-success' : 'bg-danger/10 text-danger'"
aria-hidden="true"
>
<svg
v-if="toast.tone === 'success'"
viewBox="0 0 24 24"
class="size-5"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="m5 12 4 4L19 6" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<svg
v-else
viewBox="0 0 24 24"
class="size-5"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M12 8v5m0 3.5v.01" stroke-linecap="round" />
<circle cx="12" cy="12" r="9" />
</svg>
</span>
<span class="text-foreground min-w-0 flex-1 leading-6">{{ toast.message }}</span>
<button
type="button"
class="text-muted hover:bg-workspace hover:text-foreground focus-visible:outline-brand -ml-1 grid size-8 shrink-0 place-items-center rounded-md transition-colors focus-visible:outline-2 focus-visible:outline-offset-2"
aria-label="بستن اعلان"
@click="dismissToast"
>
<svg viewBox="0 0 24 24" class="size-4" fill="none" stroke="currentColor" stroke-width="2">
<path d="m7 7 10 10M17 7 7 17" stroke-linecap="round" />
</svg>
</button>
</div>
</Transition>
</Teleport>
<section class="card border-border bg-surface overflow-hidden border">
<div class="border-border border-b px-5 py-4 sm:px-6">
<div class="flex items-start gap-3">
<span
class="bg-accent text-brand mt-0.5 grid size-9 shrink-0 place-items-center rounded-[7px]"
aria-hidden="true"
>
<svg viewBox="0 0 24 24" class="size-5" fill="none" stroke="currentColor" stroke-width="1.8">
<rect x="3" y="5" width="18" height="14" rx="2" />
<path d="M3 10h18M7 15h4" />
</svg>
</span>
<div>
<h2 class="text-lg font-extrabold sm:text-xl">اطلاعات کارت</h2>
<p class="text-muted mt-1 text-sm leading-7">
شماره کارت را وارد کنید تا شماره شبای متصل به آن نمایش داده شود.
</p>
</div>
</div>
</div>
<form class="space-y-5 p-5 sm:p-6" novalidate @submit.prevent="submit">
<div>
<label for="card-number" class="mb-2 block text-sm font-bold">شماره کارت</label>
<div class="relative">
<input
id="card-number"
:value="cardInput"
class="border-input focus:border-brand h-12 w-full rounded-lg border bg-white px-4 text-left font-mono text-base tracking-[0.08em] outline-none transition-colors"
type="text"
dir="ltr"
inputmode="numeric"
autocomplete="cc-number"
maxlength="19"
placeholder="6037 9975 1234 5678"
:aria-invalid="inputInvalid"
aria-describedby="card-hint"
:disabled="submitting"
@input="onCardInput"
/>
</div>
<p id="card-hint" class="text-muted mt-2 text-xs leading-6">
می‌توانید ارقام فارسی یا انگلیسی را با فاصله وارد کنید.
</p>
</div>
<button
class="bg-brand hover:bg-brand-hover text-on-brand flex h-12 w-full items-center justify-center gap-2 rounded-lg px-5 text-sm font-bold transition-colors disabled:cursor-not-allowed disabled:opacity-55"
type="submit"
:disabled="submitting"
>
<span
v-if="submitting"
class="size-4 animate-spin rounded-full border-2 border-white/40 border-t-white"
aria-hidden="true"
/>
{{ submitting ? 'در حال استعلام…' : 'تبدیل به شماره شبا' }}
</button>
</form>
<div
v-if="iban"
ref="resultElement"
data-result="iban"
class="border-border bg-accent/55 border-t p-5 outline-none sm:p-6"
tabindex="-1"
aria-live="polite"
>
<div class="mb-3 flex items-center justify-between gap-4">
<div>
<p class="text-muted text-xs font-semibold">شماره شبای متصل</p>
</div>
<button
type="button"
class="border-border text-brand hover:bg-surface min-h-11 rounded-lg border bg-white px-4 text-sm font-bold transition-colors"
@click="copyIBAN"
>
کپی شبا
</button>
</div>
<output
class="border-brand/15 bg-surface block overflow-x-auto rounded-lg border px-4 py-5 text-center font-mono text-lg font-bold tracking-[0.04em] whitespace-nowrap sm:text-xl"
dir="ltr"
>{{ formattedIBAN }}</output>
</div>
</section>
</template>
<style scoped>
.toast-enter-active,
.toast-leave-active {
transition: opacity 160ms ease, transform 160ms ease;
}
.toast-enter-from,
.toast-leave-to {
opacity: 0;
transform: translateY(-8px);
}
@media (prefers-reduced-motion: reduce) {
.toast-enter-active,
.toast-leave-active {
transition: none;
}
}
</style>

View File

@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest'
import { formatCard, normalizeCard, validCard } from './card'
describe('card utilities', () => {
it('normalizes Persian and Arabic digits with common separators', () => {
expect(normalizeCard('۶۰۳۷ ٩٩۷۵-۱۲۳۴ ۵۶۷۸')).toBe('6037997512345678')
})
it('keeps unexpected characters so validation can report them', () => {
expect(normalizeCard('6037 ABC 1234')).toBe('6037ABC1234')
})
it('accepts only sixteen ASCII digits', () => {
expect(validCard('6037997512345678')).toBe(true)
expect(validCard('603799751234567')).toBe(false)
expect(validCard('603799ABC2345678')).toBe(false)
})
it('groups a normalized card for display', () => {
expect(formatCard('6037997512345678')).toBe('6037 9975 1234 5678')
})
})

View File

@@ -0,0 +1,23 @@
const persianDigits = '۰۱۲۳۴۵۶۷۸۹'
const arabicDigits = '٠١٢٣٤٥٦٧٨٩'
export function normalizeCard(value: string): string {
return value
.replace(/[۰-۹]/g, (digit) => String(persianDigits.indexOf(digit)))
.replace(/[٠-٩]/g, (digit) => String(arabicDigits.indexOf(digit)))
.replace(/[\s-]/g, '')
}
export function validCard(value: string): boolean {
return /^\d{16}$/.test(value)
}
export function formatCard(value: string): string {
const digits = normalizeCard(value).replace(/\D/g, '').slice(0, 16)
return digits.replace(/(\d{4})(?=\d)/g, '$1 ')
}
export function formatIBAN(value: string): string {
return value.replace(/(.{4})(?=.)/g, '$1 ')
}