feat(frontend): add card-to-iban interface
This commit is contained in:
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>
|
||||
Reference in New Issue
Block a user