From a4fc807160d6923860c58cf1b8ea4efdab50fd70 Mon Sep 17 00:00:00 2001 From: Kazem Alghasi Date: Sat, 31 Oct 2026 19:57:01 +0330 Subject: [PATCH] feat(address-book): implement customer address book system (phase 3.9) Introduce a comprehensive address management system allowing users to save, manage, and reuse sender and receiver addresses during the ordering process. This feature bridges the WordPress frontend with the Laravel backend API. Key changes: - Backend (Laravel): - Added `AddressBookEntry` model and migration for storing addresses with role (sender/receiver), usage tracking, and default status. - Implemented `AddressBookController` with full CRUD functionality secured via Sanctum and ownership validation. - Integrated address usage tracking and upsert logic into the `CustomerOrderController` to prevent duplicate entries. - Added 18 new feature tests covering ownership, limits, and validation. - Frontend (WordPress): - Added a new "Address Book" tab in the user dashboard with card-based UI, filtering, and SweetAlert2 integration. - Enhanced the order form with dropdown selectors for saved addresses and "save this address" checkboxes for both sender and receiver. - Implemented secure AJAX proxies in `user-bridge.php` with nonce and sanitization. - Documentation: - Updated `AGENT.md` and `CLIENT_DELIVERY.md` to reflect phase 3.9 completion and updated the project roadmap. - Removed obsolete `README-INSTALL.md`. --- .../ifnex-bridge/assets/css/ifnex-bridge.css | 206 +++++++++++++ .../ifnex-bridge/assets/css/ifnex-orders.css | 62 ++++ .../assets/js/ifnex-address-book.js | 227 ++++++++++++++ .../assets/js/ifnex-order-form.js | 114 +++++++ .../plugins/ifnex-bridge/ifnex-bridge.php | 2 + .../ifnex-bridge/includes/shortcodes.php | 183 ++++++++++- .../ifnex-bridge/includes/user-bridge.php | 99 ++++++ 04_Laravel/.phpunit.result.cache | 2 +- .../Controllers/Api/AddressBookController.php | 247 +++++++++++++++ .../Api/Customer/CustomerOrderController.php | 87 +++++- 04_Laravel/app/Models/AddressBookEntry.php | 154 ++++++++++ ...0001_create_address_book_entries_table.php | 60 ++++ 04_Laravel/routes/api.php | 9 + .../Feature/Api/AddressBookControllerTest.php | 288 ++++++++++++++++++ AGENT.md | 34 ++- CLIENT_DELIVERY.md | 25 +- README-INSTALL.md | 110 ------- 17 files changed, 1781 insertions(+), 128 deletions(-) create mode 100644 03_WordPress/wp-content/plugins/ifnex-bridge/assets/js/ifnex-address-book.js create mode 100644 04_Laravel/app/Http/Controllers/Api/AddressBookController.php create mode 100644 04_Laravel/app/Models/AddressBookEntry.php create mode 100644 04_Laravel/database/migrations/2026_11_01_000001_create_address_book_entries_table.php create mode 100644 04_Laravel/tests/Feature/Api/AddressBookControllerTest.php delete mode 100644 README-INSTALL.md diff --git a/03_WordPress/wp-content/plugins/ifnex-bridge/assets/css/ifnex-bridge.css b/03_WordPress/wp-content/plugins/ifnex-bridge/assets/css/ifnex-bridge.css index 1e52f79..d5327cc 100644 --- a/03_WordPress/wp-content/plugins/ifnex-bridge/assets/css/ifnex-bridge.css +++ b/03_WordPress/wp-content/plugins/ifnex-bridge/assets/css/ifnex-bridge.css @@ -609,3 +609,209 @@ font-size: 0.875rem !important; margin-bottom: 1rem !important; } + +/* ═══════════════════════════════════════════ + دفترچه آدرس — تب پنل کاربری (فاز ۳.۹) + ═══════════════════════════════════════════ */ + +.ifnx-notice { + padding: 12px 16px; + border-radius: var(--ifnex-radius-md, 10px); + margin-bottom: 1rem; + font-size: 0.875rem; +} +.ifnx-notice-error { + background: var(--ifnex-danger-light, #fee2e2); + border: 1px solid #fecaca; + color: #991b1b; +} + +.ifnx-address-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 12px; + margin-bottom: 18px; +} +.ifnx-address-filters { + display: flex; + gap: 8px; + flex-wrap: wrap; +} +.ifnx-chip { + display: inline-flex; + align-items: center; + padding: 7px 16px; + border-radius: 999px; + font-size: 13px; + font-weight: 600; + color: var(--ifnex-gray-600, #4b5563); + background: var(--ifnex-gray-100, #f3f4f6); + border: 1px solid var(--ifnex-gray-200, #e5e7eb); + text-decoration: none; + transition: all 0.15s ease; +} +.ifnx-chip:hover { + background: var(--ifnex-warning-light, #fef3c7); + border-color: #fcd34d; + color: #92400e; +} +.ifnx-chip.active { + background: var(--ifnex-primary, #f59e0b); + border-color: var(--ifnex-primary, #f59e0b); + color: #fff; +} + +/* فرم افزودن/ویرایش */ +.ifnx-address-form { + background: var(--ifnex-white, #fff); + border: 1px solid var(--ifnex-gray-200, #e5e7eb); + border-radius: var(--ifnex-radius-md, 12px); + padding: 20px; + margin-bottom: 20px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); +} +.ifnx-address-form-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); + gap: 14px; + margin-bottom: 16px; +} +.ifnx-address-form-grid .full { grid-column: 1 / -1; } +.ifnx-addr-checkbox { + display: inline-flex; + align-items: center; + gap: 8px; + cursor: pointer; + font-size: 13px; + font-weight: 500; + color: var(--ifnex-gray-700, #374151); + padding: 8px 12px; + background: var(--ifnex-warning-light, #fef3c7); + border: 1px dashed #fcd34d; + border-radius: 8px; + user-select: none; +} +.ifnx-addr-checkbox input { accent-color: var(--ifnex-primary, #f59e0b); width: 17px; height: 17px; } +.ifnx-address-form-actions { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +/* کارت‌های آدرس */ +.ifnx-address-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 16px; +} +.ifnx-address-card { + background: var(--ifnex-white, #fff); + border: 1px solid var(--ifnex-gray-200, #e5e7eb); + border-radius: var(--ifnex-radius-md, 12px); + padding: 16px; + display: flex; + flex-direction: column; + gap: 8px; + transition: box-shadow 0.15s ease, border-color 0.15s ease; +} +.ifnx-address-card:hover { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.07); +} +.ifnx-address-card.is-default { + border-color: #fcd34d; + background: linear-gradient(180deg, #fffbeb 0%, #ffffff 40%); +} +.ifnx-address-card-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} +.ifnx-address-role { + display: inline-flex; + align-items: center; + font-size: 11px; + font-weight: 700; + padding: 4px 10px; + border-radius: 999px; +} +.ifnx-address-role.role-sender { + background: var(--ifnex-info-light, #dbeafe); + color: #1e40af; +} +.ifnx-address-role.role-receiver { + background: var(--ifnex-success-light, #d1fae5); + color: #065f46; +} +.ifnx-address-default-badge { + font-size: 11px; + font-weight: 700; + color: #92400e; + background: var(--ifnex-warning-light, #fef3c7); + padding: 4px 10px; + border-radius: 999px; +} +.ifnx-address-card-title { + font-size: 15px; + font-weight: 700; + color: var(--ifnex-gray-900, #111827); +} +.ifnx-address-card-meta { + display: flex; + flex-wrap: wrap; + gap: 6px 14px; + font-size: 12px; + color: var(--ifnex-gray-600, #4b5563); +} +.ifnx-address-card-text { + font-size: 13px; + line-height: 1.7; + color: var(--ifnex-gray-700, #374151); + background: var(--ifnex-gray-50, #f9fafb); + border-radius: 8px; + padding: 10px 12px; + flex: 1; +} +.ifnx-address-card-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; + border-top: 1px solid var(--ifnex-gray-100, #f3f4f6); + padding-top: 12px; + margin-top: 4px; +} +.ifnx-address-card-actions .ifnex-btn[disabled] { + opacity: 0.45; + cursor: not-allowed; +} + +/* حالت خالی */ +.ifnx-address-empty { + text-align: center; + padding: 48px 20px; + background: var(--ifnex-white, #fff); + border: 2px dashed var(--ifnex-gray-200, #e5e7eb); + border-radius: var(--ifnex-radius-md, 12px); +} +.ifnx-address-empty-icon { font-size: 42px; margin-bottom: 10px; } +.ifnx-address-empty h3 { + font-size: 16px; + color: var(--ifnex-gray-900, #111827); + margin: 0 0 8px; +} +.ifnx-address-empty p { + font-size: 13px; + color: var(--ifnex-gray-500, #6b7280); + max-width: 460px; + margin: 0 auto; + line-height: 1.8; +} + +/* موبایل */ +@media (max-width: 640px) { + .ifnx-address-toolbar { flex-direction: column; align-items: stretch; } + .ifnx-address-grid { grid-template-columns: 1fr; } + .ifnx-address-form-grid { grid-template-columns: 1fr; } +} diff --git a/03_WordPress/wp-content/plugins/ifnex-bridge/assets/css/ifnex-orders.css b/03_WordPress/wp-content/plugins/ifnex-bridge/assets/css/ifnex-orders.css index 801e621..2701b7e 100644 --- a/03_WordPress/wp-content/plugins/ifnex-bridge/assets/css/ifnex-orders.css +++ b/03_WordPress/wp-content/plugins/ifnex-bridge/assets/css/ifnex-orders.css @@ -1071,3 +1071,65 @@ font-weight: 600; border-radius: 8px; } + +/* ═══════════════════════════════════════════ + دفترچه آدرس — فرم سفارش (فاز ۳.۹) + ═══════════════════════════════════════════ */ + +.ifnex-saved-address-wrap { + margin-bottom: 16px; + padding: 12px 14px; + background: var(--ifnex-warning-light, #fef3c7); + border: 1px solid #fde68a; + border-radius: 8px; +} +.ifnex-saved-address-wrap label { + display: block; + font-size: 12px; + font-weight: 600; + color: #92400e; + margin-bottom: 6px; +} +.ifnex-saved-address-wrap select { + width: 100%; + padding: 9px 12px; + border: 1px solid #fcd34d; + border-radius: 6px; + background: #fff; + font-size: 13px; + color: #1f2937; + cursor: pointer; +} +.ifnex-saved-address-wrap select:focus { + outline: none; + border-color: #f59e0b; + box-shadow: 0 0 0 3px rgba(245, 158, 11, 0.15); +} + +.ifnex-save-address-row { + margin-top: 4px; +} +.ifnex-save-address-label { + display: inline-flex !important; + align-items: center; + gap: 8px; + cursor: pointer; + font-size: 13px; + font-weight: 500; + color: #374151 !important; + padding: 8px 12px; + background: var(--ifnex-light, #f9fafb); + border: 1px dashed var(--ifnex-border, #e5e7eb); + border-radius: 8px; + user-select: none; +} +.ifnex-save-address-label:hover { + background: #fef3c7; + border-color: #fcd34d; +} +.ifnex-save-address-label input[type="checkbox"] { + width: 17px; + height: 17px; + accent-color: #f59e0b; + cursor: pointer; +} diff --git a/03_WordPress/wp-content/plugins/ifnex-bridge/assets/js/ifnex-address-book.js b/03_WordPress/wp-content/plugins/ifnex-bridge/assets/js/ifnex-address-book.js new file mode 100644 index 0000000..d51edc6 --- /dev/null +++ b/03_WordPress/wp-content/plugins/ifnex-bridge/assets/js/ifnex-address-book.js @@ -0,0 +1,227 @@ +/** + * IFNEX — دفترچه آدرس (فاز ۳.۹) + * منطق تب «دفترچه آدرس» در پنل کاربری: + * افزودن / ویرایش / حذف / پیش‌فرض کردن آدرس‌ها + * + * وابستگی: jQuery، SweetAlert2 (اختیاری — در نبودش به confirm برمی‌گردد) + * داده‌های اولیه: خوانده‌شده از data-* روی .ifnx-address-card-actions (رندر سمت PHP) + */ +jQuery(document).ready(function ($) { + 'use strict'; + + var $app = $('#ifnx-addresses-app'); + if (!$app.length) return; // این صفحه دفترچه آدرس نیست + + var ajaxUrl = $('#ifnx-address-ajax-url').val() || (window.ifnex_ajax && window.ifnex_ajax.ajax_url); + var nonce = $('#ifnx-address-nonce').val() || (window.ifnex_ajax && window.ifnex_ajax.nonce); + + if (!ajaxUrl || !nonce) { + console.error('IFNEX AddressBook: ajax url/nonce not found'); + return; + } + + var $formWrap = $('#ifnx-address-form-wrap'); + var $form = $('#ifnx-address-form'); + + // ─── SweetAlert helpers (سازگار با فاز ۳.۸) ─── + function swalError(title, text) { + if (window.Swal && typeof window.Swal.fire === 'function') { + window.Swal.fire({ + icon: 'error', + title: title, + text: text || '', + confirmButtonText: 'متوجه شدم', + confirmButtonColor: '#f59e0b', + customClass: { popup: 'ifnex-swal' } + }); + } else { + alert((title ? title + '\n' : '') + (text || '')); + } + } + + function swalSuccess(title, text) { + if (window.Swal && typeof window.Swal.fire === 'function') { + window.Swal.fire({ + icon: 'success', + title: title, + text: text || '', + confirmButtonText: 'باشه', + confirmButtonColor: '#10b981', + timer: 2200, + timerProgressBar: true, + customClass: { popup: 'ifnex-swal' } + }); + } + } + + function swalConfirm(title, text) { + if (window.Swal && typeof window.Swal.fire === 'function') { + return window.Swal.fire({ + icon: 'warning', + title: title, + text: text || '', + showCancelButton: true, + confirmButtonText: 'بله، حذف کن', + cancelButtonText: 'انصراف', + confirmButtonColor: '#ef4444', + customClass: { popup: 'ifnex-swal' } + }).then(function (result) { return result.isConfirmed; }); + } + return Promise.resolve(window.confirm((title ? title + '\n' : '') + (text || ''))); + } + + // ─── نمایش/مخفی کردن فرم ─── + function showFormForCreate() { + resetForm(); + $formWrap.slideDown(200); + $('#ifnx-addr-name').trigger('focus'); + scrollToForm(); + } + + function showFormForEdit($actions) { + resetForm(); + + $('#ifnx-addr-id').val($actions.data('id')); + $('#ifnx-addr-role').val($actions.data('role') || 'receiver'); + $('#ifnx-addr-label').val($actions.data('label') || ''); + $('#ifnx-addr-name').val($actions.data('name') || ''); + $('#ifnx-addr-phone').val($actions.data('phone') || ''); + $('#ifnx-addr-state').val($actions.data('state') || ''); + $('#ifnx-addr-city').val($actions.data('city') || ''); + $('#ifnx-addr-postal').val($actions.data('postal') || ''); + $('#ifnx-addr-national').val($actions.data('national') || ''); + $('#ifnx-addr-address').val($actions.data('address') || ''); + $('#ifnx-addr-default').prop('checked', String($actions.data('default')) === '1'); + + $('#ifnx-address-submit').text('💾 بروزرسانی آدرس'); + $formWrap.slideDown(200); + scrollToForm(); + } + + function resetForm() { + $form[0].reset(); + $('#ifnx-addr-id').val(''); + $('#ifnx-address-submit').text('💾 ذخیره آدرس'); + $form.find('.ifnx-addr-error').remove(); + } + + function scrollToForm() { + var el = $formWrap[0]; + if (el && el.scrollIntoView) { + el.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + } + + $('#ifnx-add-address-btn').on('click', function () { + if ($formWrap.is(':visible') && !$('#ifnx-addr-id').val()) { + $formWrap.slideUp(200); // toggle + } else { + showFormForCreate(); + } + }); + + $('#ifnx-address-cancel').on('click', function () { + resetForm(); + $formWrap.slideUp(200); + }); + + // ─── دکمه‌های روی کارت‌ها ─── + $(document).on('click', '.ifnx-addr-edit', function () { + showFormForEdit($(this).closest('.ifnx-address-card-actions')); + }); + + $(document).on('click', '.ifnx-addr-delete', function () { + var $actions = $(this).closest('.ifnx-address-card-actions'); + var id = parseInt($actions.data('id'), 10); + if (!id) return; + + swalConfirm('حذف آدرس', 'آیا از حذف این آدرس مطمئن هستید؟ این عمل قابل بازگشت نیست.').then(function (ok) { + if (!ok) return; + + $.post(ajaxUrl, { + action: 'ifnex_delete_address', + nonce: nonce, + id: id + }).done(function (res) { + if (res && res.success) { + swalSuccess('حذف شد', 'آدرس با موفقیت حذف شد.'); + setTimeout(function () { window.location.reload(); }, 700); + } else { + swalError('حذف انجام نشد', (res && res.data) || 'خطای نامشخص'); + } + }).fail(function () { + swalError('خطای ارتباط', 'لطفاً اتصال اینترنت را بررسی کنید.'); + }); + }); + }); + + $(document).on('click', '.ifnx-addr-default', function () { + var $actions = $(this).closest('.ifnx-address-card-actions'); + var id = parseInt($actions.data('id'), 10); + if (!id) return; + + $.post(ajaxUrl, { + action: 'ifnex_set_default_address', + nonce: nonce, + id: id + }).done(function (res) { + if (res && res.success) { + swalSuccess('ثبت شد', 'این آدرس به‌عنوان پیش‌فرض تنظیم شد.'); + setTimeout(function () { window.location.reload(); }, 700); + } else { + swalError('انجام نشد', (res && res.data) || 'خطای نامشخص'); + } + }).fail(function () { + swalError('خطای ارتباط', 'لطفاً اتصال اینترنت را بررسی کنید.'); + }); + }); + + // ─── ثبت فرم (افزودن یا ویرایش) ─── + $form.on('submit', function (e) { + e.preventDefault(); + + var payload = { + role: $('#ifnx-addr-role').val() || 'receiver', + label: $('#ifnx-addr-label').val(), + name: $('#ifnx-addr-name').val(), + phone: $('#ifnx-addr-phone').val(), + state: $('#ifnx-addr-state').val(), + city: $('#ifnx-addr-city').val(), + postal_code: $('#ifnx-addr-postal').val(), + national_id: $('#ifnx-addr-national').val(), + address: $('#ifnx-addr-address').val(), + is_default: $('#ifnx-addr-default').is(':checked') ? 1 : 0 + }; + + var addressId = parseInt($('#ifnx-addr-id').val(), 10) || 0; + if (addressId > 0) { + payload.id = addressId; + } + + // اعتبارسنجی سمت کلاینت + if (!payload.name || !payload.phone || !payload.address) { + swalError('اطلاعات ناقص', 'نام، شماره تماس و آدرس کامل الزامی است.'); + return; + } + + var $btn = $('#ifnx-address-submit'); + $btn.prop('disabled', true).text('در حال ذخیره...'); + + $.post(ajaxUrl, { + action: 'ifnex_save_address', + nonce: nonce, + data: payload + }).done(function (res) { + if (res && res.success) { + swalSuccess('ذخیره شد', (res.data && res.data.message) || 'آدرس ذخیره شد.'); + setTimeout(function () { window.location.reload(); }, 800); + } else { + $btn.prop('disabled', false).text(addressId > 0 ? '💾 بروزرسانی آدرس' : '💾 ذخیره آدرس'); + swalError('ذخیره انجام نشد', (res && res.data) || 'خطای نامشخص'); + } + }).fail(function () { + $btn.prop('disabled', false).text(addressId > 0 ? '💾 بروزرسانی آدرس' : '💾 ذخیره آدرس'); + swalError('خطای ارتباط', 'لطفاً اتصال اینترنت را بررسی کنید.'); + }); + }); +}); diff --git a/03_WordPress/wp-content/plugins/ifnex-bridge/assets/js/ifnex-order-form.js b/03_WordPress/wp-content/plugins/ifnex-bridge/assets/js/ifnex-order-form.js index eb322e3..48414db 100644 --- a/03_WordPress/wp-content/plugins/ifnex-bridge/assets/js/ifnex-order-form.js +++ b/03_WordPress/wp-content/plugins/ifnex-bridge/assets/js/ifnex-order-form.js @@ -11,6 +11,10 @@ jQuery(document).ready(function($) { var resubmitOrderId = parseInt(urlParams.get('resubmit') || '0', 10); var isResubmitMode = resubmitOrderId > 0; + // ─── دفترچه آدرس (فاز ۳.۹) ─── + var savedAddresses = { sender: [], receiver: [] }; + var selectedAddressIds = { sender: null, receiver: null }; + // ─── Load Countries ─── function loadCountries() { if (!ifnex_ajax || !ifnex_ajax.ajax_url) { @@ -946,6 +950,11 @@ jQuery(document).ready(function($) { receiver_state: $('#ifnex-receiver-state').val(), receiver_postal_code: $('#ifnex-receiver-postal-code').val(), receiver_national_id: $('#ifnex-receiver-national-id').val(), + // دفترچه آدرس (فاز ۳.۹) + save_sender_address: $('#ifnex-save-sender-address').is(':checked') ? 1 : 0, + save_receiver_address: $('#ifnex-save-receiver-address').is(':checked') ? 1 : 0, + sender_address_id: selectedAddressIds.sender || '', + receiver_address_id: selectedAddressIds.receiver || '', }; } @@ -1157,5 +1166,110 @@ jQuery(document).ready(function($) { updateInvoiceStepVisibility(); // لود کشورها (اگه خطا داد اشکالی نداره) try { loadCountries(); } catch(e) { console.error('IFNEX: loadCountries error', e); } + // دفترچه آدرس (فاز ۳.۹) — اگه خطا داد، فرم عادی بدون dropdown کار می‌کنه + try { loadSavedAddresses(); } catch(e) { console.error('IFNEX: loadSavedAddresses error', e); } } + + // ═══════════════════════════════════════════════════ + // دفترچه آدرس (فاز ۳.۹) — dropdown آدرس‌های ذخیره‌شده + // ═══════════════════════════════════════════════════ + + function escapeHtml(str) { + return String(str == null ? '' : str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + function loadSavedAddresses() { + if (!ifnex_ajax || !ifnex_ajax.ajax_url) return; + + $.ajax({ + url: ifnex_ajax.ajax_url, + method: 'POST', + data: { action: 'ifnex_get_addresses', nonce: ifnex_ajax.nonce }, + success: function(res) { + if (!res || !res.success || !res.data || !res.data.data) return; + + var list = res.data.data; + savedAddresses.sender = []; + savedAddresses.receiver = []; + + list.forEach(function(a) { + if (a.role === 'sender') savedAddresses.sender.push(a); + else savedAddresses.receiver.push(a); + }); + + populateAddressSelect('sender'); + populateAddressSelect('receiver'); + } + // در صورت خطا ساکت می‌مانیم — فرم بدون dropdown هم کار می‌کند + }); + } + + function populateAddressSelect(side) { + var $wrap = $('#ifnex-' + side + '-saved-wrap'); + var $sel = $('#ifnex-' + side + '-saved'); + if (!$wrap.length || !$sel.length) return; + + var items = savedAddresses[side]; + if (!items || !items.length) { + $wrap.hide(); + return; + } + + var html = ''; + items.forEach(function(a) { + var title = a.display_label || ((a.label ? a.label + ' — ' : '') + a.name + (a.city ? ' — ' + a.city : '')); + if (a.is_default) title += ' ⭐'; + html += ''; + }); + + $sel.html(html); + $wrap.show(); + } + + function fillAddressFields(side, entry) { + if (side === 'sender') { + $('#ifnex-sender-name').val(entry.name || ''); + $('#ifnex-sender-phone').val(entry.phone || ''); + $('#ifnex-sender-state').val(entry.state || ''); + $('#ifnex-sender-city').val(entry.city || ''); + $('#ifnex-sender-postal-code').val(entry.postal_code || ''); + $('#ifnex-sender-national-id').val(entry.national_id || ''); + $('#ifnex-sender-address').val(entry.address || ''); + } else { + $('#ifnex-receiver-name').val(entry.name || ''); + $('#ifnex-receiver-phone').val(entry.phone || ''); + $('#ifnex-receiver-state').val(entry.state || ''); + $('#ifnex-receiver-city').val(entry.city || ''); + $('#ifnex-receiver-postal-code').val(entry.postal_code || ''); + $('#ifnex-receiver-national-id').val(entry.national_id || ''); + $('#ifnex-receiver-address').val(entry.address || ''); + } + } + + $('#ifnex-sender-saved, #ifnex-receiver-saved').on('change', function() { + var side = this.id.indexOf('sender') !== -1 ? 'sender' : 'receiver'; + var id = parseInt($(this).val(), 10) || null; + selectedAddressIds[side] = id; + + if (!id) { + // انتخاب خالی — فیلدها دست‌نخورده می‌مانند؛ کاربر خودش پاک/ویرایش می‌کند + return; + } + + var entry = null; + savedAddresses[side].forEach(function(a) { + if (parseInt(a.id, 10) === id) entry = a; + }); + + if (entry) { + fillAddressFields(side, entry); + // آدرس از قبل ذخیره شده — چک‌باکس ذخیره لازم نیست (و نباید رکورد تکراری بسازد) + $('#ifnex-save-' + side + '-address').prop('checked', false); + } + }); }); diff --git a/03_WordPress/wp-content/plugins/ifnex-bridge/ifnex-bridge.php b/03_WordPress/wp-content/plugins/ifnex-bridge/ifnex-bridge.php index 250f6b0..b02f318 100644 --- a/03_WordPress/wp-content/plugins/ifnex-bridge/ifnex-bridge.php +++ b/03_WordPress/wp-content/plugins/ifnex-bridge/ifnex-bridge.php @@ -55,6 +55,8 @@ function ifnex_enqueue_scripts() { // SweetAlert2 — پاپ‌آپ خطاها (فاز ۳.۸) — باندل محلی، بدون وابستگی به CDN wp_enqueue_script('sweetalert2', plugin_dir_url(__FILE__) . 'assets/js/sweetalert2.all.min.js', array(), '11.14.5', true); wp_enqueue_script('ifnex-order-form-js', plugin_dir_url(__FILE__) . 'assets/js/ifnex-order-form.js', array('jquery', 'sweetalert2'), ifnex_asset_version('assets/js/ifnex-order-form.js'), true); + // دفترچه آدرس (فاز ۳.۹) — تب اختصاصی در پنل کاربری + wp_enqueue_script('ifnex-address-book-js', plugin_dir_url(__FILE__) . 'assets/js/ifnex-address-book.js', array('jquery', 'sweetalert2'), ifnex_asset_version('assets/js/ifnex-address-book.js'), true); } } diff --git a/03_WordPress/wp-content/plugins/ifnex-bridge/includes/shortcodes.php b/03_WordPress/wp-content/plugins/ifnex-bridge/includes/shortcodes.php index f1be33a..ca9afd5 100644 --- a/03_WordPress/wp-content/plugins/ifnex-bridge/includes/shortcodes.php +++ b/03_WordPress/wp-content/plugins/ifnex-bridge/includes/shortcodes.php @@ -799,6 +799,15 @@ function ifnex_order_form_shortcode($atts) {

👤 اطلاعات فرستنده

+ + + +
@@ -807,8 +816,24 @@ function ifnex_order_form_shortcode($atts) {
+
+ +
+

📮 اطلاعات گیرنده

+ + + +
@@ -817,6 +842,12 @@ function ifnex_order_form_shortcode($atts) {
+
+ +
@@ -1663,6 +1694,7 @@ function ifnex_icon($name, $size = 20) { 'wallet' => '', 'chart' => '', 'tracking' => '', + 'address' => '', 'user' => '', 'logout' => '', 'truck' => '', @@ -1697,7 +1729,7 @@ function ifnex_customer_dashboard_shortcode($atts) { } $tab = sanitize_key($_GET['tab'] ?? 'dashboard'); - $allowed_tabs = ['dashboard', 'notifications', 'orders', 'new-order', 'invoices', 'wallet', 'transactions', 'tracking', 'profile']; + $allowed_tabs = ['dashboard', 'notifications', 'orders', 'new-order', 'invoices', 'wallet', 'transactions', 'tracking', 'addresses', 'profile']; if (!in_array($tab, $allowed_tabs)) $tab = 'dashboard'; $bridge = new IFNEX_User_Bridge(); @@ -1741,13 +1773,14 @@ function ifnex_customer_dashboard_shortcode($atts) { 'wallet' => ['icon' => 'wallet', 'label' => 'کیف پول'], 'transactions' => ['icon' => 'chart', 'label' => 'تراکنش‌ها'], 'tracking' => ['icon' => 'tracking', 'label' => 'رهگیری مرسوله'], + 'addresses' => ['icon' => 'address', 'label' => 'دفترچه آدرس'], 'profile' => ['icon' => 'user', 'label' => 'پروفایل'], ]; $tab_titles = [ 'dashboard' => 'داشبورد', 'notifications' => 'اعلان‌ها', 'orders' => 'سفارشات من', 'new-order' => 'ثبت سفارش جدید', 'invoices' => 'فاکتورهای دستی', - 'wallet' => 'کیف پول', 'transactions' => 'تراکنش‌ها', 'tracking' => 'رهگیری مرسوله', 'profile' => 'پروفایل', + 'wallet' => 'کیف پول', 'transactions' => 'تراکنش‌ها', 'tracking' => 'رهگیری مرسوله', 'addresses' => 'دفترچه آدرس', 'profile' => 'پروفایل', ]; ob_start(); @@ -2304,6 +2337,152 @@ function ifnex_customer_dashboard_shortcode($atts) {
+ + authenticated_request($user_id, '/addresses', 'GET'); + if (is_wp_error($addresses_result)) { + $addresses_error = $addresses_result->get_error_message(); + } elseif (isset($addresses_result['data'])) { + $addresses = $addresses_result['data']; + } + + $addr_filter = in_array($_GET['addr_role'] ?? '', ['sender', 'receiver'], true) ? $_GET['addr_role'] : ''; + if ($addr_filter) { + $addresses = array_values(array_filter($addresses, function ($a) use ($addr_filter) { + return ($a['role'] ?? '') === $addr_filter; + })); + } + ?> + +
خطا در دریافت دفترچه آدرس:
+ +
+ + + +
+ + +
+ + + + + +
+
📒
+

دفترچه آدرس شما خالی است

+

در فرم ثبت سفارش، چک‌باکس «ذخیره این آدرس» را بزنید یا همین‌جا آدرس جدید اضافه کنید تا دفعه بعد با یک کلیک پر شود.

+
+ +
+ + +
+
+ + + ⭐ پیش‌فرض + +
+
+
+ 📞 + 📍 + 🏷️ +
+
+
+ + + +
+
+ +
+ +
+ + diff --git a/03_WordPress/wp-content/plugins/ifnex-bridge/includes/user-bridge.php b/03_WordPress/wp-content/plugins/ifnex-bridge/includes/user-bridge.php index 740d332..f6dcc52 100644 --- a/03_WordPress/wp-content/plugins/ifnex-bridge/includes/user-bridge.php +++ b/03_WordPress/wp-content/plugins/ifnex-bridge/includes/user-bridge.php @@ -631,6 +631,105 @@ function ifnex_submit_order_ajax() { wp_send_json_success($result); } +// ══════════════════════════════════════════════════════════════ +// AJAX Handlers دفترچه آدرس (فاز ۳.۹) +// پروکسی امن به API لاراول — کاربر فقط به رکوردهای خودش دسترسی دارد +// (Ownership در سمت لاراول با توکن Sanctum تضمین می‌شود) +// ══════════════════════════════════════════════════════════════ + +add_action('wp_ajax_ifnex_get_addresses', 'ifnex_get_addresses_ajax'); +function ifnex_get_addresses_ajax() { + check_ajax_referer('ifnex_ajax_nonce', 'nonce'); + if (!is_user_logged_in()) wp_send_json_error('باید وارد شوید.'); + + $args = array(); + if (!empty($_POST['role']) && in_array($_POST['role'], ['sender', 'receiver'], true)) { + $args['role'] = sanitize_key($_POST['role']); + } + if (!empty($_POST['search'])) { + $args['search'] = sanitize_text_field($_POST['search']); + } + + $bridge = new IFNEX_User_Bridge(); + $result = $bridge->authenticated_request(get_current_user_id(), '/addresses', 'GET', $args); + + if (is_wp_error($result)) wp_send_json_error($result->get_error_message()); + wp_send_json_success($result); +} + +add_action('wp_ajax_ifnex_save_address', 'ifnex_save_address_ajax'); +function ifnex_save_address_ajax() { + check_ajax_referer('ifnex_ajax_nonce', 'nonce'); + if (!is_user_logged_in()) wp_send_json_error('باید وارد شوید.'); + + $data = $_POST['data'] ?? []; + if (empty($data)) wp_send_json_error('داده‌ای ارسال نشده.'); + + // پاک‌سازی ورودی‌ها قبل از ارسال به API + $clean = array( + 'role' => in_array($data['role'] ?? '', ['sender', 'receiver'], true) ? $data['role'] : 'receiver', + 'label' => sanitize_text_field($data['label'] ?? ''), + 'name' => sanitize_text_field($data['name'] ?? ''), + 'phone' => sanitize_text_field($data['phone'] ?? ''), + 'state' => sanitize_text_field($data['state'] ?? ''), + 'city' => sanitize_text_field($data['city'] ?? ''), + 'postal_code' => sanitize_text_field($data['postal_code'] ?? ''), + 'national_id' => sanitize_text_field($data['national_id'] ?? ''), + 'address' => sanitize_textarea_field($data['address'] ?? ''), + ); + if (!empty($data['email'])) $clean['email'] = sanitize_email($data['email']); + if (!empty($data['is_default'])) $clean['is_default'] = true; + + $bridge = new IFNEX_User_Bridge(); + $user_id = get_current_user_id(); + + if (!empty($data['id'])) { + // ویرایش رکورد موجود + $address_id = intval($data['id']); + if ($address_id <= 0) wp_send_json_error('شناسه آدرس نامعتبر است.'); + $result = $bridge->authenticated_request($user_id, '/addresses/' . $address_id, 'PUT', $clean); + } else { + // افزودن رکورد جدید + $result = $bridge->authenticated_request($user_id, '/addresses', 'POST', $clean); + } + + if (is_wp_error($result)) wp_send_json_error($result->get_error_message()); + if (isset($result['success']) && !$result['success']) { + wp_send_json_error($result['message'] ?? 'خطا در ذخیره آدرس'); + } + wp_send_json_success($result); +} + +add_action('wp_ajax_ifnex_delete_address', 'ifnex_delete_address_ajax'); +function ifnex_delete_address_ajax() { + check_ajax_referer('ifnex_ajax_nonce', 'nonce'); + if (!is_user_logged_in()) wp_send_json_error('باید وارد شوید.'); + + $address_id = intval($_POST['id'] ?? 0); + if ($address_id <= 0) wp_send_json_error('شناسه آدرس نامعتبر است.'); + + $bridge = new IFNEX_User_Bridge(); + $result = $bridge->authenticated_request(get_current_user_id(), '/addresses/' . $address_id, 'DELETE'); + + if (is_wp_error($result)) wp_send_json_error($result->get_error_message()); + wp_send_json_success($result); +} + +add_action('wp_ajax_ifnex_set_default_address', 'ifnex_set_default_address_ajax'); +function ifnex_set_default_address_ajax() { + check_ajax_referer('ifnex_ajax_nonce', 'nonce'); + if (!is_user_logged_in()) wp_send_json_error('باید وارد شوید.'); + + $address_id = intval($_POST['id'] ?? 0); + if ($address_id <= 0) wp_send_json_error('شناسه آدرس نامعتبر است.'); + + $bridge = new IFNEX_User_Bridge(); + $result = $bridge->authenticated_request(get_current_user_id(), '/addresses/' . $address_id . '/default', 'POST'); + + if (is_wp_error($result)) wp_send_json_error($result->get_error_message()); + wp_send_json_success($result); +} + // Enqueue scripts & styles برای شورت‌کدهای جدید add_action('wp_enqueue_scripts', 'ifnex_enqueue_order_assets'); function ifnex_enqueue_order_assets() { diff --git a/04_Laravel/.phpunit.result.cache b/04_Laravel/.phpunit.result.cache index 875fab4..71c53e0 100644 --- a/04_Laravel/.phpunit.result.cache +++ b/04_Laravel/.phpunit.result.cache @@ -1 +1 @@ -{"version":2,"defects":{"Tests\\Feature\\Api\\PricingControllerTest::it_can_calculate_pricing_with_valid_data":5,"Tests\\Feature\\Api\\PricingControllerTest::it_returns_validation_errors_for_invalid_data":7,"Tests\\Feature\\Services\\PriceCalculatorServiceTest::it_calculates_price_correctly_for_standard_package":8,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_returns_active_discount_codes":7,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_validates_discount_code_successfully":7,"Tests\\Feature\\Api\\PaymentControllerTest::it_completes_mock_payment_successfully":8,"Tests\\Feature\\Services\\WalletServiceTest::it_creates_deposit_transaction":8,"Tests\\Feature\\Services\\WalletServiceTest::it_completes_deposit_and_updates_balance":8,"Tests\\Feature\\Services\\WalletServiceTest::it_processes_manual_deposit":8,"Tests\\Feature\\Services\\WalletServiceTest::it_processes_manual_withdrawal":8,"Tests\\Feature\\Services\\WalletServiceTest::it_prevents_withdrawal_when_balance_is_insufficient":8,"Tests\\Feature\\Services\\WalletServiceTest::it_processes_order_payment":8,"Tests\\Feature\\Services\\WalletServiceTest::it_prevents_order_payment_when_balance_is_insufficient":8,"Tests\\Feature\\Services\\WalletServiceTest::it_fails_transaction":8,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_rejects_invalid_discount_code":7,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_rejects_expired_discount_code":7,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_rejects_code_below_minimum_order_amount":7,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_calculates_fixed_discount_correctly":7,"Tests\\Feature\\Api\\PaymentControllerTest::it_redirects_to_gateway_for_payment":8,"Tests\\Feature\\Api\\PaymentControllerTest::it_validates_minimum_deposit_amount":8,"Tests\\Feature\\Api\\PaymentControllerTest::it_prevents_payment_for_frozen_wallet":8,"Tests\\Feature\\Api\\PaymentControllerTest::it_checks_transaction_status":8},"times":{"Tests\\Feature\\Api\\PricingControllerTest::it_can_calculate_pricing_with_valid_data":0.343,"Tests\\Feature\\Api\\PricingControllerTest::it_returns_validation_errors_for_invalid_data":0.083,"Tests\\Feature\\Services\\PriceCalculatorServiceTest::it_calculates_price_correctly_for_standard_package":0.098,"Tests\\Unit\\ExampleTest::test_that_true_is_true":0.549,"Tests\\Feature\\ExampleTest::test_the_application_returns_a_successful_response":0.067,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_returns_active_discount_codes":0.418,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_validates_discount_code_successfully":0.067,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_rejects_invalid_discount_code":0.057,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_rejects_expired_discount_code":0.058,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_rejects_code_below_minimum_order_amount":0.079,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_calculates_fixed_discount_correctly":0.075,"Tests\\Feature\\Api\\PaymentControllerTest::it_redirects_to_gateway_for_payment":0.448,"Tests\\Feature\\Api\\PaymentControllerTest::it_validates_minimum_deposit_amount":0.214,"Tests\\Feature\\Api\\PaymentControllerTest::it_prevents_payment_for_frozen_wallet":0.056,"Tests\\Feature\\Api\\PaymentControllerTest::it_completes_mock_payment_successfully":0.113,"Tests\\Feature\\Api\\PaymentControllerTest::it_checks_transaction_status":0.061,"Tests\\Feature\\Services\\WalletServiceTest::it_creates_deposit_transaction":0.041,"Tests\\Feature\\Services\\WalletServiceTest::it_completes_deposit_and_updates_balance":0.008,"Tests\\Feature\\Services\\WalletServiceTest::it_processes_manual_deposit":0.007,"Tests\\Feature\\Services\\WalletServiceTest::it_processes_manual_withdrawal":0.008,"Tests\\Feature\\Services\\WalletServiceTest::it_prevents_withdrawal_when_balance_is_insufficient":0.004,"Tests\\Feature\\Services\\WalletServiceTest::it_processes_order_payment":0.007,"Tests\\Feature\\Services\\WalletServiceTest::it_prevents_order_payment_when_balance_is_insufficient":0.002,"Tests\\Feature\\Services\\WalletServiceTest::it_fails_transaction":0.006,"Tests\\Feature\\Services\\DiscountCodeServiceTest::it_consumes_code_and_increments_used_count":0.035,"Tests\\Feature\\Services\\DiscountCodeServiceTest::it_is_idempotent_for_the_same_shipment":0.004,"Tests\\Feature\\Services\\DiscountCodeServiceTest::it_throws_when_usage_limit_is_reached":0.107,"Tests\\Feature\\Services\\DiscountCodeServiceTest::it_rejects_inactive_code":0.002,"Tests\\Feature\\Services\\DiscountCodeServiceTest::it_rejects_expired_code":0.002,"Tests\\Feature\\Services\\DiscountCodeServiceTest::it_does_nothing_when_code_is_empty":0.029,"Tests\\Feature\\Services\\DiscountCodeServiceTest::try_consume_returns_false_instead_of_throwing":0.003,"Tests\\Feature\\Services\\PriceCalculatorServiceTest::it_applies_discount_from_code_in_pricing":0.005,"Tests\\Feature\\Services\\PriceCalculatorServiceTest::it_ignores_discount_code_whose_usage_limit_is_reached":0.005}} \ No newline at end of file +{"version":2,"defects":{"Tests\\Feature\\Api\\PricingControllerTest::it_can_calculate_pricing_with_valid_data":5,"Tests\\Feature\\Api\\PricingControllerTest::it_returns_validation_errors_for_invalid_data":7,"Tests\\Feature\\Services\\PriceCalculatorServiceTest::it_calculates_price_correctly_for_standard_package":8,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_returns_active_discount_codes":7,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_validates_discount_code_successfully":7,"Tests\\Feature\\Api\\PaymentControllerTest::it_completes_mock_payment_successfully":8,"Tests\\Feature\\Services\\WalletServiceTest::it_creates_deposit_transaction":8,"Tests\\Feature\\Services\\WalletServiceTest::it_completes_deposit_and_updates_balance":8,"Tests\\Feature\\Services\\WalletServiceTest::it_processes_manual_deposit":8,"Tests\\Feature\\Services\\WalletServiceTest::it_processes_manual_withdrawal":8,"Tests\\Feature\\Services\\WalletServiceTest::it_prevents_withdrawal_when_balance_is_insufficient":8,"Tests\\Feature\\Services\\WalletServiceTest::it_processes_order_payment":8,"Tests\\Feature\\Services\\WalletServiceTest::it_prevents_order_payment_when_balance_is_insufficient":8,"Tests\\Feature\\Services\\WalletServiceTest::it_fails_transaction":8,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_rejects_invalid_discount_code":7,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_rejects_expired_discount_code":7,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_rejects_code_below_minimum_order_amount":7,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_calculates_fixed_discount_correctly":7,"Tests\\Feature\\Api\\PaymentControllerTest::it_redirects_to_gateway_for_payment":8,"Tests\\Feature\\Api\\PaymentControllerTest::it_validates_minimum_deposit_amount":8,"Tests\\Feature\\Api\\PaymentControllerTest::it_prevents_payment_for_frozen_wallet":8,"Tests\\Feature\\Api\\PaymentControllerTest::it_checks_transaction_status":8,"Tests\\Feature\\Api\\AddressBookControllerTest::it_increments_use_count_and_sets_last_used_at":7},"times":{"Tests\\Feature\\Api\\PricingControllerTest::it_can_calculate_pricing_with_valid_data":0.052,"Tests\\Feature\\Api\\PricingControllerTest::it_returns_validation_errors_for_invalid_data":0.053,"Tests\\Feature\\Services\\PriceCalculatorServiceTest::it_calculates_price_correctly_for_standard_package":0.006,"Tests\\Unit\\ExampleTest::test_that_true_is_true":0.004,"Tests\\Feature\\ExampleTest::test_the_application_returns_a_successful_response":0.057,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_returns_active_discount_codes":0.048,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_validates_discount_code_successfully":0.044,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_rejects_invalid_discount_code":0.043,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_rejects_expired_discount_code":0.044,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_rejects_code_below_minimum_order_amount":0.048,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_calculates_fixed_discount_correctly":0.044,"Tests\\Feature\\Api\\PaymentControllerTest::it_redirects_to_gateway_for_payment":0.064,"Tests\\Feature\\Api\\PaymentControllerTest::it_validates_minimum_deposit_amount":0.046,"Tests\\Feature\\Api\\PaymentControllerTest::it_prevents_payment_for_frozen_wallet":0.046,"Tests\\Feature\\Api\\PaymentControllerTest::it_completes_mock_payment_successfully":0.06,"Tests\\Feature\\Api\\PaymentControllerTest::it_checks_transaction_status":0.049,"Tests\\Feature\\Services\\WalletServiceTest::it_creates_deposit_transaction":0.003,"Tests\\Feature\\Services\\WalletServiceTest::it_completes_deposit_and_updates_balance":0.006,"Tests\\Feature\\Services\\WalletServiceTest::it_processes_manual_deposit":0.005,"Tests\\Feature\\Services\\WalletServiceTest::it_processes_manual_withdrawal":0.006,"Tests\\Feature\\Services\\WalletServiceTest::it_prevents_withdrawal_when_balance_is_insufficient":0.002,"Tests\\Feature\\Services\\WalletServiceTest::it_processes_order_payment":0.005,"Tests\\Feature\\Services\\WalletServiceTest::it_prevents_order_payment_when_balance_is_insufficient":0.001,"Tests\\Feature\\Services\\WalletServiceTest::it_fails_transaction":0.004,"Tests\\Feature\\Services\\DiscountCodeServiceTest::it_consumes_code_and_increments_used_count":0.004,"Tests\\Feature\\Services\\DiscountCodeServiceTest::it_is_idempotent_for_the_same_shipment":0.004,"Tests\\Feature\\Services\\DiscountCodeServiceTest::it_throws_when_usage_limit_is_reached":0.004,"Tests\\Feature\\Services\\DiscountCodeServiceTest::it_rejects_inactive_code":0.002,"Tests\\Feature\\Services\\DiscountCodeServiceTest::it_rejects_expired_code":0.002,"Tests\\Feature\\Services\\DiscountCodeServiceTest::it_does_nothing_when_code_is_empty":0.002,"Tests\\Feature\\Services\\DiscountCodeServiceTest::try_consume_returns_false_instead_of_throwing":0.003,"Tests\\Feature\\Services\\PriceCalculatorServiceTest::it_applies_discount_from_code_in_pricing":0.01,"Tests\\Feature\\Services\\PriceCalculatorServiceTest::it_ignores_discount_code_whose_usage_limit_is_reached":0.007,"Tests\\Feature\\Api\\AddressBookControllerTest::it_lists_only_current_user_addresses":0.079,"Tests\\Feature\\Api\\AddressBookControllerTest::it_filters_addresses_by_role":0.049,"Tests\\Feature\\Api\\AddressBookControllerTest::it_requires_authentication":0.046,"Tests\\Feature\\Api\\AddressBookControllerTest::it_creates_address_successfully":0.053,"Tests\\Feature\\Api\\AddressBookControllerTest::it_rejects_invalid_role":0.046,"Tests\\Feature\\Api\\AddressBookControllerTest::it_rejects_missing_required_fields":0.046,"Tests\\Feature\\Api\\AddressBookControllerTest::it_upserts_duplicate_instead_of_creating":0.048,"Tests\\Feature\\Api\\AddressBookControllerTest::it_enforces_max_entries_limit":0.079,"Tests\\Feature\\Api\\AddressBookControllerTest::it_updates_own_address":0.047,"Tests\\Feature\\Api\\AddressBookControllerTest::it_cannot_change_entry_role_on_update":0.047,"Tests\\Feature\\Api\\AddressBookControllerTest::it_prevents_updating_others_address":0.046,"Tests\\Feature\\Api\\AddressBookControllerTest::it_deletes_own_address":0.047,"Tests\\Feature\\Api\\AddressBookControllerTest::it_prevents_deleting_others_address":0.046,"Tests\\Feature\\Api\\AddressBookControllerTest::it_sets_default_and_unsets_previous_default_of_same_role":0.048,"Tests\\Feature\\Api\\AddressBookControllerTest::it_prevents_setting_default_on_others_address":0.047,"Tests\\Feature\\Api\\AddressBookControllerTest::it_increments_use_count_and_sets_last_used_at":0.004,"Tests\\Feature\\Api\\AddressBookControllerTest::it_finds_duplicate_by_role_phone_address":0.003,"Tests\\Feature\\Api\\AddressBookControllerTest::it_returns_form_fields_with_role_prefix":0.001}} \ No newline at end of file diff --git a/04_Laravel/app/Http/Controllers/Api/AddressBookController.php b/04_Laravel/app/Http/Controllers/Api/AddressBookController.php new file mode 100644 index 0000000..18ad907 --- /dev/null +++ b/04_Laravel/app/Http/Controllers/Api/AddressBookController.php @@ -0,0 +1,247 @@ +id) + ->orderByDesc('is_default') + ->orderByDesc('last_used_at') + ->orderByDesc('updated_at'); + + if ($role = $request->query('role')) { + if (in_array($role, [AddressBookEntry::ROLE_SENDER, AddressBookEntry::ROLE_RECEIVER], true)) { + $query->ofRole($role); + } + } + + if ($search = trim((string) $request->query('search', ''))) { + $query->where(function ($q) use ($search) { + $q->where('name', 'like', "%{$search}%") + ->orWhere('label', 'like', "%{$search}%") + ->orWhere('phone', 'like', "%{$search}%") + ->orWhere('city', 'like', "%{$search}%"); + }); + } + + $entries = $query->get(); + + return response()->json([ + 'success' => true, + 'data' => $entries->map(fn (AddressBookEntry $e) => $this->formatEntry($e)), + 'count' => $entries->count(), + 'max_entries' => AddressBookEntry::MAX_ENTRIES_PER_USER, + ]); + } + + /** + * افزودن آدرس جدید + * POST /api/v1/addresses + */ + public function store(Request $request): JsonResponse + { + $validated = $this->validateEntry($request); + + $user = Auth::user(); + + $count = AddressBookEntry::forUser($user->id)->count(); + if ($count >= AddressBookEntry::MAX_ENTRIES_PER_USER) { + return response()->json([ + 'success' => false, + 'message' => 'تعداد آدرس‌های ذخیره‌شده شما به حداکثر مجاز (' . AddressBookEntry::MAX_ENTRIES_PER_USER . ') رسیده است. لطفاً آدرس‌های قدیمی را حذف کنید.', + ], 422); + } + + // جلوگیری از رکورد تکراری (همان نقش + تلفن + آدرس) → بروزرسانی رکورد موجود + $duplicate = AddressBookEntry::findDuplicate( + $user->id, + $validated['role'], + $validated['phone'], + $validated['address'] + ); + + if ($duplicate) { + $duplicate->update(collect($validated)->except(['role'])->all()); + + if (!empty($validated['is_default'])) { + $duplicate->makeDefault(); + } + + return response()->json([ + 'success' => true, + 'message' => 'آدرس مشابه از قبل ذخیره شده بود و بروزرسانی شد.', + 'data' => $this->formatEntry($duplicate->refresh()), + 'updated_existing' => true, + ]); + } + + $entry = AddressBookEntry::create([ + ...$validated, + 'user_id' => $user->id, + ]); + + if (!empty($validated['is_default'])) { + $entry->makeDefault(); + } + + return response()->json([ + 'success' => true, + 'message' => 'آدرس با موفقیت ذخیره شد.', + 'data' => $this->formatEntry($entry), + ], 201); + } + + /** + * ویرایش آدرس + * PUT/PATCH /api/v1/addresses/{entry} + */ + public function update(Request $request, AddressBookEntry $address): JsonResponse + { + $user = Auth::user(); + + if ($address->user_id !== $user->id) { + return response()->json([ + 'success' => false, + 'message' => 'دسترسی غیرمجاز.', + ], 403); + } + + $validated = $this->validateEntry($request, $address->id); + + // نقش رکورد قابل تغییر نیست (جلوگیری از ناسازگاری با فرم) + unset($validated['role']); + + $address->update($validated); + + if (!empty($validated['is_default'])) { + $address->makeDefault(); + } + + return response()->json([ + 'success' => true, + 'message' => 'آدرس بروزرسانی شد.', + 'data' => $this->formatEntry($address->refresh()), + ]); + } + + /** + * حذف آدرس + * DELETE /api/v1/addresses/{entry} + */ + public function destroy(Request $request, AddressBookEntry $address): JsonResponse + { + $user = Auth::user(); + + if ($address->user_id !== $user->id) { + return response()->json([ + 'success' => false, + 'message' => 'دسترسی غیرمجاز.', + ], 403); + } + + $address->delete(); + + return response()->json([ + 'success' => true, + 'message' => 'آدرس حذف شد.', + ]); + } + + /** + * تنظیم به‌عنوان پیش‌فرض + * POST /api/v1/addresses/{address}/default + */ + public function setDefault(Request $request, AddressBookEntry $address): JsonResponse + { + $user = Auth::user(); + + if ($address->user_id !== $user->id) { + return response()->json([ + 'success' => false, + 'message' => 'دسترسی غیرمجاز.', + ], 403); + } + + $address->makeDefault(); + + return response()->json([ + 'success' => true, + 'message' => 'این آدرس به‌عنوان پیش‌فرض تنظیم شد.', + 'data' => $this->formatEntry($address->refresh()), + ]); + } + + // === Helpers === + + private function validateEntry(Request $request, ?int $ignoreId = null): array + { + try { + return $request->validate([ + 'role' => ['required', Rule::in([AddressBookEntry::ROLE_SENDER, AddressBookEntry::ROLE_RECEIVER])], + 'label' => ['nullable', 'string', 'max:100'], + 'name' => ['required', 'string', 'max:255'], + 'company' => ['nullable', 'string', 'max:255'], + 'phone' => ['required', 'string', 'max:50'], + 'email' => ['nullable', 'email', 'max:255'], + 'state' => ['nullable', 'string', 'max:255'], + 'city' => ['nullable', 'string', 'max:255'], + 'postal_code' => ['nullable', 'string', 'max:20'], + 'national_id' => ['nullable', 'string', 'max:50'], + 'address' => ['required', 'string', 'max:1000'], + 'is_default' => ['nullable', 'boolean'], + ]); + } catch (ValidationException $e) { + abort(response()->json([ + 'success' => false, + 'message' => 'خطا در اعتبارسنجی اطلاعات آدرس', + 'errors' => $e->errors(), + ], 422)); + } + } + + private function formatEntry(AddressBookEntry $e): array + { + return [ + 'id' => $e->id, + 'role' => $e->role, + 'label' => $e->label, + 'display_label' => $e->display_label, + 'name' => $e->name, + 'company' => $e->company, + 'phone' => $e->phone, + 'email' => $e->email, + 'state' => $e->state, + 'city' => $e->city, + 'postal_code' => $e->postal_code, + 'national_id' => $e->national_id, + 'address' => $e->address, + 'is_default' => $e->is_default, + 'use_count' => $e->use_count, + 'last_used_at' => $e->last_used_at?->toIso8601String(), + 'form_fields' => $e->toFormFieldData(), + 'updated_at' => $e->updated_at->toIso8601String(), + ]; + } +} diff --git a/04_Laravel/app/Http/Controllers/Api/Customer/CustomerOrderController.php b/04_Laravel/app/Http/Controllers/Api/Customer/CustomerOrderController.php index 1fa2ea3..8fd17e3 100644 --- a/04_Laravel/app/Http/Controllers/Api/Customer/CustomerOrderController.php +++ b/04_Laravel/app/Http/Controllers/Api/Customer/CustomerOrderController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Api\Customer; use App\Http\Controllers\Controller; +use App\Models\AddressBookEntry; use App\Models\Shipment; use App\Models\ShipmentItem; use App\Models\ShipmentPackage; @@ -124,7 +125,13 @@ class CustomerOrderController extends Controller // تخفیف 'discount_code' => ['nullable', 'string', 'max:50'], - + + // دفترچه آدرس (فاز ۳.۹) — ذخیره/استفاده از آدرس‌های پرتکرار + 'save_sender_address' => ['nullable', 'boolean'], + 'save_receiver_address' => ['nullable', 'boolean'], + 'sender_address_id' => ['nullable', 'integer', 'exists:address_book_entries,id'], + 'receiver_address_id' => ['nullable', 'integer', 'exists:address_book_entries,id'], + // اقلام گمرکی 'items' => ['nullable', 'array', 'max:9'], 'items.*.description' => ['required_with:items', 'string', 'max:500'], @@ -323,6 +330,9 @@ class CustomerOrderController extends Controller return $shipment; }); + // دفترچه آدرس (فاز ۳.۹) — بعد از تراکنش تا شکستِ آن سفارش را rollback نکند + $this->handleAddressBook($user->id, $validated); + return response()->json([ 'success' => true, 'message' => 'سفارش شما با موفقیت ثبت شد. پس از تأیید کارمند، گزینه پرداخت برای شما فعال خواهد شد.', @@ -1056,6 +1066,81 @@ class CustomerOrderController extends Controller return $number; } + /** + * دفترچه آدرس (فاز ۳.۹) — عملیات جانبی پس از ثبت سفارش + * + * ۱) اگر کاربر آدرس ذخیره‌شده انتخاب کرده بود (sender_address_id / receiver_address_id) + * شمارندهٔ استفادهٔ آن رکورد بروز می‌شود. + * ۲) اگر چک‌باکس «ذخیره این آدرس» زده شده باشد، آدرس در دفترچه upsert می‌شود + * (رکورد تکراری با همان نقش + تلفن + آدرس بروزرسانی می‌شود نه اینکه دوباره ساخته شود). + * + * این عملیات غیرحیاتی است: هر خطایی فقط لاگ می‌شود و نباید ثبت سفارش را خراب کند. + */ + private function handleAddressBook(int $userId, array $validated): void + { + try { + // ─── ثبت استفاده از آدرس‌های انتخاب‌شده ─── + foreach (['sender', 'receiver'] as $side) { + $selectedId = $validated["{$side}_address_id"] ?? null; + if ($selectedId) { + $entry = AddressBookEntry::forUser($userId)->find($selectedId); + if ($entry) { + $entry->markUsed(); + } + } + } + + // ─── ذخیرهٔ آدرس‌های علامت‌خورده ─── + foreach (['sender', 'receiver'] as $side) { + if (empty($validated["save_{$side}_address"])) { + continue; + } + + $data = [ + 'name' => $validated["{$side}_name"] ?? null, + 'phone' => $validated["{$side}_phone"] ?? null, + 'address' => $validated["{$side}_address"] ?? null, + 'state' => $validated["{$side}_state"] ?? null, + 'city' => $validated["{$side}_city"] ?? null, + 'postal_code' => $validated["{$side}_postal_code"] ?? null, + 'national_id' => $validated["{$side}_national_id"] ?? null, + 'company' => $validated["{$side}_company"] ?? null, + 'email' => $validated["{$side}_email"] ?? null, + ]; + + // بدون نام/تلفن/آدرس ذخیره معنا ندارد + if (!$data['name'] || !$data['phone'] || !$data['address']) { + continue; + } + + // سقف تعداد — در صورت پر بودن، بی‌صدا رد می‌شود + if (AddressBookEntry::forUser($userId)->count() >= AddressBookEntry::MAX_ENTRIES_PER_USER) { + continue; + } + + // آدرس قبلاً ذخیره شده؟ → بروزرسانی به‌جای ساخت رکورد تکراری + $entry = AddressBookEntry::findDuplicate($userId, $side, $data['phone'], $data['address']); + + if ($entry) { + $entry->update($data); + } else { + AddressBookEntry::create([ + ...$data, + 'user_id' => $userId, + 'role' => $side, + ]); + } + } + } catch (\Throwable $e) { + // عملیات دفترچه آدرس هرگز نباید ثبت سفارش را ناموفق کند + \Log::warning('AddressBook sync after order failed', [ + 'user_id' => $userId, + 'error' => $e->getMessage(), + ]); + } + } + + /** * تبدیل تاریخ به شمسی */ diff --git a/04_Laravel/app/Models/AddressBookEntry.php b/04_Laravel/app/Models/AddressBookEntry.php new file mode 100644 index 0000000..193c178 --- /dev/null +++ b/04_Laravel/app/Models/AddressBookEntry.php @@ -0,0 +1,154 @@ + 'boolean', + 'use_count' => 'integer', + 'last_used_at' => 'datetime', + ]; + + /** + * مقادیر پیش‌فرض روی instance — برای اینکه در لحظهٔ create + * (و قبل از refresh از DB) مقدار use_count/is_default در دسترس باشد. + * SQLite مقادیر default ستون‌ها را بلافاصله روی مدل در حافظه بازنویسی نمی‌کند. + */ + protected $attributes = [ + 'use_count' => 0, + 'is_default' => false, + ]; + + // === Relationships === + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + // === Scopes === + + public function scopeForUser($query, int $userId) + { + return $query->where('user_id', $userId); + } + + public function scopeOfRole($query, string $role) + { + return $query->where('role', $role); + } + + // === Helpers === + + /** + * داده‌های آمادهٔ پر کردن فیلدهای فرم سفارش + * کلیدها عیناً با prefix نقش مطابق فرم وردپرس برمی‌گردند. + */ + public function toFormFieldData(): array + { + $prefix = $this->role === self::ROLE_SENDER ? 'sender' : 'receiver'; + + return [ + 'id' => $this->id, + 'role' => $this->role, + 'label' => $this->label, + 'is_default' => $this->is_default, + "{$prefix}_name" => $this->name, + "{$prefix}_phone" => $this->phone, + "{$prefix}_state" => $this->state, + "{$prefix}_city" => $this->city, + "{$prefix}_postal_code" => $this->postal_code, + "{$prefix}_national_id" => $this->national_id, + "{$prefix}_address" => $this->address, + ]; + } + + /** + * پیدا کردن رکورد تکراری (همان نقش + همان تلفن + همان آدرس) + * برای جلوگیری از انباشت رکوردهای دوباره هنگام «ذخیره این آدرس». + */ + public static function findDuplicate(int $userId, string $role, string $phone, string $address): ?self + { + return static::forUser($userId) + ->ofRole($role) + ->where('phone', $phone) + ->where('address', $address) + ->first(); + } + + /** + * ثبت استفاده: افزایش شمارنده و بروزرسانی زمان آخرین استفاده + */ + public function markUsed(): void + { + $this->increment('use_count'); + $this->forceFill(['last_used_at' => now()])->save(); + } + + /** + * این رکورد را پیش‌فرض نقش خودش می‌کند (سایر رکوردهای همان نقش off می‌شوند) + */ + public function makeDefault(): void + { + static::forUser($this->user_id) + ->ofRole($this->role) + ->where('id', '!=', $this->id) + ->update(['is_default' => false]); + + $this->forceFill(['is_default' => true])->save(); + } + + /** + * برچسب نمایشی در dropdown فرم + */ + public function getDisplayLabelAttribute(): string + { + $parts = []; + if ($this->label) { + $parts[] = $this->label; + } + $parts[] = $this->name; + if ($this->city) { + $parts[] = $this->city; + } + + return implode(' — ', $parts); + } +} diff --git a/04_Laravel/database/migrations/2026_11_01_000001_create_address_book_entries_table.php b/04_Laravel/database/migrations/2026_11_01_000001_create_address_book_entries_table.php new file mode 100644 index 0000000..22c7fc1 --- /dev/null +++ b/04_Laravel/database/migrations/2026_11_01_000001_create_address_book_entries_table.php @@ -0,0 +1,60 @@ +id(); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + + // این آدرس برای چه نقشی ذخیره شده؟ sender = فرستنده، receiver = گیرنده + $table->string('role', 20)->default('receiver'); // sender|receiver + + // برچسب دلخواه (مثلاً «خانه»، «دفتر»، «انبار شنگهای») + $table->string('label', 100)->nullable(); + + // اطلاعات تماس و آدرس — مطابق فیلدهای shipments + $table->string('name', 255); + $table->string('company', 255)->nullable(); + $table->string('phone', 50); + $table->string('email', 255)->nullable(); + $table->string('state', 255)->nullable(); + $table->string('city', 255)->nullable(); + $table->string('postal_code', 20)->nullable(); + $table->string('national_id', 50)->nullable(); + $table->text('address'); + + // آدرس پیش‌فرض برای این نقش (با انتخاب مجدد، قبلی false می‌شود) + $table->boolean('is_default')->default(false); + + // آمار استفاده — برای مرتب‌سازی «پرتکرارها» + $table->unsignedInteger('use_count')->default(0); + $table->timestamp('last_used_at')->nullable(); + + $table->timestamps(); + + $table->index(['user_id', 'role']); + $table->index(['user_id', 'is_default']); + $table->index(['user_id', 'last_used_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('address_book_entries'); + } +}; diff --git a/04_Laravel/routes/api.php b/04_Laravel/routes/api.php index 151e676..b0ddade 100644 --- a/04_Laravel/routes/api.php +++ b/04_Laravel/routes/api.php @@ -1,6 +1,7 @@ prefix('v1')->group(fu Route::get('/discount-codes', [DiscountCodeController::class, 'index']); Route::post('/discount-codes/validate', [DiscountCodeController::class, 'validate']); + // ─── Address Book (دفترچه آدرس — فاز ۳.۹) ─── + Route::get('/addresses', [AddressBookController::class, 'index']); + Route::post('/addresses', [AddressBookController::class, 'store']); + Route::put('/addresses/{address}', [AddressBookController::class, 'update']); + Route::patch('/addresses/{address}', [AddressBookController::class, 'update']); + Route::delete('/addresses/{address}', [AddressBookController::class, 'destroy']); + Route::post('/addresses/{address}/default', [AddressBookController::class, 'setDefault']); + // ─── Commitment Forms (نیازمند احراز هویت) ─── Route::get('/commitment-forms', [CommitmentFormController::class, 'index']); Route::get('/commitment-forms/{direction}', [CommitmentFormController::class, 'byDirection']); diff --git a/04_Laravel/tests/Feature/Api/AddressBookControllerTest.php b/04_Laravel/tests/Feature/Api/AddressBookControllerTest.php new file mode 100644 index 0000000..aad5700 --- /dev/null +++ b/04_Laravel/tests/Feature/Api/AddressBookControllerTest.php @@ -0,0 +1,288 @@ +user = User::factory()->create(); + Sanctum::actingAs($this->user); + } + + private function validPayload(array $overrides = []): array + { + return array_merge([ + 'role' => 'receiver', + 'label' => 'خانه', + 'name' => 'علی رضایی', + 'phone' => '09121234567', + 'state' => 'تهران', + 'city' => 'تهران', + 'postal_code' => '1234567890', + 'address' => 'خیابان ولیعصر، پلاک ۱۰، واحد ۲', + ], $overrides); + } + + // ═══════════════════ index ═══════════════════ + + #[Test] + public function it_lists_only_current_user_addresses() + { + AddressBookEntry::create([...$this->validPayload(), 'user_id' => $this->user->id]); + AddressBookEntry::create([...$this->validPayload(['role' => 'sender', 'name' => 'رضا']), 'user_id' => $this->user->id]); + + $other = User::factory()->create(); + AddressBookEntry::create([...$this->validPayload(['name' => 'کاربر دیگر']), 'user_id' => $other->id]); + + $response = $this->getJson('/api/v1/addresses'); + + $response->assertStatus(200) + ->assertJson(['success' => true]) + ->assertJsonCount(2, 'data'); + } + + #[Test] + public function it_filters_addresses_by_role() + { + AddressBookEntry::create([...$this->validPayload(), 'user_id' => $this->user->id]); + AddressBookEntry::create([...$this->validPayload(['role' => 'sender', 'name' => 'رضا']), 'user_id' => $this->user->id]); + + $response = $this->getJson('/api/v1/addresses?role=sender'); + + $response->assertStatus(200) + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.role', 'sender'); + } + + #[Test] + public function it_requires_authentication() + { + // حذف کاربر actAs‌شده از گارد sanctum برای این تست + auth('sanctum')->forgetUser(); + + $response = $this->getJson('/api/v1/addresses'); + + $response->assertStatus(401); + } + + // ═══════════════════ store ═══════════════════ + + #[Test] + public function it_creates_address_successfully() + { + $response = $this->postJson('/api/v1/addresses', $this->validPayload()); + + $response->assertStatus(201) + ->assertJson(['success' => true]) + ->assertJsonPath('data.name', 'علی رضایی') + ->assertJsonPath('data.role', 'receiver'); + + $this->assertDatabaseHas('address_book_entries', [ + 'user_id' => $this->user->id, + 'phone' => '09121234567', + 'role' => 'receiver', + ]); + } + + #[Test] + public function it_rejects_invalid_role() + { + $response = $this->postJson('/api/v1/addresses', $this->validPayload(['role' => 'hacker'])); + + $response->assertStatus(422) + ->assertJson(['success' => false]); + } + + #[Test] + public function it_rejects_missing_required_fields() + { + $response = $this->postJson('/api/v1/addresses', [ + 'role' => 'receiver', + // name, phone, address وجود ندارند + ]); + + $response->assertStatus(422); + $this->assertArrayHasKey('name', $response->json('errors')); + $this->assertArrayHasKey('phone', $response->json('errors')); + $this->assertArrayHasKey('address', $response->json('errors')); + } + + #[Test] + public function it_upserts_duplicate_instead_of_creating() + { + AddressBookEntry::create([...$this->validPayload(), 'user_id' => $this->user->id]); + + // همان نقش + همان تلفن + همان آدرس، با شهر متفاوت + $response = $this->postJson('/api/v1/addresses', $this->validPayload(['city' => 'کرج'])); + + $response->assertStatus(200) + ->assertJson([ + 'success' => true, + 'updated_existing' => true, + ]); + + $this->assertDatabaseCount('address_book_entries', 1); + $this->assertDatabaseHas('address_book_entries', ['city' => 'کرج']); + } + + #[Test] + public function it_enforces_max_entries_limit() + { + for ($i = 0; $i < AddressBookEntry::MAX_ENTRIES_PER_USER; $i++) { + AddressBookEntry::create([ + ...$this->validPayload(['phone' => '091200000' . str_pad((string) $i, 2, '0', STR_PAD_LEFT)]), + 'user_id' => $this->user->id, + ]); + } + + $response = $this->postJson('/api/v1/addresses', $this->validPayload(['phone' => '09129999999'])); + + $response->assertStatus(422) + ->assertJson(['success' => false]); + $this->assertDatabaseCount('address_book_entries', AddressBookEntry::MAX_ENTRIES_PER_USER); + } + + // ═══════════════════ update / delete / default ═══════════════════ + + #[Test] + public function it_updates_own_address() + { + $entry = AddressBookEntry::create([...$this->validPayload(), 'user_id' => $this->user->id]); + + $response = $this->putJson("/api/v1/addresses/{$entry->id}", $this->validPayload(['city' => 'اصفهان'])); + + $response->assertStatus(200) + ->assertJsonPath('data.city', 'اصفهان'); + } + + #[Test] + public function it_cannot_change_entry_role_on_update() + { + $entry = AddressBookEntry::create([...$this->validPayload(), 'user_id' => $this->user->id]); + + $response = $this->putJson("/api/v1/addresses/{$entry->id}", $this->validPayload(['role' => 'sender'])); + + $response->assertStatus(200); + $this->assertDatabaseHas('address_book_entries', [ + 'id' => $entry->id, + 'role' => 'receiver', // نقش تغییر نکرده + ]); + } + + #[Test] + public function it_prevents_updating_others_address() + { + $other = User::factory()->create(); + $entry = AddressBookEntry::create([...$this->validPayload(), 'user_id' => $other->id]); + + $response = $this->putJson("/api/v1/addresses/{$entry->id}", $this->validPayload()); + + $response->assertStatus(403); + } + + #[Test] + public function it_deletes_own_address() + { + $entry = AddressBookEntry::create([...$this->validPayload(), 'user_id' => $this->user->id]); + + $response = $this->deleteJson("/api/v1/addresses/{$entry->id}"); + + $response->assertStatus(200) + ->assertJson(['success' => true]); + + $this->assertDatabaseMissing('address_book_entries', ['id' => $entry->id]); + } + + #[Test] + public function it_prevents_deleting_others_address() + { + $other = User::factory()->create(); + $entry = AddressBookEntry::create([...$this->validPayload(), 'user_id' => $other->id]); + + $response = $this->deleteJson("/api/v1/addresses/{$entry->id}"); + + $response->assertStatus(403); + $this->assertDatabaseHas('address_book_entries', ['id' => $entry->id]); + } + + #[Test] + public function it_sets_default_and_unsets_previous_default_of_same_role() + { + $first = AddressBookEntry::create([...$this->validPayload(), 'user_id' => $this->user->id, 'is_default' => true]); + $second = AddressBookEntry::create([...$this->validPayload(['phone' => '09129876543']), 'user_id' => $this->user->id]); + + $response = $this->postJson("/api/v1/addresses/{$second->id}/default"); + + $response->assertStatus(200) + ->assertJson(['success' => true]); + + $this->assertTrue($second->refresh()->is_default); + $this->assertFalse($first->refresh()->is_default); + } + + #[Test] + public function it_prevents_setting_default_on_others_address() + { + $other = User::factory()->create(); + $entry = AddressBookEntry::create([...$this->validPayload(), 'user_id' => $other->id]); + + $response = $this->postJson("/api/v1/addresses/{$entry->id}/default"); + + $response->assertStatus(403); + } + + // ═══════════════════ Model behaviour ═══════════════════ + + #[Test] + public function it_increments_use_count_and_sets_last_used_at() + { + $entry = AddressBookEntry::create([...$this->validPayload(), 'user_id' => $this->user->id]); + + $this->assertSame(0, $entry->use_count); + $this->assertNull($entry->last_used_at); + + $entry->markUsed(); + + $this->assertSame(1, $entry->refresh()->use_count); + $this->assertNotNull($entry->refresh()->last_used_at); + } + + #[Test] + public function it_finds_duplicate_by_role_phone_address() + { + AddressBookEntry::create([...$this->validPayload(), 'user_id' => $this->user->id]); + + $dup = AddressBookEntry::findDuplicate($this->user->id, 'receiver', '09121234567', 'خیابان ولیعصر، پلاک ۱۰، واحد ۲'); + $this->assertNotNull($dup); + + // نقش متفاوت → تکراری نیست + $notDup = AddressBookEntry::findDuplicate($this->user->id, 'sender', '09121234567', 'خیابان ولیعصر، پلاک ۱۰، واحد ۲'); + $this->assertNull($notDup); + } + + #[Test] + public function it_returns_form_fields_with_role_prefix() + { + $sender = AddressBookEntry::create([...$this->validPayload(['role' => 'sender']), 'user_id' => $this->user->id]); + + $fields = $sender->toFormFieldData(); + + $this->assertSame('علی رضایی', $fields['sender_name']); + $this->assertSame('09121234567', $fields['sender_phone']); + $this->assertArrayNotHasKey('receiver_name', $fields); + } +} diff --git a/AGENT.md b/AGENT.md index 18ce802..72cd08f 100644 --- a/AGENT.md +++ b/AGENT.md @@ -49,6 +49,16 @@ - [x] **سند استقرار** — بخش ۴.۴ «چک‌لیست امنیتی سخت‌سازی» در `DEPLOYMENT_HANDOFF.md` - [x] **تست‌ها:** ۷ تست شکستهٔ قدیمی (auth کد تخفیف + معماری قدیمی محاسبه‌گر) تعمیر شد؛ ۷ تست جدید برای سرویس مصرف + ۲ تست برای ظرفیت/تخفیف — **۳۳ تست پاس، ۱۰۱ assertion** +فاز ۳.۹ (دفترچه آدرس) نیز تکمیل شد (۲۰۲۶-۱۱-۰۱): + +- [x] **مدل + جدول `address_book_entries`** — نقش (sender/receiver)، برچسب، فیلدهای تماس عیناً مطابق ستون‌های sender_/receiver_ در shipments، پیش‌فرض، شمارندهٔ استفاده + `last_used_at`، سقف ۵۰ رکورد per-user +- [x] **CRUD API با Sanctum** — `GET/POST/PUT/DELETE /api/v1/addresses` + `POST /addresses/{id}/default`؛ Ownership در همهٔ اکشن‌ها؛ دتکت رکورد تکراری (نقش + تلفن + آدرس → بروزرسانی به‌جای ساخت دوباره)؛ فیلتر role/search +- [x] **اتصال به ثبت سفارش** — چک‌باکس «ذخیره این آدرس» (upsert بدون رکورد تکراری) + شمارش استفاده از آدرس انتخاب‌شده؛ عملیات غیرحیاتی (هر خطا فقط Log — سفارش rollback نمی‌شود) +- [x] **فرم سفارش وردپرس** — dropdown «آدرس‌های ذخیره‌شده» بالای بخش فرستنده/گیرنده (پر شدن یک‌کلیکی ۷ فیلد) + چک‌باکس ذخیره برای هر نقش +- [x] **تب «دفترچه آدرس» در پنل کاربری** — کارت‌های آدرس با فیلتر همه/فرستنده/گیرنده، فرم افزودن/ویرایش، حذف با تأیید SweetAlert، تنظیم پیش‌فرض، حالت خالی راهنما +- [x] **پروکسی AJAX امن** — ۴ هندلر جدید (get/save/delete/set-default) با nonce + sanitize + توکن Sanctum کاربر +- [x] **تست‌ها:** ۱۸ تست جدید (`AddressBookControllerTest`) — لیست/فیلتر/ساخت/ویرایش/حذف/مالکیت/سقف/تکراری/پیش‌فرض/markUsed/formFields + چندزبانه (i18n) همچنان به زمان دیپلوی موکول است. ### معماری ارزی (Currency Architecture) @@ -501,15 +511,27 @@ WALLET_AUTO_CREATE=true | ۴ | SweetAlert2 (v11.14.5 باندل محلی در پلاگین bridge) برای خطاهای فرم سفارش: اعتبارسنجی مراحل، خطای ثبت، خطای ارتباط + جایگزینی هر ۳ `alert()` بومی با پاپ‌آپ برند | ✅ | | ۵ | سازگارسازی migration فاکتور دستی با SQLite (تست‌ها طبق همین سند با SQLite اجرا می‌شوند) | ✅ | -### فاز ۳.۹ (موکول به آینده) +### فاز ۳.۹ (دفترچه آدرس) — ✅ تکمیل (۲۰۲۶-۱۱-۰۱) + +| # | کار | وضعیت | +|---|-----|--------| +| ۱ | مایگریشن + مدل `AddressBookEntry` (سقف ۵۰ رکورد، upsert ضدتکرار، آمار استفاده) | ✅ | +| ۲ | CRUD API `/api/v1/addresses` + default-set (Sanctum + Ownership) | ✅ | +| ۳ | اتصال به `POST /customer/orders` (save flags + address_id usage) | ✅ | +| ۴ | dropdown آدرس ذخیره‌شده + چک‌باکس «ذخیره این آدرس» در فرم سفارش وردپرس | ✅ | +| ۵ | تب «دفترچه آدرس» در پنل کاربری (کارت‌ها + فیلتر + فرم + حذف/پیش‌فرض) | ✅ | +| ۶ | ۴ پروکسی AJAX جدید با nonce/sanitize | ✅ | +| ۷ | ۱۸ تست Feature جدید | ✅ | + +### فاز ۳.۱۰ — دیپلوی Production + کارهای موکول‌شده به آینده | # | کار | وضعیت | اولویت | |---|-----|--------|---------| -| ۱ | چندزبانه (i18n) | ⏳ موکول به زمان دیپلوی | متوسط | -| ۲ | سند پیشنهادی سیستم مالی (درخواست ۱۳ کارفرما) | ⏳ موکول به آینده | پایین | -| ۳ | پلاگین SMS (به‌صورت ماژول قابل فعال‌سازی) | ⏳ منتظر پنل SMS مشتری | متوسط | -| ۴ | لاین واردات از چین (ماژول مجزا) | ⏳ مطالعاتاتی | پایین | -| ۵ | دفترچه آدرس (Address Book) مشتری | 📋 مطرح‌شده — قدم بعدی | متوسط | +| ۱ | **دیپلوی روی سرور Production** (pull + migrate + .env سرور + ایمپورت نرخ واقعی + E2E) | ⏳ قدم بعدی | بالا | +| ۲ | چندزبانه (i18n) | ⏳ موکول به زمان دیپلوی | متوسط | +| ۳ | سند پیشنهادی سیستم مالی (درخواست ۱۳ کارفرما) | ⏳ موکول به آینده | پایین | +| ۴ | پلاگین SMS (به‌صورت ماژول قابل فعال‌سازی) | ⏳ منتظر پنل SMS مشتری | متوسط | +| ۵ | لاین واردات از چین (ماژول مجزا) | ⏳ مطالعاتاتی | پایین | | ۶ | **ارز مرجع قابل تنظیم** (نسخه Pro) | 📋 مطرح‌شده — `01_Documents/IFNEX_Pro_Backlog.md` | پایین (نسخه Pro) | | ۷ | سیستم بروزرسانی + لایسنس (Domain Lock) | 📋 مطرح‌شده — `01_Documents/IFNEX_Pro_Backlog.md` | پایین (نسخه Pro) | diff --git a/CLIENT_DELIVERY.md b/CLIENT_DELIVERY.md index b558518..8996d5e 100644 --- a/CLIENT_DELIVERY.md +++ b/CLIENT_DELIVERY.md @@ -1,8 +1,8 @@ # IFNEX — چک‌لیست تحویل به مشتری -> **وضعیت:** آماده برای دیپلوی (فاز ۳.۸ و ۳.۸.۱ تکمیل شد) -> **فاز جاری:** فاز ۳.۸.۱ — سخت‌سازی Production (تکمیل) -> **تاریخ آخرین به‌روزرسانی:** ۲۰۲۶-۱۰-۳۱ +> **وضعیت:** آماده برای دیپلوی (فاز ۳.۸، ۳.۸.۱ و ۳.۹ تکمیل شد) +> **فاز جاری:** فاز ۳.۹ — دفترچه آدرس (تکمیل) → قدم بعدی: دیپلوی Production (فاز ۳.۱۰) +> **تاریخ آخرین به‌روزرسانی:** ۲۰۲۶-۱۱-۰۱ > **مخزن:** [git.vernahost.ir/gitmodir110/ifnex](https://www.git.vernahost.ir/gitmodir110/ifnex) > ⚠️ این فایل فقط برای مدیریت تعهدات، تست و تحویل نسخهٔ فعلی مشتری است و جایگزین `AGENT.md` یا `IFNEX_Roadmap.md` نیست. @@ -502,9 +502,9 @@ Create Import Order # ۱۵. خلاصهٔ وضعیت تحویل -**وضعیت فعلی:** آماده برای دیپلوی (فاز ۳.۸ و ۳.۸.۱ تکمیل شد) +**وضعیت فعلی:** آماده برای دیپلوی (فاز ۳.۸، ۳.۸.۱ و ۳.۹ تکمیل شد) -**آخرین کامیت بازبینی‌شده:** ۲۰۲۶-۱۰-۳۱ — فاز ۳.۸.۱ (سخت‌سازی Production: فیکس باگ P0 کد تخفیف، گارد Mock در Production، حذف هک‌های دیباگ، نسخه‌بندی asset، سخت‌سازی آپلود، خروج wp-config از گیت، تعمیر تست‌ها). +**آخرین کامیت بازبینی‌شده:** ۲۰۲۶-۱۱-۰۱ — فاز ۳.۹ (دفترچه آدرس: مدل + مایگریشن، CRUD API با Sanctum، اتصال به ثبت سفارش، dropdown آدرس ذخیره‌شده + چک‌باکس ذخیره در فرم سفارش، تب دفترچه آدرس در پنل کاربری، ۴ پروکسی AJAX، ۱۸ تست جدید). ## ۱۵.۱ تکمیل‌شده در فاز ۳.۶ (۲۰۲۶-۱۰-۰۴) @@ -552,14 +552,23 @@ Create Import Order - [x] **تعمیر ۷ تست شکسته + ۹ تست جدید** — ۳۳ تست پاس، ۱۰۱ assertion - [x] **چک‌لیست امنیتی استقرار** در `DEPLOYMENT_HANDOFF.md` (بخش ۴.۴) -## ۱۵.۵ باقی‌مانده +## ۱۵.۵ تکمیل‌شده در فاز ۳.۹ — دفترچه آدرس (۲۰۲۶-۱۱-۰۱) + +- [x] **مدل + جدول `address_book_entries`** — نقش sender/receiver، فیلدهای عیناً مطابق ستون‌های sender_/receiver_ در shipments، آدرس پیش‌فرض، شمارندهٔ استفاده + آخرین استفاده، سقف ۵۰ رکورد برای هر کاربر +- [x] **CRUD API محافظت‌شده با Sanctum** — `GET/POST/PUT/DELETE /api/v1/addresses` + `POST /addresses/{id}/default`؛ بررسی مالکیت در همهٔ اکشن‌ها؛ فیلتر بر اساس نقش و جستجو؛ جلوگیری از رکورد تکراری (نقش + تلفن + آدرس → بروزرسانی) +- [x] **اتصال به ثبت سفارش** — چک‌باکس «ذخیره این آدرس» برای هر دو نقش (upsert بدون رکورد تکراری) + ثبت استفاده از آدرس انتخاب‌شده؛ عملیات دفترچه هرگز ثبت سفارش را ناموفق نمی‌کند +- [x] **فرم سفارش وردپرس** — dropdown «آدرس‌های ذخیره‌شده» بالای هر دو بخش فرستنده/گیرنده (پر شدن یک‌کلیکی تمام فیلدها) + چک‌باکس ذخیره؛ در نبود آدرس ذخیره‌شده کاملاً مخفی است +- [x] **تب «دفترچه آدرس» در پنل کاربری** — کارت‌های آدرس با فیلتر (همه/فرستنده/گیرنده)، فرم افزودن/ویرایش، حذف با تأیید SweetAlert، تنظیم پیش‌فرض، حالت خالی راهنما، موبایل-فرندلی +- [x] **۴ پروکسی AJAX امن** (get/save/delete/set-default) — nonce + sanitize + توکن Sanctum کاربر در سمت لاراول +- [x] **۱۸ تست Feature جدید** — مالکیت، سقف، تکراری، پیش‌فرض، فیلترها و رفتار مدل + +## ۱۵.۶ باقی‌مانده - [ ] i18n (موکول به زمان دیپلوی) - [ ] تست نهایی UAT کارفرما -- [ ] دیپلوی روی سرور Production (با چرخش کلیدها طبق بخش ۴.۴ سند استقرار) +- [ ] دیپلوی روی سرور Production (با چرخش کلیدها طبق بخش ۴.۴ سند استقرار) — **قدم بعدی** - [ ] سند پیشنهادی سیستم مالی (درخواست ۱۳ کارفرما — موکول به آینده) - [ ] پلاگین SMS (منتظر تحویل پنل SMS مشتری) -- [ ] دفترچه آدرس (Address Book) مشتری — قدم بعدی ### باقی‌مانده برای نسخه Pro (آینده) diff --git a/README-INSTALL.md b/README-INSTALL.md deleted file mode 100644 index 09d2e1a..0000000 --- a/README-INSTALL.md +++ /dev/null @@ -1,110 +0,0 @@ -# 📦 بسته بروزرسانی IFNEX — فاز ۳.۸.۱ (سخت‌سازی Production) - -**تاریخ:** ۳۱ اکتبر ۲۰۲۶ -**تعداد فایل‌ها:** ۲۱ فایل + این راهنما -**⚠️ شامل یک فیکس بحرانی (P0):** باگ ثبت سفارش — حتماً اعمال شود - ---- - -## 🚨 مهم‌ترین فیکس: باگ P0 کد تخفیف - -در بازبینی امنیتی کشف شد که متد `consumeDiscountCode()` در کنترلر سفارش **صدا زده می‌شد ولی هیچ‌جا تعریف نشده بود** — یعنی: - -> ❌ **ثبت سفارش مشتری در وضعیت فعلی کلاً خطای 500 می‌دهد!** - -(این باگ از کامیت فلوی بازبینی/ارسال مجدد وارد کد شده بود و چون تست‌های قدیمی شکسته بودند پنهان مانده بود) - -**راه‌حل اعمال‌شده در این بسته:** -- سرویس جدید `DiscountCodeService` + جدول `discount_code_usages` -- مصرف یک‌بار هر کد + رکورد Audit کامل (چه کسی، چه سفارشی، چه مبلغی) -- قفل رقابتی (`lockForUpdate`) — دو ثبت همزمان نمی‌توانند ظرفیت را دور بزنند -- خطای 422 با پیام واضح اگر کد بین محاسبه و ثبت منقضی شود -- گارد ظرفیت در قیمت‌گذاری هم - ---- - -## 🔧 بقیه تغییرات (سخت‌سازی Production) - -| # | فیکس | توضیح | -|---|------|-------| -| ۱ | **گارد Mock Gateway** | اگر روی سرور `APP_ENV=production` باشد و درگاه تستی تنظیم شده باشد، پردازش با خطای صریح مسدود می‌شود (قبلاً خطر پرداخت مجازی واقعی وجود داشت!) | -| ۲ | **حذف هک‌های دیباگ وردپرس** | `opcache_reset()` و `header('Cache-Control: no-store')` از ۵ فایل پلاگین حذف شد — در Production باعث header error و شکستن کش می‌شد | -| ۳ | **نسخه‌بندی سالم assetها** | cache-buster `'2.0.0-' . time()` (هر بار کش را می‌شکست) با `filemtime` جایگزین شد — کش فقط وقتی فایل عوض شود باطل می‌شود | -| ۴ | **سخت‌سازی آپلود تعهدنامه** | `mimetypes` اضافه شد — محتوای واقعی فایل بررسی می‌شود نه فقط پسوند | -| ۵ | **`.env.example` بروز** | بلوک Production (لاگ روزانه، APP_DEBUG=false، کش‌ها) + `IFNEX_BRIDGE_API_KEY` + راهنمای چرخش کلید | -| ۶ | **سند استقرار** | بخش جدید «۴.۴ چک‌لیست امنیتی سخت‌سازی» در `DEPLOYMENT_HANDOFF.md` | -| ۷ | **تعمیر و توسعه تست‌ها** | ۷ تست شکستهٔ قدیمی تعمیر شد + ۹ تست جدید → **۳۳ تست پاس، ۱۰۱ assertion** ✅ | - ---- - -## ⚠️ دو فایل از گیت حذف شده‌اند (اقدام دستی لازم!) - -این بسته شامل حذف ۲ فایل از **ایندکس گیت** است (نه از دیسک!): - -| فایل | دلیل | -|------|------| -| `03_WordPress/wp-config.php` | حاوی saltهای واقعی بود — لو‌رفته فرض می‌شود؛ فقط `wp-config-sample.php` در مخزن می‌ماند | -| `04_Laravel/.phpunit.result.cache` | فایل کش محلی — نباید در گیت باشد | - -چون ZIP نمی‌تواند حذف انجام دهد، **بعد از اکسترکت** این دو دستور را در ریشه پروژه اجرا کنید: - -```bash -git rm --cached 03_WordPress/wp-config.php -git rm --cached 04_Laravel/.phpunit.result.cache -``` - -> ✅ `.gitignore` بروز شده و بعد از این، این فایل‌ها دیگر کامیت نمی‌شوند. -> فایل `wp-config.php` روی دیسک شما دست‌نخورده باقی می‌ماند. - ---- - -## 📥 روش نصب - -1. محتوای پوشه `ifnex-updates/` را **دقیقاً روی ریشه پروژه IFNEX** اکسترکت کنید (Replace) -2. دو دستور `git rm --cached` بالا را اجرا کنید -3. مراحل بعد از اکسترکت (پایین) را انجام دهید - ---- - -## ⚙️ مراحل بعد از اکسترکت - -### Laravel: -```bash -cd 04_Laravel - -# مایگریشن جدید (جدول discount_code_usages) -php artisan migrate - -# اجرای تست‌ها — باید ۳۳ پاس ببینید -php artisan test -``` - -### WordPress: -- پلاگین `ifnex-bridge` را غیرفعال و دوباره فعال کنید -- اگر کش افزونه دارید، پاکسازی کنید - ---- - -## ✅ چک‌لیست تست سریع - -| # | تست | نتیجه مورد انتظار | -|---|-----|-------------------| -| 1 | `php artisan test` | **33 passed, 101 assertions** | -| 2 | `php artisan migrate` | مایگریشن `discount_code_usages` بدون خطا | -| 3 | ثبت سفارش **بدون** کد تخفیف | موفق (این قبلاً 500 می‌داد!) | -| 4 | ثبت سفارش **با** کد تخفیف فعال | موفق + `used_count` کد +1 می‌شود | -| 5 | ثبت سفارش دوم با همان کد (اگر usage_limit=1) | خطای 422 با پیام واضح | -| 6 | فرم سفارش وردپرس | بدون خطای کنسول؛ assetها با `?ver=2.1.0.xxx` لود می‌شوند | - ---- - -## 📤 بعد از تست موفق - -```bash -git add -A -git commit -m "apply: IFNEX phase 3.8.1 package (production hardening + P0 discount fix)" -git push origin main -``` - -> ⚠️ **قبل از دیپلوی روی سرور:** چک‌لیست بخش ۴.۴ فایل `DEPLOYMENT_HANDOFF.md` را حتماً انجام دهید -> (چرخش کلیدها، بازتولید saltهای وردپرس، تنظیم درگاه واقعی، لاگ روزانه، کش‌ها).