Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44a2ddda4a | ||
|
|
52f3036960 | ||
|
|
004f2382cb | ||
|
|
6cf142e35b | ||
|
|
47851d36ec | ||
|
|
c43682ef0c |
@@ -4,6 +4,61 @@ All notable changes to this project will be documented in this file.
|
|||||||
|
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||||
|
|
||||||
|
## [0.27.5] - 2026-09-30
|
||||||
|
### Security
|
||||||
|
- OTP codes (both customer login via `UserOtpService` and staff login via
|
||||||
|
`OtpService`) were stored in plaintext in `users.otp_code`/`lunar_staff.otp_code`
|
||||||
|
and compared against the plaintext guess. The staff path was additionally
|
||||||
|
weaker — a loose `!=` comparison with no timing-attack protection and no
|
||||||
|
attempt-limiting at all. Both columns are replaced with `otp_code_hash`
|
||||||
|
(bcrypt, via a `'hashed'` cast — same convention `password` already uses),
|
||||||
|
compared with `Hash::check()`. The cache-backed pending-signup OTP path
|
||||||
|
(an email with no `User` row yet) is hashed the same way. No backfill —
|
||||||
|
any code mid-flight when this deploys is invalidated (codes expire in 10
|
||||||
|
minutes regardless, so the practical impact is limited to a re-request).
|
||||||
|
- `App\Models\User`'s (3dealer) and `Modules\Core\Auth\Models\Staff`'s
|
||||||
|
`$hidden` arrays didn't list `otp_code`/`otp_expires_at`/`otp_attempts`/
|
||||||
|
`pending_email_code_hash`/etc. at all — any serialization of either model
|
||||||
|
(an API response, `Auth::user()` returned somewhere) would have leaked
|
||||||
|
those fields, including the (now-hashed, previously plaintext) OTP code
|
||||||
|
itself. `Staff` additionally never overrode Lunar's own base `$hidden`, so
|
||||||
|
it also lacked `password`/`remember_token` protection until now.
|
||||||
|
|
||||||
|
## [0.27.4] - 2026-09-29
|
||||||
|
### Added
|
||||||
|
- Generic `.bbk-notice` / `.bbk-notice--info` message box and a
|
||||||
|
`--bbk-color-info` custom property in `checkout.css`.
|
||||||
|
- Cart drawer focus handling: focus moves into the drawer on open, stays
|
||||||
|
inside it, returns to the opener on close, and is restored after each
|
||||||
|
cart update. Updates are announced as "Cart updated" instead of re-reading
|
||||||
|
the whole cart. New translation line `checkout.cart.updated` — re-run
|
||||||
|
`CheckoutTranslationsSeeder` in consuming apps to add it.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Cart drawer line: larger remove button on the title row, line total on
|
||||||
|
the quantity-stepper row, and screen-reader labels tied to the product
|
||||||
|
name. `.bbk-cart-item-aside` is removed — hosts restyling it should target
|
||||||
|
`.bbk-cart-item-head` / `.bbk-cart-item-foot` instead.
|
||||||
|
- Order confirmation page: narrower (560px), the confirmation-email note is
|
||||||
|
now an info box under the heading, the order summary comes before
|
||||||
|
shipping/billing (shown side by side), and the order number is prefixed
|
||||||
|
with `#`.
|
||||||
|
|
||||||
|
## [0.27.3] - 2026-09-29
|
||||||
|
### Added
|
||||||
|
- The checkout confirmation page now ends with the store's bank transfer
|
||||||
|
instructions (Store Details → Bank transfer) for a bank transfer order,
|
||||||
|
via `StoreDetailsService::bankTransferInstructionsFor()`. New translation
|
||||||
|
line `checkout.page.confirmation_bank_transfer_heading` — re-run
|
||||||
|
`CheckoutTranslationsSeeder` in consuming apps to add it.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- `StoreDetailsService::bankTransferInstructionsFor()` now fills a
|
||||||
|
`{order_reference}` (or `{{ order_reference }}`) typed into the bank
|
||||||
|
transfer instructions with the order's display reference
|
||||||
|
(`OrderReferenceDisplay`). It previously rendered literally in the order
|
||||||
|
confirmation email.
|
||||||
|
|
||||||
## [0.27.2] - 2026-09-29
|
## [0.27.2] - 2026-09-29
|
||||||
### Fixed
|
### Fixed
|
||||||
- `Modules\Core\Order\Services\TransactionRecorder::record()` — made idempotent
|
- `Modules\Core\Order\Services\TransactionRecorder::record()` — made idempotent
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
"name": "boboko/core",
|
"name": "boboko/core",
|
||||||
"description": "Core module — authentication and shared panel behaviour",
|
"description": "Core module — authentication and shared panel behaviour",
|
||||||
"type": "library",
|
"type": "library",
|
||||||
"version": "0.27.2",
|
"version": "0.27.5",
|
||||||
"autoload": {
|
"autoload": {
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
"Modules\\Core\\": "src/"
|
"Modules\\Core\\": "src/"
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* otp_code was stored in plaintext (a raw 6-digit string) and compared
|
||||||
|
* with hash_equals() against the plaintext guess in
|
||||||
|
* Modules\Core\Auth\Services\UserOtpService — hash_equals() only
|
||||||
|
* prevents a timing attack, it does nothing to protect the code itself
|
||||||
|
* from anyone with read access to the row. Replaced with a bcrypt hash,
|
||||||
|
* same pattern Modules\Core\Customer\Services\CustomerEmailChangeService
|
||||||
|
* already uses for its own pending_email_code_hash column.
|
||||||
|
*
|
||||||
|
* No backfill: any code mid-flight when this deploys is invalidated —
|
||||||
|
* codes expire in 10 minutes anyway, so the real-world impact is a
|
||||||
|
* shopper re-requesting one, not lost work.
|
||||||
|
*/
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->string('otp_code_hash')->nullable()->after('password');
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('otp_code');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->string('otp_code', 6)->nullable()->after('password');
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('otp_code_hash');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same fix as 2026_09_30_000001_hash_otp_code_on_users_table.php, for
|
||||||
|
* staff logins — see that migration's own docblock. This path was
|
||||||
|
* additionally weaker: Modules\Core\Auth\Services\OtpService compared
|
||||||
|
* with a loose != rather than hash_equals(), so it had no timing-attack
|
||||||
|
* protection at all on top of the plaintext storage.
|
||||||
|
*/
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('lunar_staff', function (Blueprint $table) {
|
||||||
|
$table->string('otp_code_hash')->nullable()->after('password');
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('lunar_staff', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('otp_code');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('lunar_staff', function (Blueprint $table) {
|
||||||
|
$table->string('otp_code', 6)->nullable()->after('password');
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('lunar_staff', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('otp_code_hash');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
+1
-1
@@ -393,7 +393,7 @@ Because Filament instantiates `Lunar\Admin\Models\Staff` directly (not a subclas
|
|||||||
```php
|
```php
|
||||||
use Lunar\Admin\Models\Staff as LunarStaff;
|
use Lunar\Admin\Models\Staff as LunarStaff;
|
||||||
|
|
||||||
LunarStaff::addActivitylogExcept(['otp_code', 'otp_expires_at', 'password']);
|
LunarStaff::addActivitylogExcept(['otp_code_hash', 'otp_expires_at', 'password']);
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+3
-2
@@ -30,11 +30,12 @@ Codes expire after **10 minutes**. After a successful validation the code is cle
|
|||||||
|
|
||||||
### Database
|
### Database
|
||||||
|
|
||||||
Two columns on the `lunar_staff` table (added by `2026_05_06_000001_add_otp_to_lunar_staff_table`):
|
Two columns on the `lunar_staff` table (added by `2026_05_06_000001_add_otp_to_lunar_staff_table`,
|
||||||
|
`otp_code` replaced with a hashed column by `2026_09_30_000002_hash_otp_code_on_lunar_staff_table`):
|
||||||
|
|
||||||
| Column | Type | Purpose |
|
| Column | Type | Purpose |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `otp_code` | string, nullable | The generated code |
|
| `otp_code_hash` | string, nullable | Bcrypt hash of the generated code (`'hashed'` cast on `Staff`) |
|
||||||
| `otp_expires_at` | timestamp, nullable | Expiry time |
|
| `otp_expires_at` | timestamp, nullable | Expiry time |
|
||||||
|
|
||||||
### Login Page
|
### Login Page
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@boboko/core",
|
"name": "@boboko/core",
|
||||||
"version": "0.27.2",
|
"version": "0.27.5",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Portable Stimulus controllers and styles for boboko-core's cart + checkout module. Installed as a real npm dependency (file:../boboko-core in dev, a tagged git install in prod) so a consuming app's `npm install` resolves this package's own dependencies (leaflet, @hotwired/stimulus) transitively, the same way `composer update boboko/*` does for PHP. See CONTRIBUTE.md's \"JS/CSS: a real npm package\" section.",
|
"description": "Portable Stimulus controllers and styles for boboko-core's cart + checkout module. Installed as a real npm dependency (file:../boboko-core in dev, a tagged git install in prod) so a consuming app's `npm install` resolves this package's own dependencies (leaflet, @hotwired/stimulus) transitively, the same way `composer update boboko/*` does for PHP. See CONTRIBUTE.md's \"JS/CSS: a real npm package\" section.",
|
||||||
|
|||||||
+110
-26
@@ -53,6 +53,7 @@
|
|||||||
--bbk-color-accent: #18181b;
|
--bbk-color-accent: #18181b;
|
||||||
--bbk-color-accent-text: #ffffff;
|
--bbk-color-accent-text: #ffffff;
|
||||||
--bbk-color-danger: #dc2626;
|
--bbk-color-danger: #dc2626;
|
||||||
|
--bbk-color-info: #2563eb;
|
||||||
|
|
||||||
--bbk-radius: 8px;
|
--bbk-radius: 8px;
|
||||||
--bbk-radius-sm: 4px;
|
--bbk-radius-sm: 4px;
|
||||||
@@ -153,6 +154,11 @@
|
|||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Programmatic focus targets only (tabindex=-1) — never reached by Tab, so
|
||||||
|
no ring needed. */
|
||||||
|
.bbk-cart-heading:focus,
|
||||||
|
.bbk-checkout-summary-heading:focus { outline: none; }
|
||||||
|
|
||||||
.bbk-visually-hidden {
|
.bbk-visually-hidden {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 1px;
|
width: 1px;
|
||||||
@@ -183,7 +189,7 @@
|
|||||||
|
|
||||||
.bbk-cart-item {
|
.bbk-cart-item {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 72px 1fr auto;
|
grid-template-columns: 72px 1fr;
|
||||||
gap: 0.875rem;
|
gap: 0.875rem;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
@@ -199,8 +205,23 @@
|
|||||||
|
|
||||||
.bbk-cart-item-detail { min-width: 0; }
|
.bbk-cart-item-detail { min-width: 0; }
|
||||||
|
|
||||||
|
/* Title + remove button share the first row; quantity stepper + line total
|
||||||
|
share the last one. */
|
||||||
|
.bbk-cart-item-head,
|
||||||
|
.bbk-cart-item-foot {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-cart-item-head { align-items: flex-start; }
|
||||||
|
.bbk-cart-item-foot { align-items: center; }
|
||||||
|
|
||||||
|
.bbk-cart-item-remove-form { flex: 0 0 auto; }
|
||||||
|
|
||||||
.bbk-cart-item-title {
|
.bbk-cart-item-title {
|
||||||
display: block;
|
display: block;
|
||||||
|
min-width: 0;
|
||||||
margin: 0 0 0.25rem;
|
margin: 0 0 0.25rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
@@ -252,24 +273,24 @@ a.bbk-cart-item-title:hover { text-decoration: underline; }
|
|||||||
color: var(--bbk-color-muted);
|
color: var(--bbk-color-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bbk-cart-item-aside {
|
.bbk-cart-item-total { margin: 0; font-weight: 600; white-space: nowrap; }
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-end;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-cart-item-total { margin: 0; font-weight: 600; }
|
|
||||||
|
|
||||||
|
/* 2.5rem hit area (same as the drawer's own close button), pulled up/right by
|
||||||
|
negative margins so the glyph lines up with the title's first line instead
|
||||||
|
of pushing the row taller. */
|
||||||
.bbk-cart-item-remove {
|
.bbk-cart-item-remove {
|
||||||
font-size: 1.125rem;
|
font-size: 1.75rem;
|
||||||
width: 1.5rem;
|
width: 2.5rem;
|
||||||
height: 1.5rem;
|
height: 2.5rem;
|
||||||
|
margin: -0.5rem -0.5rem 0 0;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
border-radius: var(--bbk-radius-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bbk-cart-item-remove:hover { background: var(--bbk-color-bg-muted); }
|
||||||
|
|
||||||
.bbk-cart-qty {
|
.bbk-cart-qty {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -905,10 +926,39 @@ textarea.bbk-field-input { resize: vertical; }
|
|||||||
to { transform: rotate(360deg); }
|
to { transform: rotate(360deg); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Notice ────────────────────────────────────────────────────────────
|
||||||
|
Inline, static message box: `.bbk-notice` + a tone modifier. Static
|
||||||
|
content, so no live-region role — for messages injected after load, add
|
||||||
|
role="status" (or role="alert" for errors) on the element itself. */
|
||||||
|
|
||||||
|
.bbk-notice {
|
||||||
|
--bbk-notice-color: var(--bbk-color-text);
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.625rem;
|
||||||
|
padding: 0.875rem 1rem;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--bbk-notice-color) 25%, transparent);
|
||||||
|
border-radius: var(--bbk-radius-sm);
|
||||||
|
background: color-mix(in srgb, var(--bbk-notice-color) 6%, var(--bbk-color-bg));
|
||||||
|
color: var(--bbk-color-text);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-notice--info { --bbk-notice-color: var(--bbk-color-info); }
|
||||||
|
|
||||||
|
.bbk-notice-icon {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
color: var(--bbk-notice-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-notice-text { margin: 0; align-self: center; }
|
||||||
|
|
||||||
/* ── Confirmation page ─────────────────────────────────────────────── */
|
/* ── Confirmation page ─────────────────────────────────────────────── */
|
||||||
|
|
||||||
.bbk-confirmation {
|
.bbk-confirmation {
|
||||||
max-width: 720px;
|
max-width: 560px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 3rem 1.5rem 5rem;
|
padding: 3rem 1.5rem 5rem;
|
||||||
font-family: var(--bbk-font);
|
font-family: var(--bbk-font);
|
||||||
@@ -916,7 +966,7 @@ textarea.bbk-field-input { resize: vertical; }
|
|||||||
}
|
}
|
||||||
|
|
||||||
.bbk-confirmation-heading {
|
.bbk-confirmation-heading {
|
||||||
margin: 0 0 1rem;
|
margin: 0 0 1.25rem;
|
||||||
font-size: 1.75rem;
|
font-size: 1.75rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
@@ -924,7 +974,7 @@ textarea.bbk-field-input { resize: vertical; }
|
|||||||
.bbk-confirmation-ref { margin: 0 0 0.25rem; }
|
.bbk-confirmation-ref { margin: 0 0 0.25rem; }
|
||||||
|
|
||||||
.bbk-confirmation-meta {
|
.bbk-confirmation-meta {
|
||||||
margin: 0 0 1rem;
|
margin: 1.5rem 0 1rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
@@ -940,17 +990,22 @@ textarea.bbk-field-input { resize: vertical; }
|
|||||||
.bbk-confirmation-meta-row dt { color: var(--bbk-color-muted); }
|
.bbk-confirmation-meta-row dt { color: var(--bbk-color-muted); }
|
||||||
.bbk-confirmation-meta-row dd { margin: 0; font-weight: 600; }
|
.bbk-confirmation-meta-row dd { margin: 0; font-weight: 600; }
|
||||||
|
|
||||||
.bbk-confirmation-body {
|
.bbk-confirmation-section {
|
||||||
margin: 2rem 0;
|
margin-top: 2rem;
|
||||||
display: grid;
|
padding-top: 1.5rem;
|
||||||
gap: 2.5rem;
|
border-top: 1px solid var(--bbk-color-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
.bbk-confirmation-section-heading {
|
||||||
.bbk-confirmation-body { grid-template-columns: 1fr 1fr; }
|
margin: 0 0 1rem;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bbk-confirmation-lines {
|
.bbk-confirmation-lines {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0 0 1.25rem;
|
||||||
|
padding: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
@@ -965,22 +1020,51 @@ textarea.bbk-field-input { resize: vertical; }
|
|||||||
|
|
||||||
.bbk-confirmation-line-detail { min-width: 0; }
|
.bbk-confirmation-line-detail { min-width: 0; }
|
||||||
|
|
||||||
|
.bbk-confirmation-line-name { margin: 0 0 0.25rem; }
|
||||||
|
|
||||||
|
.bbk-confirmation-line-total { margin: 0; white-space: nowrap; }
|
||||||
|
|
||||||
.bbk-confirmation-line-qty { color: var(--bbk-color-muted); }
|
.bbk-confirmation-line-qty { color: var(--bbk-color-muted); }
|
||||||
|
|
||||||
.bbk-confirmation-lines .bbk-cart-summary { margin-top: 0.75rem; }
|
|
||||||
|
|
||||||
.bbk-confirmation-addresses {
|
.bbk-confirmation-addresses {
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-direction: column;
|
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bbk-confirmation-address-heading {
|
@media (min-width: 480px) {
|
||||||
|
.bbk-confirmation-addresses { grid-template-columns: 1fr 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-confirmation-address-heading,
|
||||||
|
.bbk-confirmation-bank-transfer-heading {
|
||||||
margin: 0 0 0.5rem;
|
margin: 0 0 0.5rem;
|
||||||
font-size: 0.9375rem;
|
font-size: 0.9375rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Store-authored rich text (ManageStoreDetails' RichEditor) — may be
|
||||||
|
paragraphs, bold text or a bank/IBAN/BIC table. */
|
||||||
|
.bbk-confirmation-bank-transfer-body {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-confirmation-bank-transfer-body > :first-child { margin-top: 0; }
|
||||||
|
.bbk-confirmation-bank-transfer-body > :last-child { margin-bottom: 0; }
|
||||||
|
|
||||||
|
.bbk-confirmation-bank-transfer-body table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-confirmation-bank-transfer-body th,
|
||||||
|
.bbk-confirmation-bank-transfer-body td {
|
||||||
|
padding: 0.375rem 0.5rem;
|
||||||
|
border: 1px solid var(--bbk-color-border);
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
.bbk-address-lines {
|
.bbk-address-lines {
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -9,11 +9,13 @@ import { csrfToken } from './csrf'
|
|||||||
// - handles the in-drawer quantity / remove forms (fetch + method spoofing)
|
// - handles the in-drawer quantity / remove forms (fetch + method spoofing)
|
||||||
// - re-emits `bbk-cart:updated` {count, total} after every render so the host
|
// - re-emits `bbk-cart:updated` {count, total} after every render so the host
|
||||||
// (e.g. the header bag icon) can react
|
// (e.g. the header bag icon) can react
|
||||||
|
// - dialog focus handling: focus moves into the panel on open, Tab is kept
|
||||||
|
// inside it, and focus returns to whatever opened it on close
|
||||||
//
|
//
|
||||||
// Appearance is entirely CSS-driven: open state is the data-bbk-cart-state
|
// Appearance is entirely CSS-driven: open state is the data-bbk-cart-state
|
||||||
// attribute on the root, nothing here touches styles or class lists.
|
// attribute on the root, nothing here touches styles or class lists.
|
||||||
export default class extends Controller {
|
export default class extends Controller {
|
||||||
static targets = ['panel', 'body', 'error']
|
static targets = ['panel', 'body', 'error', 'heading', 'status']
|
||||||
|
|
||||||
connect() {
|
connect() {
|
||||||
this.onChanged = this.onChanged.bind(this)
|
this.onChanged = this.onChanged.bind(this)
|
||||||
@@ -40,19 +42,31 @@ export default class extends Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onKeydown(event) {
|
onKeydown(event) {
|
||||||
if (event.key === 'Escape' && !this.element.hidden) this.close()
|
// Only the drawer instance is a dialog — the checkout page's summary
|
||||||
|
// reuses this controller without a panel.
|
||||||
|
if (!this.hasPanelTarget || this.element.hidden) return
|
||||||
|
|
||||||
|
if (event.key === 'Escape') this.close()
|
||||||
|
if (event.key === 'Tab') this.trapFocus(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
open() {
|
open() {
|
||||||
if (!this.element.hidden) return
|
if (!this.element.hidden) return
|
||||||
|
this.returnFocusTo = document.activeElement
|
||||||
this.element.hidden = false
|
this.element.hidden = false
|
||||||
// Next frame, so the panel transitions from its off-canvas start.
|
// Next frame, so the panel transitions from its off-canvas start.
|
||||||
requestAnimationFrame(() => this.element.setAttribute('data-bbk-cart-state', 'open'))
|
requestAnimationFrame(() => {
|
||||||
|
this.element.setAttribute('data-bbk-cart-state', 'open')
|
||||||
|
if (this.hasHeadingTarget) this.headingTarget.focus({ preventScroll: true })
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
close() {
|
close() {
|
||||||
this.element.removeAttribute('data-bbk-cart-state')
|
this.element.removeAttribute('data-bbk-cart-state')
|
||||||
|
|
||||||
|
if (this.returnFocusTo?.isConnected) this.returnFocusTo.focus({ preventScroll: true })
|
||||||
|
this.returnFocusTo = null
|
||||||
|
|
||||||
const panel = this.panelTarget
|
const panel = this.panelTarget
|
||||||
const done = () => {
|
const done = () => {
|
||||||
this.element.hidden = true
|
this.element.hidden = true
|
||||||
@@ -132,6 +146,28 @@ export default class extends Controller {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// aria-modal hides the page from screen readers but doesn't stop Tab from
|
||||||
|
// walking out of the panel into it — wrap at either end instead.
|
||||||
|
trapFocus(event) {
|
||||||
|
const focusable = [...this.panelTarget.querySelectorAll(
|
||||||
|
'a[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
|
||||||
|
)].filter((el) => !el.closest('[hidden], [aria-hidden="true"]'))
|
||||||
|
|
||||||
|
if (!focusable.length) return
|
||||||
|
|
||||||
|
const first = focusable[0]
|
||||||
|
const last = focusable[focusable.length - 1]
|
||||||
|
const active = document.activeElement
|
||||||
|
|
||||||
|
if (event.shiftKey && (active === first || !this.panelTarget.contains(active) || (this.hasHeadingTarget && active === this.headingTarget))) {
|
||||||
|
event.preventDefault()
|
||||||
|
last.focus()
|
||||||
|
} else if (!event.shiftKey && (active === last || !this.panelTarget.contains(active))) {
|
||||||
|
event.preventDefault()
|
||||||
|
first.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
showError(message) {
|
showError(message) {
|
||||||
if (!this.hasErrorTarget || !message) return
|
if (!this.hasErrorTarget || !message) return
|
||||||
this.errorTarget.textContent = message
|
this.errorTarget.textContent = message
|
||||||
@@ -144,10 +180,53 @@ export default class extends Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
replaceBody(html) {
|
replaceBody(html) {
|
||||||
|
const restore = this.focusSnapshot()
|
||||||
this.bodyTarget.innerHTML = html
|
this.bodyTarget.innerHTML = html
|
||||||
|
restore()
|
||||||
|
this.announce()
|
||||||
this.emitUpdated(this.bodyTarget.querySelector('[data-bbk-cart-count]'))
|
this.emitUpdated(this.bodyTarget.querySelector('[data-bbk-cart-count]'))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Swapping the body destroys whatever control had focus (a qty stepper,
|
||||||
|
// a remove button, the coupon field), dropping keyboard/screen-reader
|
||||||
|
// users back at the top of the document. Returns a callback that, after
|
||||||
|
// the swap, re-focuses the equivalent control in the new markup — or the
|
||||||
|
// heading, when that control is gone (e.g. its line was just removed).
|
||||||
|
focusSnapshot() {
|
||||||
|
const active = document.activeElement
|
||||||
|
if (!active || !this.bodyTarget.contains(active)) return () => {}
|
||||||
|
|
||||||
|
let selector = null
|
||||||
|
if (active.id) {
|
||||||
|
selector = `#${CSS.escape(active.id)}`
|
||||||
|
} else {
|
||||||
|
const lineId = active.closest('[data-bbk-line-id]')?.dataset.bbkLineId
|
||||||
|
const dir = active.dataset.bbkCartDirParam
|
||||||
|
const control = ['bbk-cart-qty-input', 'bbk-cart-qty-btn', 'bbk-cart-item-remove']
|
||||||
|
.find((name) => active.classList.contains(name))
|
||||||
|
|
||||||
|
if (lineId && control) {
|
||||||
|
selector = `[data-bbk-line-id="${CSS.escape(lineId)}"] .${control}`
|
||||||
|
+ (dir ? `[data-bbk-cart-dir-param="${CSS.escape(dir)}"]` : '')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
const target = selector && this.bodyTarget.querySelector(selector)
|
||||||
|
if (target) target.focus({ preventScroll: true })
|
||||||
|
else if (this.hasHeadingTarget) this.headingTarget.focus({ preventScroll: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Polite "Cart updated" — cleared first so an identical message is
|
||||||
|
// re-announced on the next update.
|
||||||
|
announce() {
|
||||||
|
if (!this.hasStatusTarget) return
|
||||||
|
const message = this.statusTarget.dataset.bbkCartMessage || ''
|
||||||
|
this.statusTarget.textContent = ''
|
||||||
|
requestAnimationFrame(() => { this.statusTarget.textContent = message })
|
||||||
|
}
|
||||||
|
|
||||||
emitUpdated(node) {
|
emitUpdated(node) {
|
||||||
if (!node) return
|
if (!node) return
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
Order confirmation. Reached only via a session flash of the placed order id
|
Order confirmation. Reached only via a session flash of the placed order id
|
||||||
(CheckoutController::confirmation) — not deep-linkable. $order is a
|
(CheckoutController::confirmation) — not deep-linkable. $order is a
|
||||||
Lunar\Models\Order with lines + shipping/billing addresses eager-loaded.
|
Lunar\Models\Order with lines + shipping/billing addresses eager-loaded.
|
||||||
|
$bankTransferInstructions is already-sanitized HTML from
|
||||||
|
StoreDetailsService::bankTransferInstructionsFor(), or null unless this
|
||||||
|
is a bank transfer order with instructions filled in for this locale.
|
||||||
--}}
|
--}}
|
||||||
@extends('layouts.app')
|
@extends('layouts.app')
|
||||||
|
|
||||||
@@ -11,10 +14,19 @@
|
|||||||
<div class="bbk-confirmation">
|
<div class="bbk-confirmation">
|
||||||
<h1 class="bbk-confirmation-heading">{{ __('checkout.page.confirmation_heading') }}</h1>
|
<h1 class="bbk-confirmation-heading">{{ __('checkout.page.confirmation_heading') }}</h1>
|
||||||
|
|
||||||
|
<div class="bbk-notice bbk-notice--info">
|
||||||
|
<svg class="bbk-notice-icon" aria-hidden="true" focusable="false" viewBox="0 0 20 20" width="20" height="20">
|
||||||
|
<circle cx="10" cy="10" r="8.25" fill="none" stroke="currentColor" stroke-width="1.5"/>
|
||||||
|
<path d="M10 9v5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
<circle cx="10" cy="6.25" r="1" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
<p class="bbk-notice-text">{{ __('checkout.page.confirmation_email_note') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<dl class="bbk-confirmation-meta">
|
<dl class="bbk-confirmation-meta">
|
||||||
<div class="bbk-confirmation-meta-row">
|
<div class="bbk-confirmation-meta-row">
|
||||||
<dt>{{ __('checkout.page.confirmation_order_number') }}</dt>
|
<dt>{{ __('checkout.page.confirmation_order_number') }}</dt>
|
||||||
<dd>{{ \Modules\Core\Order\Support\OrderReferenceDisplay::resolve($order) }}</dd>
|
<dd>#{{ \Modules\Core\Order\Support\OrderReferenceDisplay::resolve($order) }}</dd>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if ($order->billingAddress?->contact_email)
|
@if ($order->billingAddress?->contact_email)
|
||||||
@@ -39,8 +51,6 @@
|
|||||||
@endif
|
@endif
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
<p class="bbk-checkout-note">{{ __('checkout.page.confirmation_email_note') }}</p>
|
|
||||||
|
|
||||||
{{-- Guests: logging in with the order's email attaches it to an account
|
{{-- Guests: logging in with the order's email attaches it to an account
|
||||||
(boboko-core's Modules\Core\Customer\Listeners\ClaimGuestOrdersOnLogin),
|
(boboko-core's Modules\Core\Customer\Listeners\ClaimGuestOrdersOnLogin),
|
||||||
so it shows in their history. --}}
|
so it shows in their history. --}}
|
||||||
@@ -53,21 +63,29 @@
|
|||||||
@endif
|
@endif
|
||||||
@endguest
|
@endguest
|
||||||
|
|
||||||
<div class="bbk-confirmation-body">
|
<section class="bbk-confirmation-section" aria-labelledby="bbk-confirmation-summary-heading">
|
||||||
<div class="bbk-confirmation-lines">
|
<h2 class="bbk-confirmation-section-heading" id="bbk-confirmation-summary-heading">
|
||||||
|
{{ __('checkout.page.order_summary_heading') }}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<ul class="bbk-confirmation-lines">
|
||||||
@foreach ($order->lines->where('type', '!=', 'shipping') as $line)
|
@foreach ($order->lines->where('type', '!=', 'shipping') as $line)
|
||||||
<div class="bbk-confirmation-line">
|
<li class="bbk-confirmation-line">
|
||||||
<div class="bbk-cart-item-media">
|
<div class="bbk-cart-item-media">
|
||||||
|
{{-- alt="" — the description is right beside it. --}}
|
||||||
@if ($thumb = $line->purchasable?->getThumbnailImage())
|
@if ($thumb = $line->purchasable?->getThumbnailImage())
|
||||||
<img src="{{ $thumb }}" alt="{{ $line->description }}" width="72" height="72" loading="lazy">
|
<img src="{{ $thumb }}" alt="" width="72" height="72" loading="lazy">
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bbk-confirmation-line-detail">
|
<div class="bbk-confirmation-line-detail">
|
||||||
<span class="bbk-confirmation-line-name">
|
<p class="bbk-confirmation-line-name">
|
||||||
{{ $line->description }}
|
{{ $line->description }}
|
||||||
<span class="bbk-confirmation-line-qty">× {{ $line->quantity }}</span>
|
<span class="bbk-confirmation-line-qty">
|
||||||
</span>
|
<span aria-hidden="true">× {{ $line->quantity }}</span>
|
||||||
|
<span class="bbk-visually-hidden">— {{ __('checkout.cart.quantity') }}: {{ $line->quantity }}</span>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
@if ($line->option)
|
@if ($line->option)
|
||||||
<p class="bbk-cart-item-variant">{{ $line->option }}</p>
|
<p class="bbk-cart-item-variant">{{ $line->option }}</p>
|
||||||
@@ -76,57 +94,69 @@
|
|||||||
@include('checkout::partials.line-custom-fields', ['line' => $line])
|
@include('checkout::partials.line-custom-fields', ['line' => $line])
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span class="bbk-confirmation-line-total">{{ $line->sub_total?->formatted() }}</span>
|
<p class="bbk-confirmation-line-total">
|
||||||
</div>
|
<span class="bbk-visually-hidden">{{ __('checkout.cart.total') }}:</span>
|
||||||
|
{{ $line->sub_total?->formatted() }}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
@endforeach
|
@endforeach
|
||||||
|
</ul>
|
||||||
|
|
||||||
<div class="bbk-cart-summary">
|
<div class="bbk-cart-summary">
|
||||||
|
<div class="bbk-cart-summary-row">
|
||||||
|
<span>{{ __('checkout.cart.subtotal') }}</span>
|
||||||
|
<span>{{ $order->sub_total?->formatted() }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($order->discount_total?->value > 0)
|
||||||
|
<div class="bbk-cart-summary-row bbk-cart-summary-row--discount">
|
||||||
|
<span>{{ __('checkout.cart.discount') }}</span>
|
||||||
|
<span>−{{ $order->discount_total->formatted() }}</span>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="bbk-cart-summary-row">
|
||||||
|
<span>{{ __('checkout.cart.shipping') }}</span>
|
||||||
|
<span>{{ $order->shipping_total?->formatted() }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($order->tax_total?->value > 0)
|
||||||
<div class="bbk-cart-summary-row">
|
<div class="bbk-cart-summary-row">
|
||||||
<span>{{ __('checkout.cart.subtotal') }}</span>
|
<span>{{ __('checkout.cart.tax') }}</span>
|
||||||
<span>{{ $order->sub_total?->formatted() }}</span>
|
<span>{{ $order->tax_total->formatted() }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
@if ($order->discount_total?->value > 0)
|
<div class="bbk-cart-summary-row bbk-cart-summary-row--total">
|
||||||
<div class="bbk-cart-summary-row bbk-cart-summary-row--discount">
|
<span>{{ __('checkout.cart.total') }}</span>
|
||||||
<span>{{ __('checkout.cart.discount') }}</span>
|
<span>{{ $order->total?->formatted() }}</span>
|
||||||
<span>−{{ $order->discount_total->formatted() }}</span>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
<div class="bbk-cart-summary-row">
|
|
||||||
<span>{{ __('checkout.cart.shipping') }}</span>
|
|
||||||
<span>{{ $order->shipping_total?->formatted() }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if ($order->tax_total?->value > 0)
|
|
||||||
<div class="bbk-cart-summary-row">
|
|
||||||
<span>{{ __('checkout.cart.tax') }}</span>
|
|
||||||
<span>{{ $order->tax_total->formatted() }}</span>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
<div class="bbk-cart-summary-row bbk-cart-summary-row--total">
|
|
||||||
<span>{{ __('checkout.cart.total') }}</span>
|
|
||||||
<span>{{ $order->total?->formatted() }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div class="bbk-confirmation-addresses">
|
@if ($order->shippingAddress || $order->billingAddress)
|
||||||
|
<div class="bbk-confirmation-section bbk-confirmation-addresses">
|
||||||
@if ($order->shippingAddress)
|
@if ($order->shippingAddress)
|
||||||
<div class="bbk-confirmation-address">
|
<section class="bbk-confirmation-address" aria-labelledby="bbk-confirmation-shipping-heading">
|
||||||
<h2 class="bbk-confirmation-address-heading">{{ __('checkout.page.confirmation_shipping_to') }}</h2>
|
<h2 class="bbk-confirmation-address-heading" id="bbk-confirmation-shipping-heading">{{ __('checkout.page.confirmation_shipping_to') }}</h2>
|
||||||
<x-checkout::address-lines :address="$order->shippingAddress" />
|
<x-checkout::address-lines :address="$order->shippingAddress" />
|
||||||
</div>
|
</section>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if ($order->billingAddress)
|
@if ($order->billingAddress)
|
||||||
<div class="bbk-confirmation-address">
|
<section class="bbk-confirmation-address" aria-labelledby="bbk-confirmation-billing-heading">
|
||||||
<h2 class="bbk-confirmation-address-heading">{{ __('checkout.page.confirmation_billing') }}</h2>
|
<h2 class="bbk-confirmation-address-heading" id="bbk-confirmation-billing-heading">{{ __('checkout.page.confirmation_billing') }}</h2>
|
||||||
<x-checkout::address-lines :address="$order->billingAddress" />
|
<x-checkout::address-lines :address="$order->billingAddress" />
|
||||||
</div>
|
</section>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</div>
|
@endif
|
||||||
|
|
||||||
|
@if ($bankTransferInstructions)
|
||||||
|
<section class="bbk-confirmation-section bbk-confirmation-bank-transfer" aria-labelledby="bbk-confirmation-bank-transfer-heading">
|
||||||
|
<h2 class="bbk-confirmation-bank-transfer-heading" id="bbk-confirmation-bank-transfer-heading">{{ __('checkout.page.confirmation_bank_transfer_heading') }}</h2>
|
||||||
|
<div class="bbk-confirmation-bank-transfer-body">{!! $bankTransferInstructions !!}</div>
|
||||||
|
</section>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
@endsection
|
@endsection
|
||||||
|
|||||||
@@ -15,7 +15,9 @@
|
|||||||
data-bbk-cart-target="panel"
|
data-bbk-cart-target="panel"
|
||||||
>
|
>
|
||||||
<header class="bbk-cart-panel-header">
|
<header class="bbk-cart-panel-header">
|
||||||
<h2 class="bbk-cart-heading" id="bbk-cart-heading">{{ __('checkout.cart.title') }}</h2>
|
{{-- tabindex=-1: the controller moves focus here on open, and back
|
||||||
|
here when the focused line is removed from under the user. --}}
|
||||||
|
<h2 class="bbk-cart-heading" id="bbk-cart-heading" tabindex="-1" data-bbk-cart-target="heading">{{ __('checkout.cart.title') }}</h2>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="bbk-cart-dismiss"
|
class="bbk-cart-dismiss"
|
||||||
@@ -26,7 +28,16 @@
|
|||||||
|
|
||||||
@include('checkout::partials.cart-error')
|
@include('checkout::partials.cart-error')
|
||||||
|
|
||||||
<div class="bbk-cart-panel-body" data-bbk-cart-target="body" aria-live="polite">
|
{{-- A short announcement after each update, rather than aria-live on
|
||||||
|
the body itself, which re-read the whole cart on every change. --}}
|
||||||
|
<p
|
||||||
|
class="bbk-visually-hidden"
|
||||||
|
role="status"
|
||||||
|
data-bbk-cart-target="status"
|
||||||
|
data-bbk-cart-message="{{ __('checkout.cart.updated') }}"
|
||||||
|
></p>
|
||||||
|
|
||||||
|
<div class="bbk-cart-panel-body" data-bbk-cart-target="body">
|
||||||
@include('checkout::partials.cart-body')
|
@include('checkout::partials.cart-body')
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -267,9 +267,15 @@
|
|||||||
|
|
||||||
<aside class="bbk-checkout-aside">
|
<aside class="bbk-checkout-aside">
|
||||||
<div class="bbk-checkout-summary" data-controller="bbk-cart">
|
<div class="bbk-checkout-summary" data-controller="bbk-cart">
|
||||||
<h2 class="bbk-checkout-summary-heading">{{ __('checkout.page.order_summary_heading') }}</h2>
|
<h2 class="bbk-checkout-summary-heading" tabindex="-1" data-bbk-cart-target="heading">{{ __('checkout.page.order_summary_heading') }}</h2>
|
||||||
@include('checkout::partials.cart-error')
|
@include('checkout::partials.cart-error')
|
||||||
<div data-bbk-cart-target="body" aria-live="polite">
|
<p
|
||||||
|
class="bbk-visually-hidden"
|
||||||
|
role="status"
|
||||||
|
data-bbk-cart-target="status"
|
||||||
|
data-bbk-cart-message="{{ __('checkout.cart.updated') }}"
|
||||||
|
></p>
|
||||||
|
<div data-bbk-cart-target="body">
|
||||||
@include('checkout::partials.cart-body')
|
@include('checkout::partials.cart-body')
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -24,80 +24,96 @@
|
|||||||
<li class="bbk-cart-item" data-bbk-line-id="{{ $line->id }}">
|
<li class="bbk-cart-item" data-bbk-line-id="{{ $line->id }}">
|
||||||
<div class="bbk-cart-item-media">
|
<div class="bbk-cart-item-media">
|
||||||
@if ($thumb)
|
@if ($thumb)
|
||||||
|
{{-- Decorative duplicate of the title link below — hidden from AT
|
||||||
|
and skipped by keyboard so the product isn't announced twice. --}}
|
||||||
@if ($productUrl)
|
@if ($productUrl)
|
||||||
<a href="{{ $productUrl }}" aria-hidden="true" tabindex="-1">
|
<a href="{{ $productUrl }}" aria-hidden="true" tabindex="-1">
|
||||||
<img src="{{ $thumb }}" alt="{{ $name }}" width="72" height="72" loading="lazy">
|
<img src="{{ $thumb }}" alt="" width="72" height="72" loading="lazy">
|
||||||
</a>
|
</a>
|
||||||
@else
|
@else
|
||||||
<img src="{{ $thumb }}" alt="{{ $name }}" width="72" height="72" loading="lazy">
|
<img src="{{ $thumb }}" alt="" width="72" height="72" loading="lazy">
|
||||||
@endif
|
@endif
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bbk-cart-item-detail">
|
<div class="bbk-cart-item-detail">
|
||||||
@if ($productUrl)
|
<div class="bbk-cart-item-head">
|
||||||
<a href="{{ $productUrl }}" class="bbk-cart-item-title">{{ $name }}</a>
|
@if ($productUrl)
|
||||||
@else
|
<a href="{{ $productUrl }}" class="bbk-cart-item-title" id="bbk-cart-item-title-{{ $line->id }}">{{ $name }}</a>
|
||||||
<p class="bbk-cart-item-title">{{ $name }}</p>
|
@else
|
||||||
@endif
|
<p class="bbk-cart-item-title" id="bbk-cart-item-title-{{ $line->id }}">{{ $name }}</p>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<form
|
||||||
|
class="bbk-cart-item-remove-form"
|
||||||
|
method="POST"
|
||||||
|
action="{{ route('checkout.cart.remove', ['locale' => app()->getLocale(), 'line' => $line->id]) }}"
|
||||||
|
data-action="submit->bbk-cart#submit"
|
||||||
|
>
|
||||||
|
@csrf
|
||||||
|
@method('DELETE')
|
||||||
|
{{-- Named "Remove", described by the product title, so AT
|
||||||
|
hears which line it removes rather than a bare "Remove". --}}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="bbk-cart-item-remove"
|
||||||
|
aria-label="{{ __('checkout.cart.remove') }}"
|
||||||
|
aria-describedby="bbk-cart-item-title-{{ $line->id }}"
|
||||||
|
><span aria-hidden="true">×</span></button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
@if ($variantLabel)
|
@if ($variantLabel)
|
||||||
<p class="bbk-cart-item-variant">{{ $variantLabel }}</p>
|
<p class="bbk-cart-item-variant">{{ $variantLabel }}</p>
|
||||||
@endif
|
@endif
|
||||||
@include('checkout::partials.line-custom-fields', ['line' => $line])
|
@include('checkout::partials.line-custom-fields', ['line' => $line])
|
||||||
<p class="bbk-cart-item-unit">{{ $line->unitPrice?->formatted() }}</p>
|
<p class="bbk-cart-item-unit">{{ $line->unitPrice?->formatted() }}</p>
|
||||||
|
|
||||||
<form
|
<div class="bbk-cart-item-foot">
|
||||||
class="bbk-cart-qty"
|
{{-- role=group + the title as its name: entering the stepper
|
||||||
method="POST"
|
announces which product's quantity is being changed. --}}
|
||||||
action="{{ route('checkout.cart.update', ['locale' => app()->getLocale(), 'line' => $line->id]) }}"
|
<form
|
||||||
>
|
class="bbk-cart-qty"
|
||||||
@csrf
|
method="POST"
|
||||||
@method('PATCH')
|
action="{{ route('checkout.cart.update', ['locale' => app()->getLocale(), 'line' => $line->id]) }}"
|
||||||
<button
|
role="group"
|
||||||
type="button"
|
aria-labelledby="bbk-cart-item-title-{{ $line->id }}"
|
||||||
class="bbk-cart-qty-btn"
|
|
||||||
data-action="bbk-cart#step"
|
|
||||||
data-bbk-cart-dir-param="-1"
|
|
||||||
aria-label="{{ __('checkout.cart.decrease') }}"
|
|
||||||
>−</button>
|
|
||||||
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
name="quantity"
|
|
||||||
value="{{ $line->quantity }}"
|
|
||||||
min="0"
|
|
||||||
inputmode="numeric"
|
|
||||||
class="bbk-cart-qty-input"
|
|
||||||
data-action="change->bbk-cart#submit"
|
|
||||||
data-bbk-cart-confirmed-quantity="{{ $line->quantity }}"
|
|
||||||
aria-label="{{ __('checkout.cart.quantity') }}"
|
|
||||||
>
|
>
|
||||||
|
@csrf
|
||||||
|
@method('PATCH')
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="bbk-cart-qty-btn"
|
||||||
|
data-action="bbk-cart#step"
|
||||||
|
data-bbk-cart-dir-param="-1"
|
||||||
|
aria-label="{{ __('checkout.cart.decrease') }}"
|
||||||
|
><span aria-hidden="true">−</span></button>
|
||||||
|
|
||||||
<button
|
<input
|
||||||
type="button"
|
type="number"
|
||||||
class="bbk-cart-qty-btn"
|
name="quantity"
|
||||||
data-action="bbk-cart#step"
|
value="{{ $line->quantity }}"
|
||||||
data-bbk-cart-dir-param="1"
|
min="0"
|
||||||
aria-label="{{ __('checkout.cart.increase') }}"
|
inputmode="numeric"
|
||||||
>+</button>
|
class="bbk-cart-qty-input"
|
||||||
</form>
|
data-action="change->bbk-cart#submit"
|
||||||
</div>
|
data-bbk-cart-confirmed-quantity="{{ $line->quantity }}"
|
||||||
|
aria-label="{{ __('checkout.cart.quantity') }}"
|
||||||
|
>
|
||||||
|
|
||||||
<div class="bbk-cart-item-aside">
|
<button
|
||||||
<p class="bbk-cart-item-total">{{ $line->subTotal?->formatted() }}</p>
|
type="button"
|
||||||
|
class="bbk-cart-qty-btn"
|
||||||
|
data-action="bbk-cart#step"
|
||||||
|
data-bbk-cart-dir-param="1"
|
||||||
|
aria-label="{{ __('checkout.cart.increase') }}"
|
||||||
|
><span aria-hidden="true">+</span></button>
|
||||||
|
</form>
|
||||||
|
|
||||||
<form
|
<p class="bbk-cart-item-total">
|
||||||
method="POST"
|
<span class="bbk-visually-hidden">{{ __('checkout.cart.total') }}:</span>
|
||||||
action="{{ route('checkout.cart.remove', ['locale' => app()->getLocale(), 'line' => $line->id]) }}"
|
{{ $line->subTotal?->formatted() }}
|
||||||
data-action="submit->bbk-cart#submit"
|
</p>
|
||||||
>
|
</div>
|
||||||
@csrf
|
|
||||||
@method('DELETE')
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="bbk-cart-item-remove"
|
|
||||||
aria-label="{{ __('checkout.cart.remove') }}"
|
|
||||||
>×</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class Staff extends ModelsStaff
|
|||||||
'last_name',
|
'last_name',
|
||||||
'admin',
|
'admin',
|
||||||
'email',
|
'email',
|
||||||
'otp_code',
|
'otp_code_hash',
|
||||||
'otp_expires_at',
|
'otp_expires_at',
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -19,6 +19,18 @@ class Staff extends ModelsStaff
|
|||||||
'admin' => 'bool',
|
'admin' => 'bool',
|
||||||
'email_verified_at' => 'datetime',
|
'email_verified_at' => 'datetime',
|
||||||
'password' => 'hashed',
|
'password' => 'hashed',
|
||||||
|
'otp_code_hash' => 'hashed',
|
||||||
'otp_expires_at' => 'datetime',
|
'otp_expires_at' => 'datetime',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Overrides (doesn't merge with) Lunar\Admin\Models\Staff's own
|
||||||
|
// $hidden — repeats its password/remember_token here so this class
|
||||||
|
// doesn't silently drop that protection while adding otp_code_hash/
|
||||||
|
// otp_expires_at, which the base model has no reason to know about.
|
||||||
|
protected $hidden = [
|
||||||
|
'password',
|
||||||
|
'remember_token',
|
||||||
|
'otp_code_hash',
|
||||||
|
'otp_expires_at',
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace Modules\Core\Auth\Services;
|
namespace Modules\Core\Auth\Services;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Illuminate\Support\Facades\Mail;
|
use Illuminate\Support\Facades\Mail;
|
||||||
use Modules\Core\Auth\Mail\OtpMail;
|
use Modules\Core\Auth\Mail\OtpMail;
|
||||||
use Modules\Core\Auth\Models\Staff;
|
use Modules\Core\Auth\Models\Staff;
|
||||||
@@ -27,7 +28,10 @@ class OtpService
|
|||||||
|
|
||||||
$code = str_pad((string) random_int(0, 999999), self::CODE_LENGTH, '0', STR_PAD_LEFT);
|
$code = str_pad((string) random_int(0, 999999), self::CODE_LENGTH, '0', STR_PAD_LEFT);
|
||||||
|
|
||||||
$staff->otp_code = $code;
|
// otp_code_hash's 'hashed' cast (see Staff's own $casts) hashes
|
||||||
|
// this automatically on assignment, same as password — never
|
||||||
|
// stored or compared in plaintext.
|
||||||
|
$staff->otp_code_hash = $code;
|
||||||
$staff->otp_expires_at = now()->addMinutes(self::EXPIRY_MINUTES);
|
$staff->otp_expires_at = now()->addMinutes(self::EXPIRY_MINUTES);
|
||||||
$staff->save();
|
$staff->save();
|
||||||
|
|
||||||
@@ -44,11 +48,15 @@ class OtpService
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $staff->otp_expires_at || $staff->otp_code != $code || now()->isAfter($staff->otp_expires_at)) {
|
if (! $staff->otp_code_hash || ! $staff->otp_expires_at || now()->isAfter($staff->otp_expires_at)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$staff->otp_code = null;
|
if (! Hash::check($code, $staff->otp_code_hash)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$staff->otp_code_hash = null;
|
||||||
$staff->otp_expires_at = null;
|
$staff->otp_expires_at = null;
|
||||||
$staff->save();
|
$staff->save();
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use Illuminate\Support\Facades\Auth;
|
|||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Event;
|
use Illuminate\Support\Facades\Event;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Illuminate\Support\Facades\Mail;
|
use Illuminate\Support\Facades\Mail;
|
||||||
use Illuminate\Support\Facades\RateLimiter;
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
use Modules\Core\Auth\Events\UserAuthenticated;
|
use Modules\Core\Auth\Events\UserAuthenticated;
|
||||||
@@ -40,8 +41,11 @@ use Modules\Core\Auth\Mail\UserOtpMail;
|
|||||||
* at all — firstOrCreate() and UserCreated only fire from validate(), and
|
* at all — firstOrCreate() and UserCreated only fire from validate(), and
|
||||||
* only once the code has actually been proven correct. An email that
|
* only once the code has actually been proven correct. An email that
|
||||||
* already has a User row is unaffected: its OTP state still lives on that
|
* already has a User row is unaffected: its OTP state still lives on that
|
||||||
* row's own otp_code/otp_expires_at/otp_attempts columns exactly as
|
* row's own otp_code_hash/otp_expires_at/otp_attempts columns exactly as
|
||||||
* before, so a returning shopper's login is unchanged.
|
* before, so a returning shopper's login is unchanged. otp_code_hash
|
||||||
|
* holds a bcrypt hash of the code (the 'otp_code_hash' => 'hashed' cast
|
||||||
|
* on App\Models\User hashes it automatically on assignment, same as
|
||||||
|
* password), not the code itself — compared via Hash::check().
|
||||||
*
|
*
|
||||||
* Two independent throttles, both configured under core.auth.otp — see
|
* Two independent throttles, both configured under core.auth.otp — see
|
||||||
* config/core.php's own comment for why they're separate: max_attempts
|
* config/core.php's own comment for why they're separate: max_attempts
|
||||||
@@ -87,7 +91,7 @@ class UserOtpService
|
|||||||
$code = str_pad((string) random_int(0, 999999), self::CODE_LENGTH, '0', STR_PAD_LEFT);
|
$code = str_pad((string) random_int(0, 999999), self::CODE_LENGTH, '0', STR_PAD_LEFT);
|
||||||
|
|
||||||
if ($user) {
|
if ($user) {
|
||||||
$user->otp_code = $code;
|
$user->otp_code_hash = $code;
|
||||||
$user->otp_expires_at = now()->addMinutes(self::EXPIRY_MINUTES);
|
$user->otp_expires_at = now()->addMinutes(self::EXPIRY_MINUTES);
|
||||||
$user->otp_attempts = 0;
|
$user->otp_attempts = 0;
|
||||||
$user->save();
|
$user->save();
|
||||||
@@ -96,8 +100,12 @@ class UserOtpService
|
|||||||
// class's own docblock for why: creating one on every
|
// class's own docblock for why: creating one on every
|
||||||
// generateAndSend() call let anyone mint real User/Customer
|
// generateAndSend() call let anyone mint real User/Customer
|
||||||
// rows for an email nobody proved they owned.
|
// rows for an email nobody proved they owned.
|
||||||
|
//
|
||||||
|
// Hashed even in the cache (not just on the DB-backed path)
|
||||||
|
// — a code sitting in Cache::get()-able storage is the same
|
||||||
|
// exposure as a plaintext DB column if anything can read it.
|
||||||
Cache::put($this->pendingKey($email), [
|
Cache::put($this->pendingKey($email), [
|
||||||
'code' => $code,
|
'code_hash' => Hash::make($code),
|
||||||
'expires_at' => now()->addMinutes(self::EXPIRY_MINUTES)->timestamp,
|
'expires_at' => now()->addMinutes(self::EXPIRY_MINUTES)->timestamp,
|
||||||
'attempts' => 0,
|
'attempts' => 0,
|
||||||
], now()->addMinutes(self::EXPIRY_MINUTES));
|
], now()->addMinutes(self::EXPIRY_MINUTES));
|
||||||
@@ -154,15 +162,15 @@ class UserOtpService
|
|||||||
return DB::transaction(function () use ($model, $email, $code) {
|
return DB::transaction(function () use ($model, $email, $code) {
|
||||||
$user = $model::where('email', $email)->lockForUpdate()->first();
|
$user = $model::where('email', $email)->lockForUpdate()->first();
|
||||||
|
|
||||||
if (! $user || ! $user->otp_expires_at || now()->isAfter($user->otp_expires_at)) {
|
if (! $user || ! $user->otp_code_hash || ! $user->otp_expires_at || now()->isAfter($user->otp_expires_at)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! hash_equals((string) $user->otp_code, $code)) {
|
if (! Hash::check($code, $user->otp_code_hash)) {
|
||||||
$user->otp_attempts++;
|
$user->otp_attempts++;
|
||||||
|
|
||||||
if ($user->otp_attempts >= (int) config('core.auth.otp.max_attempts', 5)) {
|
if ($user->otp_attempts >= (int) config('core.auth.otp.max_attempts', 5)) {
|
||||||
$user->otp_code = null;
|
$user->otp_code_hash = null;
|
||||||
$user->otp_expires_at = null;
|
$user->otp_expires_at = null;
|
||||||
$user->otp_attempts = 0;
|
$user->otp_attempts = 0;
|
||||||
}
|
}
|
||||||
@@ -172,7 +180,7 @@ class UserOtpService
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$user->otp_code = null;
|
$user->otp_code_hash = null;
|
||||||
$user->otp_expires_at = null;
|
$user->otp_expires_at = null;
|
||||||
$user->otp_attempts = 0;
|
$user->otp_attempts = 0;
|
||||||
$user->save();
|
$user->save();
|
||||||
@@ -200,7 +208,7 @@ class UserOtpService
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! hash_equals((string) $pending['code'], $code)) {
|
if (! Hash::check($code, $pending['code_hash'])) {
|
||||||
$pending['attempts']++;
|
$pending['attempts']++;
|
||||||
|
|
||||||
if ($pending['attempts'] >= (int) config('core.auth.otp.max_attempts', 5)) {
|
if ($pending['attempts'] >= (int) config('core.auth.otp.max_attempts', 5)) {
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ class CheckoutTranslationsSeeder extends Seeder
|
|||||||
'cart.increase' => ['Increase quantity', 'Αύξηση ποσότητας'],
|
'cart.increase' => ['Increase quantity', 'Αύξηση ποσότητας'],
|
||||||
'cart.decrease' => ['Decrease quantity', 'Μείωση ποσότητας'],
|
'cart.decrease' => ['Decrease quantity', 'Μείωση ποσότητας'],
|
||||||
'cart.remove' => ['Remove', 'Αφαίρεση'],
|
'cart.remove' => ['Remove', 'Αφαίρεση'],
|
||||||
|
'cart.updated' => ['Cart updated', 'Το καλάθι ενημερώθηκε'],
|
||||||
'cart.subtotal' => ['Subtotal', 'Υποσύνολο'],
|
'cart.subtotal' => ['Subtotal', 'Υποσύνολο'],
|
||||||
'cart.discount' => ['Discount', 'Έκπτωση'],
|
'cart.discount' => ['Discount', 'Έκπτωση'],
|
||||||
'cart.shipping' => ['Shipping', 'Μεταφορικά'],
|
'cart.shipping' => ['Shipping', 'Μεταφορικά'],
|
||||||
@@ -190,6 +191,10 @@ class CheckoutTranslationsSeeder extends Seeder
|
|||||||
'Θέλεις να παρακολουθείς την παραγγελία σου; Δημιούργησε λογαριασμό ή',
|
'Θέλεις να παρακολουθείς την παραγγελία σου; Δημιούργησε λογαριασμό ή',
|
||||||
],
|
],
|
||||||
'page.confirmation_billing' => ['Billing', 'Χρέωση'],
|
'page.confirmation_billing' => ['Billing', 'Χρέωση'],
|
||||||
|
'page.confirmation_bank_transfer_heading' => [
|
||||||
|
'Bank transfer details',
|
||||||
|
'Στοιχεία τραπεζικής μεταφοράς',
|
||||||
|
],
|
||||||
'page.confirmation_continue' => ['Continue shopping', 'Συνέχεια αγορών'],
|
'page.confirmation_continue' => ['Continue shopping', 'Συνέχεια αγορών'],
|
||||||
'page.box_now_locker_label' => [
|
'page.box_now_locker_label' => [
|
||||||
'Choose a Box Now locker',
|
'Choose a Box Now locker',
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ use Modules\Core\Customer\Services\CustomerAccountService;
|
|||||||
use Modules\Core\Payment\Enums\PaymentResultStatus;
|
use Modules\Core\Payment\Enums\PaymentResultStatus;
|
||||||
use Modules\Core\Payment\Models\PaymentMethod;
|
use Modules\Core\Payment\Models\PaymentMethod;
|
||||||
use Modules\Core\Shipping\Carriers\BoxNow\BoxNowClient;
|
use Modules\Core\Shipping\Carriers\BoxNow\BoxNowClient;
|
||||||
|
use Modules\Core\Store\Services\StoreDetailsService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The checkout page — one page, sections (contact / billing / shipping /
|
* The checkout page — one page, sections (contact / billing / shipping /
|
||||||
@@ -554,6 +555,8 @@ class CheckoutController extends Controller
|
|||||||
return view('checkout::confirmation', [
|
return view('checkout::confirmation', [
|
||||||
'order' => $order,
|
'order' => $order,
|
||||||
'paymentMethodName' => $paymentMethodName,
|
'paymentMethodName' => $paymentMethodName,
|
||||||
|
'bankTransferInstructions' => app(StoreDetailsService::class)
|
||||||
|
->bankTransferInstructionsFor($order, $locale),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -150,7 +150,7 @@ class CorePlugin implements Plugin
|
|||||||
});
|
});
|
||||||
|
|
||||||
LunarStaff::addActivitylogExcept([
|
LunarStaff::addActivitylogExcept([
|
||||||
'otp_code',
|
'otp_code_hash',
|
||||||
'otp_expires_at',
|
'otp_expires_at',
|
||||||
'password',
|
'password',
|
||||||
'remember_token',
|
'remember_token',
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ class CustomerDataProvider implements PersonalDataProvider
|
|||||||
// secret tied to an identity that no longer exists here — clear
|
// secret tied to an identity that no longer exists here — clear
|
||||||
// it alongside name/email rather than leaving it to expire on
|
// it alongside name/email rather than leaving it to expire on
|
||||||
// its own 10-minute window.
|
// its own 10-minute window.
|
||||||
'otp_code' => null,
|
'otp_code_hash' => null,
|
||||||
'otp_expires_at' => null,
|
'otp_expires_at' => null,
|
||||||
'otp_attempts' => 0,
|
'otp_attempts' => 0,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ use Modules\Core\Customer\Exceptions\InvalidEmailChangeCodeException;
|
|||||||
* hash of the code, expiry, wrong-guess count) lives on the user's own
|
* hash of the code, expiry, wrong-guess count) lives on the user's own
|
||||||
* row (see the migration adding pending_email/pending_email_code_hash/
|
* row (see the migration adding pending_email/pending_email_code_hash/
|
||||||
* pending_email_expires_at/pending_email_attempts) — the same convention
|
* pending_email_expires_at/pending_email_attempts) — the same convention
|
||||||
* Auth\Services\UserOtpService's otp_code/otp_expires_at/otp_attempts
|
* Auth\Services\UserOtpService's otp_code_hash/otp_expires_at/otp_attempts
|
||||||
* already use — rather than the session, since a code arrives by email
|
* already use — rather than the session, since a code arrives by email
|
||||||
* and is often opened on a different device/session than the one that
|
* and is often opened on a different device/session than the one that
|
||||||
* requested it; a session-scoped pending change couldn't be confirmed
|
* requested it; a session-scoped pending change couldn't be confirmed
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use Illuminate\Support\Facades\Event;
|
|||||||
use Lunar\Models\Language;
|
use Lunar\Models\Language;
|
||||||
use Lunar\Models\Order;
|
use Lunar\Models\Order;
|
||||||
use Modules\Core\Order\Services\OrderStatusFlow;
|
use Modules\Core\Order\Services\OrderStatusFlow;
|
||||||
|
use Modules\Core\Order\Support\OrderReferenceDisplay;
|
||||||
use Modules\Core\Store\Events\StoreDetailsUpdated;
|
use Modules\Core\Store\Events\StoreDetailsUpdated;
|
||||||
use Modules\Core\Store\Models\StoreDetails;
|
use Modules\Core\Store\Models\StoreDetails;
|
||||||
|
|
||||||
@@ -39,8 +40,8 @@ class StoreDetailsService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Null for any non-bank-transfer order — the confirmation email only
|
* Null for any non-bank-transfer order — the confirmation email and page
|
||||||
* shows this block when there's actually a wire to send (see
|
* only show this block when there's actually a wire to send (see
|
||||||
* BankTransferPaymentDriver's own docblock for why a bank transfer
|
* BankTransferPaymentDriver's own docblock for why a bank transfer
|
||||||
* order stays at 'awaiting_payment' until staff confirm the wire
|
* order stays at 'awaiting_payment' until staff confirm the wire
|
||||||
* arrived). Null also when the store hasn't filled the field in for
|
* arrived). Null also when the store hasn't filled the field in for
|
||||||
@@ -66,7 +67,30 @@ class StoreDetailsService
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return RichContentRenderer::make($content)->toHtml();
|
return $this->fillOrderReference(
|
||||||
|
RichContentRenderer::make($content)->toHtml(),
|
||||||
|
$order,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces a `{order_reference}` (or `{{ order_reference }}`) the shop
|
||||||
|
* owner typed into the instructions with the order's display reference
|
||||||
|
* (OrderReferenceDisplay — same form as the email subject).
|
||||||
|
*
|
||||||
|
* A plain text replace rather than RichContentRenderer::mergeTags():
|
||||||
|
* that only fills genuine Tiptap mergeTag nodes, which this editor never
|
||||||
|
* creates — Lunar's TranslatedText can't pass mergeTags() through to its
|
||||||
|
* per-locale RichEditors, so the placeholder is always stored as
|
||||||
|
* ordinary typed text.
|
||||||
|
*/
|
||||||
|
private function fillOrderReference(string $html, Order $order): string
|
||||||
|
{
|
||||||
|
return preg_replace(
|
||||||
|
'/\{\{?\s*order_reference\s*\}\}?/',
|
||||||
|
e(OrderReferenceDisplay::resolve($order)),
|
||||||
|
$html,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function update(array $attributes): StoreDetails
|
public function update(array $attributes): StoreDetails
|
||||||
|
|||||||
Reference in New Issue
Block a user