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های وردپرس، تنظیم درگاه واقعی، لاگ روزانه، کش‌ها).