diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 6e54fd5..082f1cb 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,5 +1,7 @@ # KiriminAja Official — WordPress/WooCommerce Shipping Plugin +These are local repository instructions for GitHub Copilot. `AGENTS.md` is the canonical source for shared agentic workflow rules. Keep this file aligned with `AGENTS.md` and `CLAUDE.md`. + ## Project Overview KiriminAja Official is a WooCommerce shipping plugin for Indonesian online sellers. It provides real-time shipping rates from multiple couriers, COD support, pickup scheduling, label printing, and package tracking—all from the WooCommerce dashboard. @@ -254,6 +256,16 @@ Tables are created via `Migration\SetupMigration::register()` which calls `setti --- +## Local Workflow Rules + +- Use `rtk` before shell commands in this workspace. +- Check `git status --short --branch` before edits and avoid unrelated user changes. +- Prefer narrow fixes in the existing service/controller/template structure. +- Follow WordPress Coding Standards unless nearby code has a stronger local convention. +- When using `$wpdb->prepare()` with dynamic `IN (...)` placeholders, pass the prepared SQL directly into `$wpdb->query()` or similar calls. Do not store prepared SQL in an intermediate variable. Keep replacement counts exact and avoid interpolated placeholder variables that trigger WordPressCS / Plugin Check `PreparedSQL` warnings. +- Do not start local WordPress or dev servers unless explicitly requested. +- When changing packaged source files, run `make zip` before final verification. + ## Testing - Test runner: ParaTest diff --git a/AGENTS.md b/AGENTS.md index eb28eeb..ee78362 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,7 @@ # AGENTS.md +This is the canonical local instruction file for agentic coding tools in this repository. Keep `AGENTS.md`, `CLAUDE.md`, and `.github/copilot-instructions.md` aligned when these project rules change. + ## Project Context This repository is the KiriminAja Official WordPress plugin for WooCommerce. Keep changes scoped to plugin behavior, tests, packaging, and documentation that directly support the requested task. @@ -11,6 +13,7 @@ This repository is the KiriminAja Official WordPress plugin for WooCommerce. Kee - Prefer narrow fixes in the existing service/controller/template structure. - Follow the official WordPress Coding Standards for all plugin changes: https://developer.wordpress.org/coding-standards/wordpress-coding-standards/ - Apply those standards to PHP, JavaScript, CSS, HTML, documentation, escaping, sanitization, naming, and formatting decisions unless the surrounding file has a stronger local convention. +- Be extra careful with WordPressCS / Plugin Check database sniffs: when using `$wpdb->prepare()` with dynamic `IN (...)` placeholders, pass the prepared SQL directly into the query call instead of storing it in an intermediate variable, and avoid mismatched replacement counts or interpolated placeholder variables that trigger `PreparedSQL` warnings. - Do not start local WordPress or dev servers unless explicitly requested. - When changing packaged source files, run `make zip` before final verification because build/source parity is tested. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..564b3c3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,29 @@ +# CLAUDE.md + +This is the canonical local instruction file for Claude and other agentic coding tools in this repository. Keep `CLAUDE.md`, `AGENTS.md`, and `.github/copilot-instructions.md` aligned when these project rules change. + +## Project Context + +This repository is the KiriminAja Official WordPress plugin for WooCommerce. Keep changes scoped to plugin behavior, tests, packaging, and documentation that directly support the requested task. + +## Workflow + +- Use `rtk` before shell commands in this workspace. +- Check `git status --short --branch` before edits and avoid touching unrelated user changes. +- Prefer narrow fixes in the existing service/controller/template structure. +- Follow the official WordPress Coding Standards for all plugin changes: https://developer.wordpress.org/coding-standards/wordpress-coding-standards/ +- Apply those standards to PHP, JavaScript, CSS, HTML, documentation, escaping, sanitization, naming, and formatting decisions unless the surrounding file has a stronger local convention. +- Be extra careful with WordPressCS / Plugin Check database sniffs: when using `$wpdb->prepare()` with dynamic `IN (...)` placeholders, pass the prepared SQL directly into the query call instead of storing it in an intermediate variable, and avoid mismatched replacement counts or interpolated placeholder variables that trigger `PreparedSQL` warnings. +- Do not start local WordPress or dev servers unless explicitly requested. +- When changing packaged source files, run `make zip` before final verification because build/source parity is tested. + +## Testing + +- Use ParaTest as the default test runner: `make test`. +- For focused checks, run `vendor/bin/paratest --configuration paratest.xml --filter `. +- Keep `tests/` string/structure assertions aligned with the current implementation when behavior is intentionally changed. + +## Packaging + +- `CLAUDE.md`, `AGENTS.md`, `paratest.xml`, tests, development files, and cache directories must not be included in the distributable zip. +- The distributable archive is produced by `make zip`. diff --git a/inc/Controllers/TransactionProcessController.php b/inc/Controllers/TransactionProcessController.php index 2c569c7..a02da45 100644 --- a/inc/Controllers/TransactionProcessController.php +++ b/inc/Controllers/TransactionProcessController.php @@ -712,6 +712,11 @@ public function renderWooActionModalTemplatesForKiriofPage() + + diff --git a/lang/kiriminaja-official-id_ID.po b/lang/kiriminaja-official-id_ID.po index 6f88eaf..0499ad8 100644 --- a/lang/kiriminaja-official-id_ID.po +++ b/lang/kiriminaja-official-id_ID.po @@ -254,6 +254,9 @@ msgstr "Jadwal Tersedia" msgid "-- Select schedule --" msgstr "-- Pilih jadwal --" +msgid "Browser secure storage is unavailable. PIN will not be remembered." +msgstr "Penyimpanan aman browser tidak tersedia. PIN tidak akan diingat." + msgid "Close" msgstr "Tutup" @@ -263,6 +266,19 @@ msgstr "Tidak ada jadwal pickup yang tersedia." msgid "Please select a pickup schedule." msgstr "Silakan pilih jadwal pickup." +#, php-format +msgid "Remember PIN on this browser for %d minutes" +msgstr "Ingat PIN di browser ini selama %d menit" + +msgid "Saved PIN expired. Please enter your PIN again." +msgstr "PIN tersimpan sudah kedaluwarsa. Silakan masukkan PIN Anda lagi." + +msgid "Saved PIN was cleared. Please enter your latest PIN again." +msgstr "PIN tersimpan telah dihapus. Silakan masukkan PIN terbaru Anda lagi." + +msgid "Saved PIN will be reused until it expires on this browser." +msgstr "PIN tersimpan akan digunakan kembali sampai masa berlakunya habis di browser ini." + #: inc/Base/Helper.php:37 #: inc/Base/Helper.php:57 msgid "Canceled" diff --git a/templates/transaction-process/view/index.php b/templates/transaction-process/view/index.php index 16564c3..e386ae0 100644 --- a/templates/transaction-process/view/index.php +++ b/templates/transaction-process/view/index.php @@ -8,6 +8,16 @@ $kiriof_helper = kiriof_helper(); $kiriof_homeUrl = home_url(); $kiriof_adminUrl = $kiriof_homeUrl . '/wp-admin'; +$kiriof_current_user = wp_get_current_user(); +$kiriof_pin_cache_ttl = (int) apply_filters('kiriof_pin_cache_ttl', 15 * MINUTE_IN_SECONDS, $kiriof_current_user); +if ($kiriof_pin_cache_ttl < MINUTE_IN_SECONDS) { + $kiriof_pin_cache_ttl = MINUTE_IN_SECONDS; +} +$kiriof_pin_cache_label = sprintf( + /* translators: %d: cached PIN duration in minutes. */ + __('Remember PIN on this browser for %d minutes', 'kiriminaja-official'), + (int) ceil($kiriof_pin_cache_ttl / MINUTE_IN_SECONDS) +); /** * @var string $locale @@ -627,6 +637,16 @@ const kjPickScheduleLabel = ''; const kjConfirmPinLabel = ''; const kjValidateLabel = ''; +const kjPinCacheConfig = { +key: , +ttl: , +userHash: , +siteHash: , +rememberLabel: , +expiredLabel: , +invalidatedLabel: , +unsupportedLabel: , +}; const kjUpdateRequestPickupCount = () => { const pickupCount = $transactionCheckboxes().filter(':checked:not(:disabled)[data-can-pickup="1"]').length; const printCount = $transactionCheckboxes().filter(':checked:not(:disabled)[data-can-print="1"]').length; @@ -730,7 +750,7 @@ function kiriofSetModalState($modal, state) { } else if (state==='pin' ) { $modal.find('.kiriof-modal-state-pin').show(); $modal.find('#btn-next').text(kjValidateLabel); - kjFocusPinInput($modal); + kjPrepareCreditPinStep($modal); } else { $modal.find('.kiriof-modal-state-content').show(); } @@ -789,6 +809,159 @@ function kjResetPinRetryState($modal) { $modal.data('kiriofPinCooldownTimer', null); } + function kjGetPinRememberCheckbox($modal) { + return $modal.find('#kiriof-pin-remember'); + } + + function kjGetPinCacheNotice($modal) { + return $modal.find('.kiriof-pin-cache-notice'); + } + + function kjSetPinCacheNotice($modal, message, tone) { + const $notice = kjGetPinCacheNotice($modal); + if (!message) { + $notice.hide().text('').removeAttr('data-tone'); + return; + } + + $notice.attr('data-tone', tone || 'info').text(message).show(); + } + + function kjCanUsePinCache() { + return window.isSecureContext && window.crypto && window.crypto.subtle && window.localStorage; + } + + async function kjGetPinCacheKeyMaterial() { + const rawKey = await window.crypto.subtle.digest('SHA-256', new TextEncoder().encode(`${kjPinCacheConfig.userHash}:${kjPinCacheConfig.siteHash}`)); + return window.crypto.subtle.importKey('raw', rawKey, 'AES-GCM', false, ['encrypt', 'decrypt']); + } + + function kjGetPinCacheRecord() { + if (!kjCanUsePinCache()) { + return null; + } + + try { + const rawValue = window.localStorage.getItem(kjPinCacheConfig.key); + if (!rawValue) { + return null; + } + + const parsedValue = JSON.parse(rawValue); + if (!parsedValue || parsedValue.userHash !== kjPinCacheConfig.userHash || parsedValue.siteHash !== kjPinCacheConfig.siteHash) { + window.localStorage.removeItem(kjPinCacheConfig.key); + return null; + } + + if (!parsedValue.expiresAt || Date.now() >= parsedValue.expiresAt) { + window.localStorage.removeItem(kjPinCacheConfig.key); + return { expired: true }; + } + + return parsedValue; + } catch (error) { + window.localStorage.removeItem(kjPinCacheConfig.key); + return null; + } + } + + async function kjDecryptCachedPin(record) { + if (!record || !record.ciphertext || !record.iv) { + return ''; + } + + const key = await kjGetPinCacheKeyMaterial(); + const iv = Uint8Array.from(atob(record.iv), (character) => character.charCodeAt(0)); + const ciphertext = Uint8Array.from(atob(record.ciphertext), (character) => character.charCodeAt(0)); + const decrypted = await window.crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext); + return new TextDecoder().decode(decrypted).replace(/\D/g, '').substring(0, 6); + } + + async function kjPersistCachedPin($modal, pin) { + if (!kjCanUsePinCache()) { + kjSetPinCacheNotice($modal, kjPinCacheConfig.unsupportedLabel, 'warning'); + return false; + } + + const rememberPin = kjGetPinRememberCheckbox($modal).is(':checked'); + if (!rememberPin || !pin || pin.length !== 6) { + return false; + } + + const key = await kjGetPinCacheKeyMaterial(); + const iv = window.crypto.getRandomValues(new Uint8Array(12)); + const encrypted = await window.crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, new TextEncoder().encode(pin)); + const payload = { + userHash: kjPinCacheConfig.userHash, + siteHash: kjPinCacheConfig.siteHash, + expiresAt: Date.now() + (kjPinCacheConfig.ttl * 1000), + iv: btoa(String.fromCharCode(...iv)), + ciphertext: btoa(String.fromCharCode(...new Uint8Array(encrypted))), + }; + window.localStorage.setItem(kjPinCacheConfig.key, JSON.stringify(payload)); + kjSetPinCacheNotice($modal, kjPinCacheConfig.rememberLabel, 'success'); + return true; + } + + function kjClearCachedPin($modal, reason) { + if (window.localStorage) { + window.localStorage.removeItem(kjPinCacheConfig.key); + } + kjGetPinRememberCheckbox($modal).prop('checked', false); + if (reason === 'expired') { + kjSetPinCacheNotice($modal, kjPinCacheConfig.expiredLabel, 'warning'); + } else if (reason === 'invalid') { + kjSetPinCacheNotice($modal, kjPinCacheConfig.invalidatedLabel, 'warning'); + } else if (!reason) { + kjSetPinCacheNotice($modal, '', 'info'); + } + } + + async function kjRestoreCachedPin($modal) { + if (!kjCanUsePinCache()) { + return false; + } + + const record = kjGetPinCacheRecord(); + if (!record) { + return false; + } + + if (record.expired) { + kjClearCachedPin($modal, 'expired'); + return false; + } + + try { + const pin = await kjDecryptCachedPin(record); + if (!pin || pin.length !== 6) { + kjClearCachedPin($modal, 'invalid'); + return false; + } + + kjSetPinValue($modal, pin); + kjGetPinRememberCheckbox($modal).prop('checked', true); + kjSetPinCacheNotice($modal, kjPinCacheConfig.rememberLabel, 'success'); + return true; + } catch (error) { + kjClearCachedPin($modal, 'invalid'); + return false; + } + } + + function kjPrepareCreditPinStep($modal) { + kjEnsurePinInputReady($modal); + if (!kjCanUsePinCache()) { + kjSetPinCacheNotice($modal, kjPinCacheConfig.unsupportedLabel, 'warning'); + } + window.setTimeout(function() { + void kjRestoreCachedPin($modal).finally(function() { + kjFocusPinInput($modal); + kjUpdatePickupButton($modal); + }); + }, 0); + } + function kjNormalizePinErrorData($modal, data) { const normalized = Object.assign({}, data || {}); const error = normalized.error || ''; @@ -839,6 +1012,9 @@ function kjNormalizePinErrorData($modal, data) { const $modal = kiriofGetRequestPickupModal(); kjResetPinRetryState($modal); + kjSetPinValue($modal, ''); + kjGetPinRememberCheckbox($modal).prop('checked', false); + kjSetPinCacheNotice($modal, '', 'info'); kiriofSetModalState($modal, 'loading'); $modal.find('#btn-next').prop('disabled', true); @@ -1001,6 +1177,11 @@ function kjLoadPaymentMethodConfig($modal, totalFee) { kjResetPinRetryState($modal); $modal.find('#kiriof-pin-widget').removeAttr('invalid'); $modal.find('.kiriof-pin-error').hide().text(''); + kjSetPinCacheNotice($modal, '', 'info'); + } else { + void kjRestoreCachedPin($modal).finally(function() { + kjUpdatePickupButton($modal); + }); } kjUpdatePickupButton($modal); }); @@ -1166,6 +1347,7 @@ function kjSubmitPickup($modal, ids, schedule, paymentMethod, pin, closeModal) { kiriofSetModalState($modal, 'content'); $errMsg.text('*').show(); } else if (errCode === 'PIN_INVALID' || errCode === 'PIN_MAX_ATTEMPT_REACHED') { + kjClearCachedPin($modal, 'invalid'); kiriofSetModalState($modal, 'pin'); kjShowPinError($modal, resp?.data || {}, resp?.message || errCode); } else { @@ -1253,6 +1435,7 @@ function kjSubmitPickup($modal, ids, schedule, paymentMethod, pin, closeModal) { complete: function(pinResp) { const pinData = JSON.parse(pinResp.responseText).data; if (pinData?.status !== 200) { + kjClearCachedPin($modal, 'invalid'); kiriofSetModalState($modal, 'pin'); const pinErr = pinData?.data; if (pinErr?.error === 'PIN_MAX_ATTEMPT_REACHED') { @@ -1265,7 +1448,9 @@ function kjSubmitPickup($modal, ids, schedule, paymentMethod, pin, closeModal) { return; } kjResetPinRetryState($modal); - kjSubmitPickup($modal, orderIds, schedule, paymentMethod, pin, closeModal); + void kjPersistCachedPin($modal, pin).finally(function() { + kjSubmitPickup($modal, orderIds, schedule, paymentMethod, pin, closeModal); + }); } }); return; diff --git a/templates/woocommerce/cart/cart-shipping.php b/templates/woocommerce/cart/cart-shipping.php index fe7e0e0..4e25506 100644 --- a/templates/woocommerce/cart/cart-shipping.php +++ b/templates/woocommerce/cart/cart-shipping.php @@ -88,32 +88,32 @@ $kiriof_session_rate_meta = ( function_exists( 'WC' ) && WC() && isset( WC()->session ) && WC()->session ) ? (array) WC()->session->get( 'kiriof_shipping_coupon_rate_meta', array() ) : array(); - $kiriof_get_shipping_method_label_html = static function ( $method ) use ( $kiriof_session_rate_meta ) { - $kiriof_method_label_html = wp_kses_post( wc_cart_totals_shipping_method_label( $method ) ); - $kiriof_rate_meta = $kiriof_session_rate_meta[ $method->id ] ?? array(); + $kiriof_get_shipping_method_label_html = static function ( $kiriof_method ) use ( $kiriof_session_rate_meta ) { + $kiriof_method_label_html = wp_kses_post( wc_cart_totals_shipping_method_label( $kiriof_method ) ); + $kiriof_rate_meta = $kiriof_session_rate_meta[ $kiriof_method->id ] ?? array(); $kiriof_original_cost = (float) ( $kiriof_rate_meta['original_cost'] ?? 0.0 ); $kiriof_discount_amount = (float) ( $kiriof_rate_meta['discount_amount'] ?? 0.0 ); $kiriof_badge = (string) ( $kiriof_rate_meta['badge'] ?? '' ); $kiriof_notice = (string) ( $kiriof_rate_meta['notice'] ?? '' ); - $kiriof_current_cost = isset( $method->cost ) ? (float) $method->cost : 0.0; + $kiriof_current_cost = isset( $kiriof_method->cost ) ? (float) $kiriof_method->cost : 0.0; - if ( method_exists( $method, 'get_meta' ) ) { + if ( method_exists( $kiriof_method, 'get_meta' ) ) { if ( 0.0 === $kiriof_original_cost ) { - $kiriof_original_cost = (float) $method->get_meta( 'kiriof_shipping_coupon_original_cost', true ); + $kiriof_original_cost = (float) $kiriof_method->get_meta( 'kiriof_shipping_coupon_original_cost', true ); } if ( 0.0 === $kiriof_discount_amount ) { - $kiriof_discount_amount = max( 0.0, (float) $method->get_meta( 'kiriof_shipping_coupon_discount_amount', true ) ); + $kiriof_discount_amount = max( 0.0, (float) $kiriof_method->get_meta( 'kiriof_shipping_coupon_discount_amount', true ) ); } if ( '' === $kiriof_badge ) { - $kiriof_badge = (string) $method->get_meta( 'kiriof_shipping_coupon_badge', true ); + $kiriof_badge = (string) $kiriof_method->get_meta( 'kiriof_shipping_coupon_badge', true ); } if ( '' === $kiriof_notice ) { - $kiriof_notice = (string) $method->get_meta( 'kiriof_shipping_coupon_notice', true ); + $kiriof_notice = (string) $kiriof_method->get_meta( 'kiriof_shipping_coupon_notice', true ); } } if ( $kiriof_discount_amount > 0 && $kiriof_original_cost > $kiriof_current_cost ) { - $kiriof_method_label_html = esc_html( (string) $method->get_label() ); + $kiriof_method_label_html = esc_html( (string) $kiriof_method->get_label() ); if ( '' !== $kiriof_badge ) { $kiriof_method_label_html .= ' ' . esc_html( $kiriof_badge ) . ''; } @@ -130,7 +130,7 @@ ); $kiriof_method_label_html .= ''; } elseif ( '' !== $kiriof_notice ) { - $kiriof_method_label_html = esc_html( (string) $method->get_label() ); + $kiriof_method_label_html = esc_html( (string) $kiriof_method->get_label() ); $kiriof_method_label_html .= '' . wp_kses_post( wc_price( $kiriof_current_cost ) ) . ''; $kiriof_method_label_html .= '' . esc_html( $kiriof_notice ) . ''; } @@ -163,22 +163,20 @@ class="wc-enhanced-select kiriof-classic-shipping-method-select"