diff --git a/static/js/chaharsu/payments.js b/static/js/chaharsu/payments.js index 2991215..bb626de 100644 --- a/static/js/chaharsu/payments.js +++ b/static/js/chaharsu/payments.js @@ -4,12 +4,13 @@ let toast; function status(text){if(!toast){toast=document.createElement('div');toast.className='pay-copy-status';toast.setAttribute('role','status');document.body.append(toast);}toast.textContent=text;toast.hidden=false;setTimeout(()=>{toast.hidden=true;},3000);} async function copy(text){ - try{await navigator.clipboard.writeText(text);status('کپی شد');} + try{await navigator.clipboard.writeText(text);status('کپی شد');return true;} catch{ const dialog=document.createElement('dialog');dialog.className='pay-manual-copy';dialog.setAttribute('aria-label','کپی اطلاعات'); const title=document.createElement('h2');title.textContent='متن را انتخاب و کپی کنید';const area=document.createElement('textarea');area.readOnly=true;area.value=text; const select=document.createElement('button');select.className='pay-button';select.textContent='انتخاب متن';select.onclick=()=>{area.focus();area.select();}; const close=document.createElement('button');close.className='pay-button secondary';close.textContent='بستن';close.onclick=()=>dialog.close();dialog.append(title,area,select,close);document.body.append(dialog);dialog.addEventListener('close',()=>dialog.remove());dialog.showModal();area.focus();area.select(); + return false; } } document.querySelectorAll('[data-copy]').forEach(button=>button.addEventListener('click',()=>copy(button.dataset.copy))); @@ -17,18 +18,26 @@ document.querySelectorAll('[data-notice-sms]').forEach(form=>form.addEventListen if(form.dataset.sending==='true'){event.preventDefault();return;} form.dataset.sending='true';const button=form.querySelector('button');button.disabled=true;button.textContent='در حال ثبت درخواست ارسال…'; })); -document.querySelectorAll('[data-share-link]').forEach(form=>form.addEventListener('submit',async event=>{ +document.querySelectorAll('[data-share-link]').forEach(form=>{ + const button=form.querySelector('button'),label=button.querySelector('span'),originalLabel=label.textContent; + const feedback=form.querySelector('[role="status"]'); + form.addEventListener('submit',async event=>{ event.preventDefault(); - const button=form.querySelector('button'),feedback=form.querySelector('[role="status"]'); - button.disabled=true;button.setAttribute('aria-busy','true');feedback.textContent='در حال آماده‌سازی لینک…'; + // This AJAX form owns its button state; keep the generic navigation submit handler out. + event.stopPropagation(); + if(button.disabled)return; + button.disabled=true;button.setAttribute('aria-busy','true');label.textContent='در حال آماده‌سازی لینک…';feedback.textContent='در حال آماده‌سازی لینک…'; try{ const response=await fetch(form.action,{method:'POST',body:new FormData(form),credentials:'same-origin',cache:'no-store',headers:{'Accept':'application/json'}}); if(!response.ok || !response.headers.get('Content-Type')?.includes('application/json'))throw new Error('share unavailable'); const data=await response.json(); - await copy(data.url);feedback.textContent='لینک آماده است؛ می‌توانید آن را برای دیگران ارسال کنید.'; - }catch{feedback.textContent='دریافت لینک ممکن نشد. دوباره تلاش کنید یا لینک پرداخت دریافتی را باز کنید.';} + const copied=await copy(data.url); + label.textContent=copied?'لینک کپی شد؛ کپی دوباره':originalLabel; + feedback.textContent=copied?'لینک کوتاه کپی شد؛ می‌توانید آن را برای دیگران ارسال کنید.':'لینک آماده است؛ کپی خودکار ممکن نشد. متن را از پنجرهٔ بازشده انتخاب و کپی کنید.'; + }catch{label.textContent=originalLabel;feedback.textContent='دریافت لینک ممکن نشد. دوباره تلاش کنید یا لینک پرداخت دریافتی را باز کنید.';} finally{button.disabled=false;button.removeAttribute('aria-busy');} -})); + }); +}); const identifiers=document.querySelector('#identifier-accounts'),account=document.querySelector('#id_account'),deposit=document.querySelector('#id_deposit_id'); if(identifiers && account && deposit){const ids=JSON.parse(identifiers.textContent).map(String);function update(){deposit.required=ids.includes(account.value);document.querySelector('#identifier-help').textContent=deposit.required?'این حساب شناسه‌دار است؛ شناسه واریز مشتری الزامی است.':'شناسه واریز در صورت اعلام بانک وارد شود.';}account.addEventListener('change',update);update();} const badge=document.querySelector('[data-payment-count]'); diff --git a/static/js/chaharsu/receipt-amount.js b/static/js/chaharsu/receipt-amount.js index 647ef14..affef78 100644 --- a/static/js/chaharsu/receipt-amount.js +++ b/static/js/chaharsu/receipt-amount.js @@ -6,6 +6,7 @@ const input=document.querySelector('#id_paid_amount, #id_amount');if(!input)return; input.inputMode='numeric'; const words=document.querySelector('#amount-words'); +if(words)input.setAttribute('aria-describedby',[input.getAttribute('aria-describedby'),words.id].filter(Boolean).join(' ')); const small=['صفر','یک','دو','سه','چهار','پنج','شش','هفت','هشت','نه','ده','یازده','دوازده','سیزده','چهارده','پانزده','شانزده','هفده','هجده','نوزده']; const tens=['','','بیست','سی','چهل','پنجاه','شصت','هفتاد','هشتاد','نود']; const hundreds=['','صد','دویست','سیصد','چهارصد','پانصد','ششصد','هفتصد','هشتصد','نهصد']; diff --git a/templates/payments/_field.html b/templates/payments/_field.html index dba9d78..ec300a6 100644 --- a/templates/payments/_field.html +++ b/templates/payments/_field.html @@ -1,6 +1,7 @@
{{ field }} + {% if field.name == 'amount' %}

{% endif %} {% if hint %}

{{ hint }}

{% endif %} {% if field.help_text %}

{{ field.help_text }}

{% endif %} {% if field.errors %}{% endif %} diff --git a/tests/e2e/pages/payment_feedback.py b/tests/e2e/pages/payment_feedback.py new file mode 100644 index 0000000..68b919e --- /dev/null +++ b/tests/e2e/pages/payment_feedback.py @@ -0,0 +1,25 @@ +from playwright.sync_api import expect + +from .payments import PaymentsPage + + +class PaymentFeedbackPage(PaymentsPage): + def check_amount(self, raw, expected): + self.page.locator('#id_amount').fill(raw) + expect(self.page.locator('#amount-words')).to_have_text(expected) + + def request_share(self): + self.page.locator('[data-share-link] button').click() + + def expect_pending_share(self): + button = self.page.locator('[data-share-link] button') + expect(button).to_be_disabled() + expect(button).to_have_attribute('aria-busy', 'true') + expect(button).to_have_text('در حال آماده‌سازی لینک…') + + def expect_failed_share(self): + button = self.page.locator('[data-share-link] button') + expect(button).to_be_enabled() + expect(button).not_to_have_attribute('aria-busy', 'true') + expect(button).to_have_text('کپی لینک کوتاه این صفحه برای ارسال به دیگران') + expect(self.page.locator('.pay-share-feedback')).to_contain_text('دریافت لینک ممکن نشد') diff --git a/tests/e2e/pages/payment_link.py b/tests/e2e/pages/payment_link.py index edae63d..ce67a87 100644 --- a/tests/e2e/pages/payment_link.py +++ b/tests/e2e/pages/payment_link.py @@ -12,9 +12,10 @@ class PaymentLinkPage(PaymentsPage): self.page.context.grant_permissions(['clipboard-read', 'clipboard-write']) def copied_text(self, button_name): - self.page.get_by_role('button', name=button_name, exact=True).click() + button = self.page.locator('[data-share-link] button') if button_name == 'کپی لینک کوتاه این صفحه برای ارسال به دیگران' else self.page.get_by_role('button', name=button_name, exact=True) + button.click() if button_name == 'کپی لینک کوتاه این صفحه برای ارسال به دیگران': - expect(self.page.locator('.pay-share-feedback')).to_contain_text('لینک آماده است') + expect(self.page.locator('.pay-share-feedback')).to_contain_text('لینک آماده است' if self.manual_copy else 'لینک کوتاه کپی شد') if self.manual_copy: dialog = self.page.get_by_role('dialog') expect(dialog).to_be_visible() @@ -26,7 +27,9 @@ class PaymentLinkPage(PaymentsPage): def copy_short_link(self): value = self.copied_text('کپی لینک کوتاه این صفحه برای ارسال به دیگران') - expect(self.page.locator('.pay-share-feedback')).to_contain_text('لینک آماده است') + expect(self.page.locator('[data-share-link] button')).to_be_enabled() + expect(self.page.locator('[data-share-link] button')).not_to_have_attribute('aria-busy', 'true') + expect(self.page.locator('[data-share-link] button')).to_have_text('کپی لینک کوتاه این صفحه برای ارسال به دیگران' if self.manual_copy else 'لینک کپی شد؛ کپی دوباره') return value def open_link(self, path): diff --git a/tests/e2e/test_payment_feedback_e2e.py b/tests/e2e/test_payment_feedback_e2e.py new file mode 100644 index 0000000..ae060c8 --- /dev/null +++ b/tests/e2e/test_payment_feedback_e2e.py @@ -0,0 +1,55 @@ +import pytest +from django.urls import reverse +from playwright.sync_api import expect + +from apps.payments import services +from tests.e2e.pages.auth import AuthenticationPage +from tests.e2e.pages.payment_feedback import PaymentFeedbackPage +from tests.e2e.pages.payment_link import PaymentLinkPage +from tests.e2e.test_payments_e2e import payment_case, PASSWORD + +pytestmark = [pytest.mark.e2e, pytest.mark.django_db(transaction=True)] + + +@pytest.mark.parametrize('width', [320, 1440]) +def test_notice_toman_words_are_exact_for_small_large_and_localized_rial_inputs(live_server, browser_page, payment_case, width): + customer, account, reviewers = payment_case + samples = [('0', 'صفر'), ('1', 'یک دهم'), ('10', 'یک'), ('19', 'یک و نه دهم'), ('20', 'دو'), + ('101', 'ده و یک دهم'), ('1001', 'صد و یک دهم'), ('1000000', 'صد هزار'), + ('۱۲٬۳۴۵', 'هزار و دویست و سی و چهار و پنج دهم'), ('١٢٣٤٥', 'هزار و دویست و سی و چهار و پنج دهم'), + ('1234567890', 'صد و بیست و سه میلیون و چهارصد و پنجاه و شش هزار و هفتصد و هشتاد و نه'), + ('9007199254740991', 'نهصد تریلیون و هفتصد و نوزده میلیارد و نهصد و بیست و پنج میلیون و چهارصد و هفتاد و چهار هزار و نود و نه و یک دهم')] + with browser_page() as page: + page.set_viewport_size({'width': width, 'height': 900}) + AuthenticationPage(page, live_server.url).login_staff(reviewers[0], PASSWORD) + view = PaymentFeedbackPage(page, live_server.url) + view.goto(reverse('staff_payments:notice_new')) + for raw, words in samples: + view.check_amount(raw, 'معادل: ' + words + ' تومان') + for invalid in ['', '12.5', '-100', '12abc', '9007199254740992']: + view.check_amount(invalid, '') + assert page.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1') + + +def test_share_pending_failure_retry_and_repeated_success_restore_button(live_server, browser_page, payment_case): + customer, account, reviewers = payment_case + notice = services.issue_notice(reviewers[0], {'customer': customer, 'account': account, + 'subject': 'آزمون وضعیت کپی لینک', 'amount': 10000}) + endpoint = live_server.url + reverse('payments:notice_share_link', args=[notice.public_id]) + pending = [] + with browser_page() as page: + view = PaymentLinkPage(page, live_server.url) + view.prepare_copy() + AuthenticationPage(page, live_server.url).login_customer(customer, PASSWORD) + view.goto(reverse('payments:notice_detail', args=[notice.public_id])) + page.route(endpoint, lambda route: pending.append(route)) + feedback = PaymentFeedbackPage(page, live_server.url) + feedback.request_share() + feedback.expect_pending_share() + expect(page.locator('.pay-share-feedback')).to_have_text('در حال آماده‌سازی لینک…') + assert len(pending) == 1 + pending[0].fulfill(status=503, content_type='application/json', body='{}') + feedback.expect_failed_share() + page.unroute(endpoint) + first = view.copy_short_link() + assert view.copy_short_link() == first