Files
card-to-iban/frontend/app/components/CardConverter.vue

299 lines
9.1 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 px-4 py-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-2.5 py-4 text-center font-mono text-xs font-bold tracking-normal whitespace-nowrap min-[390px]:text-sm sm:px-4 sm:py-5 sm:text-xl sm:tracking-[0.04em]"
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>