299 lines
9.1 KiB
Vue
299 lines
9.1 KiB
Vue
<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>
|