diff --git a/frontend/app/app.vue b/frontend/app/app.vue new file mode 100644 index 0000000..6937d37 --- /dev/null +++ b/frontend/app/app.vue @@ -0,0 +1,37 @@ + + + diff --git a/frontend/app/assets/css/main.css b/frontend/app/assets/css/main.css new file mode 100644 index 0000000..c7eda9a --- /dev/null +++ b/frontend/app/assets/css/main.css @@ -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; + } +} diff --git a/frontend/app/components/BrandLogo.vue b/frontend/app/components/BrandLogo.vue new file mode 100644 index 0000000..317c2ce --- /dev/null +++ b/frontend/app/components/BrandLogo.vue @@ -0,0 +1,17 @@ + + + diff --git a/frontend/app/components/CardConverter.test.ts b/frontend/app/components/CardConverter.test.ts new file mode 100644 index 0000000..5c51ff3 --- /dev/null +++ b/frontend/app/components/CardConverter.test.ts @@ -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( + 'تبدیل این شماره کارت انجام نشد.', + ) + }) +}) diff --git a/frontend/app/components/CardConverter.vue b/frontend/app/components/CardConverter.vue new file mode 100644 index 0000000..a9681a2 --- /dev/null +++ b/frontend/app/components/CardConverter.vue @@ -0,0 +1,298 @@ + + + + + diff --git a/frontend/app/utils/card.test.ts b/frontend/app/utils/card.test.ts new file mode 100644 index 0000000..c3cd982 --- /dev/null +++ b/frontend/app/utils/card.test.ts @@ -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') + }) +}) diff --git a/frontend/app/utils/card.ts b/frontend/app/utils/card.ts new file mode 100644 index 0000000..897e281 --- /dev/null +++ b/frontend/app/utils/card.ts @@ -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 ') +} diff --git a/frontend/public/brand/zarinpal-logo-sheet.jpg b/frontend/public/brand/zarinpal-logo-sheet.jpg new file mode 100644 index 0000000..eaeef14 Binary files /dev/null and b/frontend/public/brand/zarinpal-logo-sheet.jpg differ diff --git a/frontend/public/brand/zarinpal-mark.png b/frontend/public/brand/zarinpal-mark.png new file mode 100644 index 0000000..dbbfc88 Binary files /dev/null and b/frontend/public/brand/zarinpal-mark.png differ diff --git a/frontend/public/fonts/FontLicense.txt b/frontend/public/fonts/FontLicense.txt new file mode 100644 index 0000000..c3ad570 --- /dev/null +++ b/frontend/public/fonts/FontLicense.txt @@ -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 +در اين پروژه استفاده می شود + +''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''' +در صورت استفاده از این مجموعه فونت در یک محصول نرم افزاری‌ یا وب‌سایت کد ۶ رقمی لایسنس خود را در محل مشخص شده (.....) درج کنید و این فایل را در کنار فونت ها قرار دهید. diff --git a/frontend/public/fonts/IRANYekanXVFaNumVF.woff2 b/frontend/public/fonts/IRANYekanXVFaNumVF.woff2 new file mode 100644 index 0000000..c57aecc Binary files /dev/null and b/frontend/public/fonts/IRANYekanXVFaNumVF.woff2 differ