feat(frontend): add card-to-iban interface
This commit is contained in:
37
frontend/app/app.vue
Normal file
37
frontend/app/app.vue
Normal 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>
|
||||||
105
frontend/app/assets/css/main.css
Normal file
105
frontend/app/assets/css/main.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
17
frontend/app/components/BrandLogo.vue
Normal file
17
frontend/app/components/BrandLogo.vue
Normal 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>
|
||||||
81
frontend/app/components/CardConverter.test.ts
Normal file
81
frontend/app/components/CardConverter.test.ts
Normal 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(
|
||||||
|
'تبدیل این شماره کارت انجام نشد.',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
298
frontend/app/components/CardConverter.vue
Normal file
298
frontend/app/components/CardConverter.vue
Normal 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>
|
||||||
23
frontend/app/utils/card.test.ts
Normal file
23
frontend/app/utils/card.test.ts
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
23
frontend/app/utils/card.ts
Normal file
23
frontend/app/utils/card.ts
Normal 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 ')
|
||||||
|
}
|
||||||
BIN
frontend/public/brand/zarinpal-logo-sheet.jpg
Normal file
BIN
frontend/public/brand/zarinpal-logo-sheet.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
BIN
frontend/public/brand/zarinpal-mark.png
Normal file
BIN
frontend/public/brand/zarinpal-mark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
18
frontend/public/fonts/FontLicense.txt
Normal file
18
frontend/public/fonts/FontLicense.txt
Normal 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
|
||||||
|
در اين پروژه استفاده می شود
|
||||||
|
|
||||||
|
'''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''
|
||||||
|
در صورت استفاده از این مجموعه فونت در یک محصول نرم افزاری یا وبسایت کد ۶ رقمی لایسنس خود را در محل مشخص شده (.....) درج کنید و این فایل را در کنار فونت ها قرار دهید.
|
||||||
BIN
frontend/public/fonts/IRANYekanXVFaNumVF.woff2
Normal file
BIN
frontend/public/fonts/IRANYekanXVFaNumVF.woff2
Normal file
Binary file not shown.
Reference in New Issue
Block a user