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 ')
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -0,0 +1,18 @@
IRANYekanX fonts are considered a proprietary software.
To gain information about the laws regarding the use of these fonts, please visit www.fontiran.com
This set of fonts are used in this project under the license: (.....)
'''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''
In case you use this set of fonts in a software product, write the license code in the specified place and place this file next to the fonts.
'''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''
فونت های ایران‌یکانX يک نرم افزار مالکيتی محسوب می شود جهت آگاهی از قوانين استفاده از اين فونت‌ها لطفاً به وبسايت
www.fontiran.com
مراجعه نماييد
اين مجموعه فونت تحت اجازه نامه
736912
در اين پروژه استفاده می شود
'''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''
در صورت استفاده از این مجموعه فونت در یک محصول نرم افزاری‌ یا وب‌سایت کد ۶ رقمی لایسنس خود را در محل مشخص شده (.....) درج کنید و این فایل را در کنار فونت ها قرار دهید.

Binary file not shown.