Compare commits

..
Author SHA1 Message Date
arvanitakis 1b06486b2b Feat: Adding Elta integrations 2026-09-29 14:31:30 +03:00
arvanitakis 6f6ae03428 Feat: Updating Favicon for core plugin 2026-09-29 01:25:35 +03:00
34 changed files with 1627 additions and 478 deletions
-50
View File
@@ -4,56 +4,6 @@ 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.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
### Fixed
- `Modules\Core\Order\Services\TransactionRecorder::record()` — made idempotent
on `(order_id, type, reference)`. A successful Stripe payment can legitimately
report `PaymentCaptured` twice for the same PaymentIntent (checkout's
synchronous capture via `pay()`, then the webhook confirming the same
outcome asynchronously via `handleCallback()`), both routing through
`resultFromIntent()`. With no dedupe check, this wrote two identical
`Transaction` rows for one real payment. Now returns the existing row
instead of creating a duplicate.
## [0.27.1] - 2026-09-29
### Added
- Temp logger for Stripe webhook
## [0.27.0] - 2026-09-29 ## [0.27.0] - 2026-09-29
### Added ### Added
+4 -2
View File
@@ -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.4", "version": "0.27.0",
"autoload": { "autoload": {
"psr-4": { "psr-4": {
"Modules\\Core\\": "src/" "Modules\\Core\\": "src/"
@@ -18,7 +18,9 @@
"lunarphp/search": "*", "lunarphp/search": "*",
"lunarphp/meilisearch": "*", "lunarphp/meilisearch": "*",
"spatie/laravel-translation-loader": "^2.8", "spatie/laravel-translation-loader": "^2.8",
"stripe/stripe-php": "^16.6" "stripe/stripe-php": "^16.6",
"picqer/php-barcode-generator": "^3.3",
"barryvdh/laravel-dompdf": "^3.1"
}, },
"require-dev": { "require-dev": {
"fakerphp/faker": "^1.23", "fakerphp/faker": "^1.23",
+70
View File
@@ -0,0 +1,70 @@
<?php
/*
|--------------------------------------------------------------------------
| ELTA Courier credentials
|--------------------------------------------------------------------------
|
| ELTA's API is SOAP (unlike ACS's JSON gateway or Box Now's REST +
| OAuth2). Two operation families live at the same endpoint — see
| Modules\Core\Shipping\Carriers\Elta\EltaClient's docblock for why only
| the "*NEW" family (create/track/station/cancel) is used; the old
| family (including its label-print operation) is unreachable with this
| account and isn't called anywhere in this integration. Labels are
| rendered locally instead — see
| Modules\Core\Shipping\Carriers\Elta\EltaLabelRenderer — which is why
| sender identity (name/address, unlike PELVGNEW's request which needs
| only apost_code) is configured here.
|
| Set these via environment variables — never commit real values.
|
| ELTA_SERVICE_LOCATION SOAP endpoint (defaults to the live production
| host; only needed if ELTA gives you a separate
| sandbox host).
| ELTA_USER_CODE Account user code (pel_user_code / pel_user).
| ELTA_USER_PASS Account security code — not used by anything the
| *NEW family calls; kept only in case ELTA ever
| asks for it.
| ELTA_APOST_CODE Sender/account code (pel_apost_code).
| ELTA_APOST_SUB_CODE Sender sub-code (pel_apost_sub_code).
| ELTA_SENDER_NAME Sender name printed on the label — PELVGNEW's
| own request has no such field, so this only
| matters for our own locally-rendered label.
| ELTA_SENDER_ADDRESS Sender street address printed on the label.
| ELTA_SENDER_POSTCODE Sender postcode printed on the label.
| ELTA_SENDER_AREA Sender area/city printed on the label.
| ELTA_SENDER_PHONE Sender phone printed on the label.
| ELTA_ORIGIN_STATION_CODE This account's home ELTA station code — shown
| on the label as "Γ.Κατάθεσης"/"Από". ELTA's own
| client gets this from a PELLOGINNEW response
| (user_station); configured here instead so a
| label print doesn't need an extra API call.
| ELTA_LABEL_PAPER_SIZE "a4" (default, 3 copies per page) or "a6"
| (single thermal label) — matches the real
| client's own one-time printer-setup choice, not
| varied per shipment.
|
*/
return [
'service_location' => env('ELTA_SERVICE_LOCATION', 'http://212.205.47.226:9003'),
'user_code' => env('ELTA_USER_CODE'),
'user_pass' => env('ELTA_USER_PASS'),
'apost_code' => env('ELTA_APOST_CODE'),
'apost_sub_code' => env('ELTA_APOST_SUB_CODE'),
'sender_name' => env('ELTA_SENDER_NAME'),
'sender_address' => env('ELTA_SENDER_ADDRESS'),
'sender_postcode' => env('ELTA_SENDER_POSTCODE'),
'sender_area' => env('ELTA_SENDER_AREA'),
'sender_phone' => env('ELTA_SENDER_PHONE'),
'origin_station_code' => env('ELTA_ORIGIN_STATION_CODE'),
'label_paper_size' => env('ELTA_LABEL_PAPER_SIZE', 'a4'),
'timeout' => env('ELTA_HTTP_TIMEOUT', 15),
];
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@boboko/core", "name": "@boboko/core",
"version": "0.27.4", "version": "0.27.0",
"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.",
+26 -110
View File
@@ -53,7 +53,6 @@
--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;
@@ -154,11 +153,6 @@
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;
@@ -189,7 +183,7 @@
.bbk-cart-item { .bbk-cart-item {
display: grid; display: grid;
grid-template-columns: 72px 1fr; grid-template-columns: 72px 1fr auto;
gap: 0.875rem; gap: 0.875rem;
align-items: start; align-items: start;
} }
@@ -205,23 +199,8 @@
.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;
@@ -273,24 +252,24 @@ a.bbk-cart-item-title:hover { text-decoration: underline; }
color: var(--bbk-color-muted); color: var(--bbk-color-muted);
} }
.bbk-cart-item-total { margin: 0; font-weight: 600; white-space: nowrap; } .bbk-cart-item-aside {
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.75rem; font-size: 1.125rem;
width: 2.5rem; width: 1.5rem;
height: 2.5rem; height: 1.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;
@@ -926,39 +905,10 @@ 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: 560px; max-width: 720px;
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);
@@ -966,7 +916,7 @@ textarea.bbk-field-input { resize: vertical; }
} }
.bbk-confirmation-heading { .bbk-confirmation-heading {
margin: 0 0 1.25rem; margin: 0 0 1rem;
font-size: 1.75rem; font-size: 1.75rem;
font-weight: 700; font-weight: 700;
} }
@@ -974,7 +924,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: 1.5rem 0 1rem; margin: 0 0 1rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.25rem; gap: 0.25rem;
@@ -990,22 +940,17 @@ 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-section { .bbk-confirmation-body {
margin-top: 2rem; margin: 2rem 0;
padding-top: 1.5rem; display: grid;
border-top: 1px solid var(--bbk-color-border); gap: 2.5rem;
} }
.bbk-confirmation-section-heading { @media (min-width: 640px) {
margin: 0 0 1rem; .bbk-confirmation-body { grid-template-columns: 1fr 1fr; }
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;
@@ -1020,51 +965,22 @@ 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: grid; display: flex;
flex-direction: column;
gap: 1.5rem; gap: 1.5rem;
} }
@media (min-width: 480px) { .bbk-confirmation-address-heading {
.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;
+3 -82
View File
@@ -9,13 +9,11 @@ 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', 'heading', 'status'] static targets = ['panel', 'body', 'error']
connect() { connect() {
this.onChanged = this.onChanged.bind(this) this.onChanged = this.onChanged.bind(this)
@@ -42,31 +40,19 @@ export default class extends Controller {
} }
onKeydown(event) { onKeydown(event) {
// Only the drawer instance is a dialog — the checkout page's summary if (event.key === 'Escape' && !this.element.hidden) this.close()
// 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(() => { requestAnimationFrame(() => this.element.setAttribute('data-bbk-cart-state', 'open'))
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
@@ -146,28 +132,6 @@ 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
@@ -180,53 +144,10 @@ 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
Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" width="151" height="150" viewBox="0 0 151 150"><metadata><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:dc="http://purl.org/dc/elements/1.1/"><rdf:Description><dc:creator>RealFaviconGenerator</dc:creator><dc:source>https://realfavicongenerator.net</dc:source></rdf:Description></rdf:RDF></metadata><image width="151" height="150" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAJcAAACWCAYAAADTwxrcAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAOdEVYdFNvZnR3YXJlAEZpZ21hnrGWYwAACYRJREFUeAHtnctuG0cWhv8qJuuRgWSAWU1nMrO28gRuP4FkzANIegJZyAXIStQqQOLA8hOY2ieR/QSmnyDMOgnSuwCJAzLrmF05p6spURSvYpNddfp8gO6UxGb9fc6p21+AoiiKoiiKoiiKoiiKoiiKoiiKoiiKoiiKRAyUxVz2d+j9TvH5o3sZlKVQcY1z2d9FjhQW9+GwS99JMBLVbbLizaCHIV6jhS4JbwDlChXXZT+l93skpkPMFtKydOnvXOD/9zpQGiwuFpXDKX2Wono4onXo40WT02jzxLVZUU3iRfbo3hkaSHPE5YvyUxLWY2wfFtnDpkUxiybAhbrD9zUJi0nof/9Cz+MUDUK+uF72D6hhX8H3/OrFoU0Ce4qGIDstcqTgBg2PLr3yj6QPXcgVV7jCGtEr6zCxApOZFr/p7wcuLIbrwEsIRp64LvsJXdVzxEEquQaTlRZ5uIF7hSEU76vg02MXwpAWubirnyA2HEVaPw4nCjni8mNZdY1jrUsCf2OIQo64Yi+O+cbwk+hikCGub/uHiDEdTuJkRS8Z4jJiGiWVFL3iF5dvjARScNiHECRErgPI4kBKzzF+cQm600t2kMu4prjF5VOiuPEhWPsAAohbXPlWVpNuH5dr5KodAxF3+BR2ijnSyIm95pKXEq/ZReTELq7oG2Amefw3TrziEjjRO0GCyIk5ckkXV/Q0Y/ePUgsqLmVjqLiUjRGvuOTvXo5+V1DskUvuvj9bWDRFTeziyiAXjVw18wPk0kPkxC0uhy5k0pOwEztucVmx4noNAcQtLt9j7EIaBi8gAAkrUUXc5WNkUnZfxy8ui3NIGpIwEGNxGb+4fOF7ARlkEJTmpexblBG9nCz3Zxni4gZxeIa4ycoULwY5E9e+YTLECtdawlwG5YiLG8bgCDFiLHvVdyAMWUtufBc+tvSYAbnIQxBkGu5+12dr8BThw9H2I6nLh2QuFmQb7hjqL07jgtelyRSXr78eImSBeWGJmOaZhdxlzhwRwhTYoBRWB8KRvYZ+JLBwJoJHB0x10ACac2rZt/12rQ6ExpDA3VGTTpNt1nmLbO7hjXm3aQPAafCkKdFqnGaeFHvZPyzNbRNsjkExJcUzBw09+7rZZ1x7kbHtZYrqaLyoRugB6oz3wuLDqPZwN6ENyk4DL/0Rsf69ClRck3j3nN3ijW2MLP596zEOf9Irl9HPB8U6/gYfkq4oiqIoiqIoiqIoihIIWxnn+hVfJNO+/y98nkHZCO6XwpB4minxwHywnW14lYqrj6c7b/GWBiDtnoFLDMyuo4+Y/wR6Dsjosa+HGHb/ic+itw7aNoWQhjTD4E8U4QHgBPPdrnlGoUeDwb3CDqGFHgkuQ8VUIq7f8VVK4jigt30S01oW3vQ3MhJbtwV7dg8nGZSpFILKcUgv2B4JJMW6GJppyHFh/osOKmItcb3B14d0mVVP/I5hOiqym5Tp7piE8Bib8eLnzbkd+nixbjS7k7jKSMVLVlJsBRXZFkQ1Ce9iP1snkq0kLq6pcuSnlPoeY8uU6fLsPXzcQcMgYaUkqueo48gWTpcGR3eJYkuLi4S1S8K6XFSgbxoHc/4+Pj5BQyBhcbSq20OCo9gJRbGV9iIsJa43eMLF+vm6xXpVcBSzsA8lp8myYOdotY9QsGhTBFt6d/hCcf2BrzkNthEYkgVWCot3jW9zrf9yrCCwueL6A0+Oqc4J1tZHqsDcz/geYZ8l2TEfLjZ9mblvkVLhfsjCYrj+ozrwFdWDCYRAUespwj+k9JCe58JtelPFxY1FUeEpIoAFRiP7zyEAilin5VBD+ORok8AO5j3ETv+9/FXdvcIVSWnsLY5GmUEx3AC0ERPUi6Xnncz68S1xlQX8zF8IFY60v+HL0NPJVIoG8j3D2Nih1DHzed8QF6fDEHuGy0LFfRSpfArHqGOAtApoXpPS+dSscUNcAmqX1M93xkMZtaJO6cRpOT11gytx8Xwh4nDjmwuNrdRnNnIX8sie73R2pt0gV+IqJ6Kjh+vFWKJXWQwfQgbHk9GrEFc5TpRCDO4AMSAjao3w68vGKMQ1RC7pIpm0TPOhk0ISpvDauML67wm7SE+KgHE/Fs8vgSS45ziWGi0vpYlxXGsRFmYPIWMDWu1QJWOp0VJKjHLgcRE0L7rLixsRLg8gEYv715/CiRQX43ciBYvM1z2/Lke45roPuQTZgFSXiL2hMVZHWhrfSiCUYK/tLUJO12szmsy2Eov5Mf6BEDHCeom3KW4e2YccKHWh4lI2i4pL2RgqLmVjcG8xg1z+RJhsxcKoRjJ+x4OoYi+UesIZQqQV8UHvy1FoiqZ/8AOEEnBUziCXK3M5SouuB6HkyDMESPniZ5AIm8qVcEEvUlwctYJ2KWRHP4mMXZd9H592IbDAZHdChE0XErHX11UMReTAS4jDhX1NrWCORq6SjFJ+d/RFuRLVdSAITonv4ZOgG6+ou5y46NUd/6IQl7TUGEFK9Ljlva6iwN68nqsRehoTegYhsH8qIsD8j24COdGrM2lteSWud/DOuYzRetOJyq9LSvSyt6/jSlzUIAM2tEXE8M0RS9QaISR6daYZ8t6YuC6dkruIFL45onQZbCFmA+FsWtRibq2KaKHFdoQRFvemE6uNON31PNgbp8DYq36Gjfgtcfk730R1oTGmw0nMhziPMD3OPQRhpuHuGzxhP9RjBA4Ja2BhP5JgulvaKbGLc4LQMeia/+DhvIfMXCxIg5BsiXOBwBlieCLFzblIL7ZosNDLkoxP1Vj0oLkrUan+emwCntjOkR/R5HQHgohAYMXzW+a4lrni4uEJixZfaFBTKZwKJQprRFHge4FlCIvessJilj7753d81Q7BIK482OARCV/sOrQRQdVgDs8olbVXOWV2pVPL2POKGvZ5fRtpzQvqFR5xREWDcD+iTRGjrht7QAI/o8He8xV/bzVxMexCSEU0F/tb60n64/DcSegrHTZJLVFsjePw/K/fERbZXxi2qWjbmEVkWVs943nPpkWrWbifiqOHOYol2BQsqmERrbpYgzuLa8QmRKaiWkwhMn7NTYUOihWJ6vrPVQQbrQ2R7+dwaQvmwap1Gae+IdxLA/eiXF+mLEGZLvep4N67g9AGxYYKh5d8rvUqxfoyVCauSVhsbL5mYBOHPMHUf26zHMPeu3g30whVDYX315AE5510plk18QrYAfX8uuaD4IY6FEVRFEVRFEVRFEVRFEVRFEVRFEVRFEVRFKXJ/A2oC/VTZ0o/1AAAAABJRU5ErkJggg=="></image></svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

+20 -50
View File
@@ -2,9 +2,6 @@
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')
@@ -14,19 +11,10 @@
<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)
@@ -51,6 +39,8 @@
@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. --}}
@@ -63,29 +53,21 @@
@endif @endif
@endguest @endguest
<section class="bbk-confirmation-section" aria-labelledby="bbk-confirmation-summary-heading"> <div class="bbk-confirmation-body">
<h2 class="bbk-confirmation-section-heading" id="bbk-confirmation-summary-heading"> <div class="bbk-confirmation-lines">
{{ __('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)
<li class="bbk-confirmation-line"> <div 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="" width="72" height="72" loading="lazy"> <img src="{{ $thumb }}" alt="{{ $line->description }}" width="72" height="72" loading="lazy">
@endif @endif
</div> </div>
<div class="bbk-confirmation-line-detail"> <div class="bbk-confirmation-line-detail">
<p class="bbk-confirmation-line-name"> <span class="bbk-confirmation-line-name">
{{ $line->description }} {{ $line->description }}
<span class="bbk-confirmation-line-qty"> <span class="bbk-confirmation-line-qty">&times; {{ $line->quantity }}</span>
<span aria-hidden="true">&times; {{ $line->quantity }}</span>
<span class="bbk-visually-hidden">— {{ __('checkout.cart.quantity') }}: {{ $line->quantity }}</span>
</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>
@@ -94,13 +76,9 @@
@include('checkout::partials.line-custom-fields', ['line' => $line]) @include('checkout::partials.line-custom-fields', ['line' => $line])
</div> </div>
<p class="bbk-confirmation-line-total"> <span class="bbk-confirmation-line-total">{{ $line->sub_total?->formatted() }}</span>
<span class="bbk-visually-hidden">{{ __('checkout.cart.total') }}:</span> </div>
{{ $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"> <div class="bbk-cart-summary-row">
@@ -132,31 +110,23 @@
<span>{{ $order->total?->formatted() }}</span> <span>{{ $order->total?->formatted() }}</span>
</div> </div>
</div> </div>
</section> </div>
@if ($order->shippingAddress || $order->billingAddress) <div class="bbk-confirmation-addresses">
<div class="bbk-confirmation-section bbk-confirmation-addresses">
@if ($order->shippingAddress) @if ($order->shippingAddress)
<section class="bbk-confirmation-address" aria-labelledby="bbk-confirmation-shipping-heading"> <div class="bbk-confirmation-address">
<h2 class="bbk-confirmation-address-heading" id="bbk-confirmation-shipping-heading">{{ __('checkout.page.confirmation_shipping_to') }}</h2> <h2 class="bbk-confirmation-address-heading">{{ __('checkout.page.confirmation_shipping_to') }}</h2>
<x-checkout::address-lines :address="$order->shippingAddress" /> <x-checkout::address-lines :address="$order->shippingAddress" />
</section> </div>
@endif @endif
@if ($order->billingAddress) @if ($order->billingAddress)
<section class="bbk-confirmation-address" aria-labelledby="bbk-confirmation-billing-heading"> <div class="bbk-confirmation-address">
<h2 class="bbk-confirmation-address-heading" id="bbk-confirmation-billing-heading">{{ __('checkout.page.confirmation_billing') }}</h2> <h2 class="bbk-confirmation-address-heading">{{ __('checkout.page.confirmation_billing') }}</h2>
<x-checkout::address-lines :address="$order->billingAddress" /> <x-checkout::address-lines :address="$order->billingAddress" />
</section>
@endif
</div> </div>
@endif @endif
</div>
@if ($bankTransferInstructions) </div>
<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
+2 -13
View File
@@ -15,9 +15,7 @@
data-bbk-cart-target="panel" data-bbk-cart-target="panel"
> >
<header class="bbk-cart-panel-header"> <header class="bbk-cart-panel-header">
{{-- tabindex=-1: the controller moves focus here on open, and back <h2 class="bbk-cart-heading" id="bbk-cart-heading">{{ __('checkout.cart.title') }}</h2>
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"
@@ -28,16 +26,7 @@
@include('checkout::partials.cart-error') @include('checkout::partials.cart-error')
{{-- A short announcement after each update, rather than aria-live on <div class="bbk-cart-panel-body" data-bbk-cart-target="body" aria-live="polite">
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>
+2 -8
View File
@@ -267,15 +267,9 @@
<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" tabindex="-1" data-bbk-cart-target="heading">{{ __('checkout.page.order_summary_heading') }}</h2> <h2 class="bbk-checkout-summary-heading">{{ __('checkout.page.order_summary_heading') }}</h2>
@include('checkout::partials.cart-error') @include('checkout::partials.cart-error')
<p <div data-bbk-cart-target="body" aria-live="polite">
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,60 +24,32 @@
<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="" width="72" height="72" loading="lazy"> <img src="{{ $thumb }}" alt="{{ $name }}" width="72" height="72" loading="lazy">
</a> </a>
@else @else
<img src="{{ $thumb }}" alt="" width="72" height="72" loading="lazy"> <img src="{{ $thumb }}" alt="{{ $name }}" width="72" height="72" loading="lazy">
@endif @endif
@endif @endif
</div> </div>
<div class="bbk-cart-item-detail"> <div class="bbk-cart-item-detail">
<div class="bbk-cart-item-head">
@if ($productUrl) @if ($productUrl)
<a href="{{ $productUrl }}" class="bbk-cart-item-title" id="bbk-cart-item-title-{{ $line->id }}">{{ $name }}</a> <a href="{{ $productUrl }}" class="bbk-cart-item-title">{{ $name }}</a>
@else @else
<p class="bbk-cart-item-title" id="bbk-cart-item-title-{{ $line->id }}">{{ $name }}</p> <p class="bbk-cart-item-title">{{ $name }}</p>
@endif @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">&times;</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>
<div class="bbk-cart-item-foot">
{{-- role=group + the title as its name: entering the stepper
announces which product's quantity is being changed. --}}
<form <form
class="bbk-cart-qty" class="bbk-cart-qty"
method="POST" method="POST"
action="{{ route('checkout.cart.update', ['locale' => app()->getLocale(), 'line' => $line->id]) }}" action="{{ route('checkout.cart.update', ['locale' => app()->getLocale(), 'line' => $line->id]) }}"
role="group"
aria-labelledby="bbk-cart-item-title-{{ $line->id }}"
> >
@csrf @csrf
@method('PATCH') @method('PATCH')
@@ -87,7 +59,7 @@
data-action="bbk-cart#step" data-action="bbk-cart#step"
data-bbk-cart-dir-param="-1" data-bbk-cart-dir-param="-1"
aria-label="{{ __('checkout.cart.decrease') }}" aria-label="{{ __('checkout.cart.decrease') }}"
><span aria-hidden="true">&minus;</span></button> >&minus;</button>
<input <input
type="number" type="number"
@@ -107,13 +79,25 @@
data-action="bbk-cart#step" data-action="bbk-cart#step"
data-bbk-cart-dir-param="1" data-bbk-cart-dir-param="1"
aria-label="{{ __('checkout.cart.increase') }}" aria-label="{{ __('checkout.cart.increase') }}"
><span aria-hidden="true">+</span></button> >+</button>
</form> </form>
<p class="bbk-cart-item-total">
<span class="bbk-visually-hidden">{{ __('checkout.cart.total') }}:</span>
{{ $line->subTotal?->formatted() }}
</p>
</div> </div>
<div class="bbk-cart-item-aside">
<p class="bbk-cart-item-total">{{ $line->subTotal?->formatted() }}</p>
<form
method="POST"
action="{{ route('checkout.cart.remove', ['locale' => app()->getLocale(), 'line' => $line->id]) }}"
data-action="submit->bbk-cart#submit"
>
@csrf
@method('DELETE')
<button
type="submit"
class="bbk-cart-item-remove"
aria-label="{{ __('checkout.cart.remove') }}"
>&times;</button>
</form>
</div> </div>
</li> </li>
@@ -0,0 +1,123 @@
<!DOCTYPE html>
<html lang="el">
<head>
<meta charset="utf-8">
<style>
{{--
Geometry is transcribed directly from ELTA_PEL.sydetaE.rdlc
(the "delivery copy" RDLC ELTA's own client selects for
printer_size==1, i.e. plain A4, per Sydeta.cs::print_vg()) —
every .mc-*/.ps-* absolute position below is that report's
own Top/Left in cm, walked through its Rectangle/Textbox
nesting so each value is already relative to its own
containing band. Static label text and font sizes/weights
also come from the RDLC's own Textbox/Style elements, not
guessed from the screenshot in ELTA's setup manual (that
manual image was used only as a sanity check afterwards).
--}}
@page { margin: 0; size: 21cm 29.7cm; }
html, body { margin: 0; padding: 0; }
body { font-family: 'DejaVu Sans', sans-serif; font-size: 6.5pt; color: #000; }
.page { position: relative; width: 21cm; height: 29.7cm; }
/* Each RDLC "band" (one ΣΥΝΟΔΕΥΤΙΚΟ ΔΕΛΤΙΟ copy) is its own
positioning context, so every child offset below is a direct
transcription of the RDLC's own relative-to-band Top/Left —
no manual offset arithmetic. */
.band { position: absolute; left: 0.27cm; }
.band > * { position: absolute; }
.mc-logo, .mc-company, .mc-box, .mc-cell, .mc-partybox { position: absolute; }
.mc-logo img { width: 100%; height: 100%; object-fit: contain; }
.mc-company { font-size: 4.6pt; line-height: 1.25; }
.mc-company-name { font-weight: bold; font-size: 5.2pt; }
.mc-text.mc-small { font-size: 4.6pt; }
.mc-box { border: 1px solid #000; box-sizing: border-box; }
.mc-title { position: absolute; font-weight: bold; font-size: 8pt; text-align: center; white-space: nowrap; }
.mc-barcode { position: absolute; text-align: center; }
.mc-barcode img { height: 26px; }
.mc-voucher { position: absolute; text-align: center; font-weight: bold; font-size: 10.5pt; letter-spacing: 0.5px; }
.mc-copylabel { position: absolute; text-align: center; font-weight: bold; font-size: 8pt; }
.mc-cell { border: 1px solid #000; box-sizing: border-box; padding: 1px 2px; }
.mc-cell-label { font-size: 5.5pt; }
.mc-cell-val { font-weight: bold; font-size: 8pt; margin-top: 3px; }
.mc-cell-service { text-align: center; }
/* 5-cell deposit/destination/weight/pieces/volumetric row: a real
table with border-collapse so each internal shared edge is
drawn once, not twice (see _main-copy.blade.php comment). */
.mc-cell-row { position: absolute; border-collapse: collapse; table-layout: fixed; }
.mc-cell-td { border: 1px solid #000; box-sizing: border-box; padding: 1px 2px; vertical-align: top; }
.mc-partybox { border: 1px solid #000; box-sizing: border-box; }
.mc-party-title { position: absolute; font-weight: bold; font-size: 9pt; }
.mc-party-line { position: absolute; font-size: 6.3pt; white-space: nowrap; overflow: hidden; }
.mc-charge { text-align: center; font-weight: bold; font-size: 8pt; box-sizing: border-box; padding-top: 2px; }
/* Payment stub (copy 3) reuses the .mc-* classes but at smaller
scale, matching the RDLC's own narrower band. */
.ps-brandbox { border: 1.5px solid #c8102e; box-sizing: border-box; text-align: center; padding-top: 4px; }
.ps-brand-logo img { width: 60%; margin: 0 auto 8px; display: block; }
.ps-brand-tag { color: #c8102e; font-weight: bold; font-size: 9pt; }
.ps-brandbox-small { border: 1px solid #000; box-sizing: border-box; text-align: center; padding: 2px; }
.ps-brand-logo-sm img { width: 40%; margin: 2px auto; display: block; }
.ps-legal-sm { font-size: 4.6pt; line-height: 1.3; }
/* Perforated tear-off strip on the right of copy 1 only, with
the rotated "3. ΑΡΧΕΙΟ / ΓΡΑΦΕΙΟ ΚΑΤΑΘΕΣΗΣ" label — RDLC's
image10 (x=15.42cm) is the dashed perforation line itself;
reproduced here as a CSS dashed border since we can't extract
that embedded bitmap. transform:rotate() (not
writing-mode:vertical-rl, confirmed broken in dompdf) rotates
a normal-flow block. */
.stub-strip { position: absolute; top: 2.25cm; left: 15.42cm; width: 5.58cm; height: 6.16cm; border-left: 1px dashed #000; }
.stub-vtext {
position: absolute; top: 2.6cm; left: -1.3cm; width: 6cm;
text-align: center; font-size: 6pt; font-weight: bold;
white-space: nowrap; transform: rotate(-90deg);
}
.cut-line { position: absolute; left: 0; width: 20.9cm; border-top: 1px dashed #000; text-align: center; font-weight: bold; font-size: 7pt; }
.cut-line span { position: relative; top: -5px; background: #fff; padding: 0 6px; }
.footer-ocr { position: absolute; top: 27.35cm; left: 0; width: 21cm; text-align: center; font-family: monospace; font-size: 8pt; letter-spacing: 1.5px; }
</style>
</head>
<body>
<div class="page">
{{-- Copy 1: delivery copy — RDLC band top=0 --}}
<div class="band" style="top:0.05cm; width:15.75cm; height:8.7cm;">
@include('core::shipping.carriers.elta.partials._main-copy', ['copyType' => 'delivery'])
</div>
<div class="stub-strip">
<div class="stub-vtext">3. ΑΡΧΕΙΟ / ΓΡΑΦΕΙΟ ΚΑΤΑΘΕΣΗΣ</div>
</div>
<div class="cut-line" style="top:9.0cm;"><span>✂ ΑΠΟΚΟΨΤΕ ΕΔΩ</span></div>
{{-- Copy 2: sender's copy — RDLC band top≈8.94cm on the full page --}}
<div class="band" style="top:9.05cm; width:15.75cm; height:9.9cm;">
@include('core::shipping.carriers.elta.partials._main-copy', ['copyType' => 'sender'])
</div>
<div class="cut-line" style="top:18.75cm;"><span>✂ ΑΠΟΚΟΨΤΕ ΕΔΩ</span></div>
{{-- Copy 3: ΤΑΧΥΠΛΗΡΩΜΗ ΕΙΣΠΡΑΞΗ / ΜΕΤΑΒΙΒΑΣΗ stub — RDLC band top≈19.22cm --}}
<div style="position:absolute; top:19.05cm; left:0.27cm; width:20.4cm; font-weight:bold; font-size:7pt; text-align:center;">
ΤΑΧΥΠΛΗΡΩΜΗ ΕΙΣΠΡΑΞΗ / ΜΕΤΑΒΙΒΑΣΗ &nbsp; Ο. Αριθμός Λογ/μού Ταχυπληρωμής {{ $siimvasi }}
</div>
<div class="band" style="top:19.45cm; width:16.75cm; height:7.4cm;">
@include('core::shipping.carriers.elta.partials._payment-stub')
</div>
<div class="cut-line" style="top:26.85cm;"><span>ΜΗ ΣΗΜΕΙΩΝΕΤΕ ΚΑΤΩ ΑΠΟ ΑΥΤΗ ΤΗ ΓΡΑΜΜΗ</span></div>
<div class="footer-ocr">&gt;{{ $ocr_line }}&lt;</div>
</div>
</body>
</html>
@@ -0,0 +1,225 @@
<!DOCTYPE html>
<html lang="el">
<head>
<meta charset="utf-8">
<style>
{{--
Geometry transcribed directly from ELTA_PEL.SydetaLabelE.rdlc
— the RDLC ELTA's own client selects for printer_size==2
(the A6/"thermal label" printer-setup radio button), per
Sydeta.cs::print_vg(); RDLCPrinter.cs overrides the actual
print DeviceInfo to 10.4cm x 14.8cm for this printer_size
regardless of what PageWidth/PageHeight the RDLC itself
declares (all ELTA RDLCs declare A4 internally). Every
.a6-* absolute position below is that report's own
Top/Left in cm.
--}}
@page { margin: 0; size: 10.4cm 14.8cm; }
html, body { margin: 0; padding: 0; }
body { font-family: 'DejaVu Sans', sans-serif; font-size: 6.3pt; color: #000; }
{{--
height is deliberately a hair under the true 14.8cm page —
dompdf's border-box math isn't quite exact at this scale, and
a .page box sized to exactly fill the page (even with
box-sizing:border-box) was empirically confirmed to overflow
a fraction of a point past the page canvas and silently push
a second, blank page (reproduced with an otherwise-empty
.page div: only the border's presence, not any content,
triggered it — 14.7cm was stable, 14.75cm was not).
--}}
.page { position: relative; width: 10.4cm; height: 14.65cm; border: 1.5px solid #000; box-sizing: border-box; overflow: hidden; }
.page > * { position: absolute; box-sizing: border-box; }
.a6-logo img { width: 100%; height: 100%; object-fit: contain; }
.a6-service-box { border: 1px solid #000; text-align: center; }
.a6-service-code { font-weight: bold; font-size: 7pt; }
.a6-service-name { font-weight: bold; font-size: 6pt; text-align: center; }
.a6-title { font-weight: bold; font-size: 7pt; }
.a6-datetime { font-size: 6pt; }
.a6-copy { font-weight: bold; font-size: 7pt; text-align: right; }
.a6-station-box { border: 1px solid #000; }
.a6-station-label { font-size: 6pt; font-weight: bold; }
.a6-station-val { font-weight: bold; font-size: 8pt; }
.a6-cell { border: 1px solid #000; padding: 1px 3px; }
.a6-cell-label { font-size: 6pt; }
.a6-cell-val { font-weight: bold; font-size: 8pt; }
/* ΧΡΕΩΣΗ/REFERENCE/ΣΥΜΒΑΣΗ row and the Γρ.Κατάθεσης/ΒΑΡΟΣ/
ΟΓΚΟΜΕΤΡΙΚΟ/Τεμάχια row: real tables with border-collapse so
each internal shared edge is drawn once (see label-a6.blade.php
comment above their markup). */
.a6-cell-row { position: absolute; border-collapse: collapse; border-spacing: 0; table-layout: fixed; }
.a6-cell-td { border: 1px solid #000; box-sizing: border-box; padding: 1px 3px; vertical-align: top; overflow: hidden; }
.a6-box { border: 1px solid #000; padding: 2px 3px; }
.a6-party-title { font-weight: bold; font-size: 7pt; }
.a6-party-line { font-size: 8pt; white-space: nowrap; overflow: hidden; }
.a6-return-title { font-weight: bold; font-size: 6pt; }
.a6-return-item { font-size: 6pt; }
.a6-checkbox { border: 1px solid #000; display: inline-block; width: 6px; height: 6px; margin-right: 3px; vertical-align: middle; }
.a6-small-title { font-weight: bold; font-size: 5pt; }
.a6-small-val { font-size: 6pt; }
.a6-legal { font-size: 5pt; text-align: center; line-height: 1.4; }
.a6-barcode { text-align: center; }
.a6-barcode img { height: 30px; }
.a6-voucher { text-align: center; font-weight: bold; font-size: 12pt; letter-spacing: 1px; }
</style>
</head>
<body>
<div class="page">
{{-- Header: logo (0.10,0.12,1.66x1.26) + service box (6.42,0.10,3.93x0.84) --}}
<div class="a6-logo" style="top:0.12cm;left:0.10cm;width:1.66cm;height:1.26cm;">
<img src="{{ $eltaLogo }}" alt="ELTA Courier">
</div>
<div class="a6-service-box" style="top:0.10cm;left:6.42cm;width:3.93cm;height:0.84cm;">
<div class="a6-service-code" style="position:absolute;top:0.02cm;left:0.09cm;">ΥΠΗΡΕΣΙΑ: {{ $service_code }}</div>
<div class="a6-service-name" style="position:absolute;top:0.40cm;left:0.04cm;width:3.81cm;">{{ $service_name }}</div>
</div>
<div class="a6-licence-box" style="top:1.05cm;left:6.42cm;width:3.88cm;height:0.62cm;border:1px solid #000;text-align:center;font-size:5.3pt;font-weight:bold;line-height:1.3;padding-top:2px;">
ΕΕΤΤ ΑΜ: 99-150 Γενική Άδεια<br>Ταχ/κων Υπηρεσιών
</div>
<div class="a6-datetime" style="top:1.68cm;left:0.13cm;width:1.68cm;">{{ $date }}</div>
<div class="a6-title" style="top:1.79cm;left:1.93cm;width:5.89cm;">ΣΥΝΟΔΕΥΤΙΚΟ ΔΕΛΤΙΟ ΤΑΧΥΜΕΤΑΦΟΡΑΣ</div>
{{-- The RDLC's own barcode textbox here uses the "Free 3 of 9
Extended" barcode font — we don't have that font, so this
would-be *{voucher}* fallback text is dropped in favor of the
real Code 128 barcode image rendered near the bottom instead. --}}
<div class="a6-datetime" style="top:2.00cm;left:0.16cm;width:1.60cm;">{{ $time }}</div>
<div class="a6-station-box" style="top:2.33cm;left:0.10cm;width:10.20cm;height:0.44cm;">
<div class="a6-station-label" style="position:absolute;top:0.05cm;left:0.10cm;">Γρ. Επίδοσης :</div>
<div class="a6-station-val" style="position:absolute;top:0.03cm;left:2.50cm;">{{ $station_pros }}</div>
<div class="a6-station-val" style="position:absolute;top:0.03cm;left:3.90cm;width:6.06cm;">{{ $station_pros_title }}</div>
</div>
{{-- ΧΡΕΩΣΗ / REFERENCE / ΣΥΜΒΑΣΗ 3-cell row, and the Γρ.Κατάθεσης /
ΒΑΡΟΣ / ΟΓΚΟΜΕΤΡΙΚΟ ΒΑΡΟΣ / Τεμάχια row right below it: both are
genuinely tabular single rows of fixed-width cells, so — like
the equivalent A4 rows — they're built as real
<table border-collapse:collapse> instead of independently-
bordered absolute divs, which was drawing every shared edge
(each cell-to-cell seam, and the seam between these two rows)
twice, visibly doubling/thickening those lines versus a real
printed ELTA label. Each table is positioned at the row's own
RDLC top/left; the first table's bottom border is dropped since
the second table's top border already draws that shared line. --}}
<table class="a6-cell-row" style="top:2.82cm;left:0.10cm;width:10.16cm;">
<colgroup>
<col style="width:5.41cm;"><col style="width:2.84cm;"><col style="width:1.91cm;">
</colgroup>
<tr>
<td class="a6-cell-td" style="height:0.47cm;border-bottom:none;">
<div style="font-size:7pt;">{{ $xreosi }}</div>
</td>
<td class="a6-cell-td" style="height:0.47cm;text-align:center;border-bottom:none;">
<div style="font-size:7pt;">{{ $ocr_reference }}</div>
</td>
<td class="a6-cell-td" style="height:0.47cm;text-align:center;border-bottom:none;">
<div style="font-size:7pt;">{{ $siimvasi }}</div>
</td>
</tr>
</table>
<table class="a6-cell-row" style="top:3.39cm;left:0.10cm;width:9.97cm;">
<colgroup>
<col style="width:2.00cm;"><col style="width:2.00cm;"><col style="width:4.12cm;"><col style="width:1.85cm;">
</colgroup>
<tr>
<td class="a6-cell-td" style="height:0.97cm;">
<div class="a6-cell-label">Γρ.Κατάθεσης:</div>
<div class="a6-cell-val">{{ $station_apo }}</div>
</td>
<td class="a6-cell-td" style="height:0.97cm;">
<div class="a6-cell-label">ΒΑΡΟΣ(Kgr)</div>
<div class="a6-cell-val">{{ $weight }}</div>
</td>
<td class="a6-cell-td" style="height:0.97cm;">
<div class="a6-cell-label">ΟΓΚΟΜΕΤΡΙΚΟ ΒΑΡΟΣ(Kgr)</div>
<div class="a6-cell-val" style="font-size:7pt;">{{ $volumetric_weight ?? '0.000' }}</div>
</td>
<td class="a6-cell-td" style="height:0.97cm;">
<div class="a6-cell-label">Τεμάχια</div>
<div class="a6-cell-val">{{ $package_label }}</div>
</td>
</tr>
</table>
{{-- ΑΠΟΣΤΟΛΕΑΣ + ΑΙΤΙΑ ΕΠΙΣΤΡΟΦΗΣ --}}
<div class="a6-box" style="top:4.43cm;left:0.10cm;width:7.24cm;height:2.44cm;">
@include('core::shipping.carriers.elta.partials._party-box', [
'title' => 'ΑΠΟΣΤΟΛΕΑΣ',
'lines' => $sender_lines,
])
</div>
<div class="a6-box" style="top:4.43cm;left:7.55cm;width:2.80cm;height:2.44cm;">
<div class="a6-return-title">ΑΙΤΙΑ ΕΠΙΣΤΡΟΦΗΣ</div>
<div style="margin-top:4px;">
<div class="a6-return-item"><span class="a6-checkbox"></span>Άγνωστος</div>
<div class="a6-return-item" style="margin-top:4px;"><span class="a6-checkbox"></span>Ελλειπή Δ/νση</div>
<div class="a6-return-item" style="margin-top:4px;"><span class="a6-checkbox"></span>Απαράδεκτο</div>
<div class="a6-return-item" style="margin-top:4px;"><span class="a6-checkbox"></span>Άλλαξε Δ/νση</div>
<div class="a6-return-item" style="margin-top:4px;"><span class="a6-checkbox"></span>Αζήτητο</div>
</div>
</div>
{{-- ΠΑΡΑΛΗΠΤΗΣ + ΑΝΤΙΚΑΤΑΒΟΛΗ column --}}
<div class="a6-box" style="top:6.97cm;left:0.10cm;width:7.26cm;height:2.78cm;">
@include('core::shipping.carriers.elta.partials._party-box', [
'title' => 'ΠΑΡΑΛΗΠΤΗΣ',
'lines' => $recipient_lines,
])
</div>
<div class="a6-box" style="top:6.97cm;left:7.48cm;width:2.85cm;height:2.79cm;">
@if ($antik_1 ?? null)
<div style="font-size:6pt;">{{ $antik_1 }}</div>
@endif
</div>
{{-- REFERENCE / ΕΠΙΒΑΡΥΝΣΕΙΣ --}}
<div class="a6-box" style="top:9.84cm;left:0.10cm;width:5.81cm;height:0.69cm;">
<div class="a6-small-title">REFERENCE</div>
<div class="a6-small-val" style="margin-top:3px; font-weight:bold;">{{ $order_reference }}</div>
</div>
<div class="a6-box" style="top:9.84cm;left:6.00cm;width:4.37cm;height:1.78cm;">
<div class="a6-small-title">* ΕΠΙΒΑΡΥΝΣΕΙΣ *</div>
</div>
{{-- ΠΑΡΑΤΗΡΗΣΕΙΣ --}}
<div class="a6-box" style="top:10.60cm;left:0.10cm;width:5.82cm;height:1.01cm;">
<div class="a6-small-title">* ΠΑΡΑΤΗΡΗΣΕΙΣ *</div>
</div>
@if ($multiPiece)
<div class="a6-box" style="top:11.68cm;left:0.10cm;width:10.23cm;height:0.54cm;text-align:center;">
<div style="font-weight:bold; font-size:9pt; margin-top:2px;">* ΠΟΛΛΑΠΛΗ ΑΠΟΣΤΟΛΗ * {{ $package_label }}</div>
</div>
@endif
<div class="a6-box" style="top:12.20cm;left:0.11cm;width:10.23cm;height:0.57cm;">
<div class="a6-legal">
Ισχύουν οι Γενικοί Οροι Παραχής Υπηρεσιών οι οποίοι βρίσκονται αναρτημένοι στο www.elta-courier.gr<br>
και είναι διαθέσιμοι σε ολα τα καταστήματα της εταιρίας.
</div>
</div>
@if ($antik_1 ?? null)
<div class="a6-box" style="top:12.70cm;left:0.10cm;width:10.23cm;height:0.51cm;text-align:center;">
<div style="font-weight:bold; font-size:9pt; margin-top:4px;">{{ $antik_1 }}</div>
</div>
@endif
<div class="a6-barcode" style="top:13.20cm;left:0.10cm;width:10.27cm;">
<img src="{{ $barcode_voucher }}" alt="">
</div>
<div class="a6-voucher" style="top:13.95cm;left:0.10cm;width:10.30cm;">{{ $voucher_no }}</div>
</div>
</body>
</html>
@@ -0,0 +1,153 @@
{{--
One "ΣΥΝΟΔΕΥΤΙΚΟ ΔΕΛΤΙΟ ΤΑΧΥΜΕΤΑΦΟΡΑΣ" copy (delivery or sender's),
geometry transcribed directly from ELTA_PEL.sydetaE.rdlc's delivery-
copy band (Top/Left values there are relative to that band; here
they're absolute cm offsets from THIS partial's own 0,0, since the
caller wraps it in a `position:relative` container sized 15.75cm x
8.75cm — the same width/height as the RDLC band, read off its own
rectangle8/rectangle9/rectangle11/rectangle12 extents).
Expects: $copyType ('delivery'|'sender') and all of
EltaLabelRenderer's $data.
--}}
<div class="mc-logo" style="top:0.09cm;left:0.32cm;width:2.65cm;height:1.93cm;">
<img src="{{ $eltaLogo }}" alt="ELTA Courier">
</div>
<div class="mc-company" style="top:0.12cm;left:3.15cm;width:5.19cm;">
<div class="mc-company-name">ΕΛΛΗΝΙΚΑ ΤΑΧΥΔΡΟΜΕΙΑ Α.Ε.</div>
<div>Έδρα: Απελλού 1, 105 51, Αθήνα</div>
<div>Υποκ/μα: Λ. Μεσογείων 395, 153 43, Αγ. Παρασκευή</div>
<div>Α.Φ.Μ.: 094026421 | Δ.Ο.Υ.: ΚΕΦΟ.Δ.Ε. ΑΤΤΙΚΗΣ</div>
<div>Τ. 210-6073000 | Ε. info@elta-courier.gr</div>
<div>www.eltacourier.gr</div>
</div>
<div class="mc-text mc-small" style="top:1.80cm;left:3.15cm;width:5.03cm;">ΕΕΤΤ ΑΜ 99-150 Γενική Αδεια Ταχ/κων Υπηρεσιών</div>
{{-- Title / barcode / voucher, centered column at x=8.52..15.58 --}}
<div class="mc-box" style="top:0.05cm;left:8.52cm;width:7.06cm;height:1.75cm;">
<div class="mc-title" style="top:0.19cm;left:0.05cm;width:6.85cm;">ΣΥΝΟΔΕΥΤΙΚΟ ΔΕΛΤΙΟ ΤΑΧΥΜΕΤΑΦΟΡΑΣ</div>
<div class="mc-barcode" style="top:0.57cm;left:0.24cm;width:6.67cm;">
<img src="{{ $barcode_voucher }}" alt="">
</div>
{{-- The RDLC's own barcode textbox uses the "Free 3 of 9 Extended"
barcode font (literal *{voucher}* text rendered as bars by that
font) — we don't have that font, so we render a real Code 128
barcode image above instead (visually equivalent, actually
scannable), making this second plain-text *{voucher}* line
redundant with it rather than a distinct field. --}}
<div class="mc-voucher" style="top:1.29cm;left:0.24cm;width:6.64cm;">{{ $voucher_no }}</div>
</div>
{{-- 5-cell deposit/destination/weight/pieces/volumetric row — a genuinely
tabular single row of fixed-width cells, so it's built as a real
<table border-collapse:collapse> rather than independently-bordered
absolute divs: that was drawing every shared internal edge twice
(once per adjacent cell), producing a visibly doubled/thickened line
that real printed ELTA labels don't show. The table is positioned via
the same absolute top/left/width the RDLC geometry gives the row as a
whole; each <td> keeps its own RDLC-derived width via <col>. --}}
<table class="mc-cell-row" style="top:2.20cm;left:0.27cm;width:8.44cm;height:0.82cm;">
<colgroup>
<col style="width:1.51cm;"><col style="width:1.67cm;"><col style="width:1.35cm;"><col style="width:1.08cm;"><col style="width:2.83cm;">
</colgroup>
<tr>
<td class="mc-cell-td">
<div class="mc-cell-label">Γρ.Κατάθεσης</div>
<div class="mc-cell-val">{{ $station_apo }}</div>
</td>
<td class="mc-cell-td">
<div class="mc-cell-label">Γρ.Προορισμού</div>
<div class="mc-cell-val">{{ $station_pros }}</div>
</td>
<td class="mc-cell-td">
<div class="mc-cell-label">Βάρος</div>
<div class="mc-cell-val">{{ $weight }}</div>
</td>
<td class="mc-cell-td">
<div class="mc-cell-label">Τεμάχια</div>
<div class="mc-cell-val">{{ $package_label }}</div>
</td>
<td class="mc-cell-td">
<div class="mc-cell-label">Ογκ/κο Βάρος</div>
<div class="mc-cell-val" style="font-size:7pt;">{{ $volumetric_weight ?? '0.000' }}</div>
</td>
</tr>
</table>
<div class="mc-cell mc-cell-service" style="top:2.20cm;left:9.19cm;width:6.19cm;height:0.82cm;">
<div style="position:absolute;top:0.05cm;left:0.32cm;font-size:6.5pt;">ΥΠΗΡΕΣΙΑ :</div>
<div style="position:absolute;top:0.03cm;left:3.21cm;font-size:7.5pt;">{{ $service_code }}</div>
<div style="position:absolute;top:0.42cm;left:0.05cm;width:6.08cm;text-align:center;font-size:7.5pt;">{{ $service_name }}</div>
</div>
{{-- Sender box --}}
<div class="mc-box mc-partybox" style="top:3.12cm;left:0.27cm;width:6.24cm;height:2.53cm;">
<div class="mc-party-title" style="top:0.05cm;left:0.10cm;">ΑΠΟΣΤΟΛΕΑΣ</div>
@foreach ($sender_lines as $i => $line)
@if (trim((string) $line) !== '')
<div class="mc-party-line" style="top:{{ 0.53 + $i * 0.395 }}cm;left:0.05cm;width:6.11cm;">{{ $line }}</div>
@endif
@endforeach
</div>
{{-- Recipient box --}}
<div class="mc-box mc-partybox" style="top:5.76cm;left:0.29cm;width:6.20cm;height:2.68cm;">
<div class="mc-party-title" style="top:0.05cm;left:0.05cm;">ΠΑΡΑΛΗΠΤΗΣ</div>
@foreach ($recipient_lines as $i => $line)
@if (trim((string) $line) !== '')
<div class="mc-party-line" style="top:{{ 0.54 + $i * 0.445 }}cm;left:0.05cm;width:6.11cm;">{{ $line }}</div>
@endif
@endforeach
</div>
{{-- Charge banner + right-column panel stack (ΧΡΕΩΣΗ / Συν.Χρέωσης /
REFERENCE / ΠΑΡΑΤΗΡΗΣΕΙΣ, and the Πρόσθετες Υπηρεσίες column beside
them): these panels sit only a hair apart (~0.09-0.10cm, the RDLC's
own geometry, left untouched), close enough that each pair's two
independent borders read as one thick/doubled line at print
resolution. Each panel below keeps only the border sides it "owns"
on a shared seam (border-top/border-left removed where the
neighbouring panel above/left already draws that same line), so
every seam is drawn exactly once while every panel's outward-facing
sides keep their border. --}}
<div class="mc-box mc-charge" style="top:3.09cm;left:6.60cm;width:8.78cm;height:0.48cm;">{{ str_replace(' ΠΙΣΤΩΣΗ', ' ΤΡ.ΠΛΗΡ: ΠΙΣΤΩΣΗ', $xreosi) }}</div>
<div class="mc-box" style="top:3.66cm;left:6.60cm;width:4.37cm;height:0.44cm;border-top:none;">
<div style="position:absolute;top:0.05cm;left:0.05cm;font-size:7pt;font-weight:bold;">Συν.Χρέωσης (€):</div>
</div>
<div class="mc-box" style="top:3.66cm;left:11.07cm;width:4.31cm;height:2.65cm;border-top:none;">
<div style="position:absolute;top:0.04cm;left:0.05cm;font-size:6pt;">Πρόσθετες Υπηρεσίες</div>
@foreach ([$sur_1 ?? null, $sur_2 ?? null, $sur_3 ?? null, $sur_4 ?? null] as $i => $sur)
@if (!empty($sur))
<div style="position:absolute;top:{{ 0.35 + $i * 0.365 }}cm;left:0.08cm;width:4.18cm;font-size:6pt;">{{ $sur }}</div>
@endif
@endforeach
</div>
<div class="mc-box" style="top:4.20cm;left:6.59cm;width:4.37cm;height:0.67cm;border-top:none;border-right:none;">
<div style="position:absolute;top:0.03cm;left:0.05cm;font-size:6pt;">* REFERENCE No*</div>
<div style="position:absolute;top:0.31cm;left:0.05cm;font-size:6.5pt;">{{ $order_reference }}</div>
</div>
<div class="mc-box" style="top:4.97cm;left:6.60cm;width:4.37cm;height:1.31cm;border-top:none;">
<div style="position:absolute;top:0.03cm;left:0.08cm;font-size:6pt;">* ΠΑΡΑΤΗΡΗΣΕΙΣ *</div>
</div>
{{-- Right-hand signature / print-timestamp column, differs by copy type --}}
@if ($copyType === 'delivery')
<div class="mc-box" style="top:6.37cm;left:6.61cm;width:4.71cm;height:2.07cm;">
<div style="position:absolute;top:0.05cm;left:0.08cm;font-size:6.5pt;">{{ $antik_1 ?? '' }}</div>
</div>
<div class="mc-box" style="top:6.37cm;left:11.48cm;width:3.89cm;height:2.03cm;">
<div style="position:absolute;top:0.08cm;left:0.08cm;font-size:6pt;">ΓΙΑ ΤΗΝ ΠΑΡΑΛΑΒΗ</div>
<div style="position:absolute;top:0.38cm;left:0.08cm;font-size:6pt;">ΟΝΟΜΑ/ΥΠΟΓΡΑΦΗ</div>
<div style="position:absolute;top:1.73cm;left:0.11cm;font-size:6pt;">{{ $date }} {{ $time }}</div>
</div>
@else
<div class="mc-box" style="top:6.37cm;left:6.61cm;width:4.71cm;height:2.07cm;">
<div style="position:absolute;top:0.05cm;left:0.08cm;font-size:6.5pt;">{{ $antik_1 ?? '' }}</div>
</div>
<div class="mc-box" style="top:6.37cm;left:11.48cm;width:3.89cm;height:2.03cm;">
<div style="position:absolute;top:0.08cm;left:0.08cm;font-size:6pt;font-weight:bold;">ΥΠΟΓΡΑΦΗ ΑΠΟΣΤΟΛΕΑ</div>
<div style="position:absolute;top:1.73cm;left:0.11cm;font-size:6pt;">Ημερομηνία - Ωρα Εκτύπωσης: {{ $date }} {{ $time }}</div>
</div>
@endif
@@ -0,0 +1,17 @@
{{--
Sender/recipient box, matching SydetaLabelE.rdlc's rectangle10/
rectangle11 (ΑΠΟΣΤΟΛΕΑΣ/ΠΑΡΑΛΗΠΤΗΣ) layout: a bold title line, then
the RDLC's sender_1..5 / rec_1..5 fields as separate plain text
lines (ELTA's own client pre-wraps the address into these fixed-
width lines server-side, so we render each line separately rather
than reflowing the address ourselves, to match the real line
breaks).
Expects: $title, $lines (array of up to 5 strings).
--}}
<div class="pb-title">{{ $title }}</div>
@foreach ($lines as $line)
@if (trim((string) $line) !== '')
<div class="pb-line">{{ $line }}</div>
@endif
@endforeach
@@ -0,0 +1,120 @@
{{--
Copy 3: the ΤΑΧΥΠΛΗΡΩΜΗ ΕΙΣΠΡΑΞΗ/ΜΕΤΑΒΙΒΑΣΗ payment-receipt stub,
geometry transcribed from sydetaE.rdlc's third band (source Top
values there run ~19.22cm-27.0cm on the full A4 page; offsets below
are relative to this partial's own container, i.e. the RDLC's Top
minus 19.22cm). This band is narrower than copies 1/2 (starts at
x=3.01cm instead of x=0.27cm) — the RDLC leaves its left margin for
the perforated tear line + rotated "3. ΑΡΧΕΙΟ / ΓΡΑΦΕΙΟ ΚΑΤΑΘΕΣΗΣ"
text, reproduced by the caller outside this partial.
Expects all of EltaLabelRenderer's $data.
--}}
<div class="mc-box" style="top:0.00cm;left:6.44cm;width:6.64cm;height:1.15cm;">
<div class="mc-barcode" style="top:0.10cm;left:0.22cm;width:6.27cm;">
<img src="{{ $barcode_voucher }}" alt="">
</div>
<div class="mc-voucher" style="top:0.75cm;left:0.24cm;width:6.14cm;">{{ $voucher_no }}</div>
</div>
<div class="mc-copylabel" style="position:absolute;top:0.10cm;left:0.00cm;width:3.92cm;font-size:8pt;font-weight:bold;">{{ $voucher_no }}</div>
{{-- 4-cell deposit/destination/weight/pieces row: same doubled-border
issue and same fix as _main-copy.blade.php's 5-cell row — a real
<table border-collapse:collapse> in place of 4 independently-bordered
absolute divs, positioned at the row's own RDLC top/left/width. --}}
<table class="mc-cell-row" style="top:1.60cm;left:0.02cm;width:5.61cm;height:0.82cm;">
<colgroup>
<col style="width:1.51cm;"><col style="width:1.67cm;"><col style="width:1.35cm;"><col style="width:1.08cm;">
</colgroup>
{{-- border-bottom:none on every cell: the ΑΠΟΣΤΟΛΕΑΣ party box
immediately below (top:2.44cm, this row ends at 2.42cm) already
draws that shared line with its own border-top. --}}
<tr>
<td class="mc-cell-td" style="border-bottom:none;">
<div class="mc-cell-label">Γρ.Κατάθεσης</div>
<div class="mc-cell-val">{{ $station_apo }}</div>
</td>
<td class="mc-cell-td" style="border-bottom:none;">
<div class="mc-cell-label">Γρ.Προορισμού</div>
<div class="mc-cell-val">{{ $station_pros }}</div>
</td>
<td class="mc-cell-td" style="border-bottom:none;">
<div class="mc-cell-label">Βάρος</div>
<div class="mc-cell-val">{{ $weight }}</div>
</td>
<td class="mc-cell-td" style="border-bottom:none;">
<div class="mc-cell-label">Τεμάχια</div>
<div class="mc-cell-val">{{ $package_label }}</div>
</td>
</tr>
</table>
<div class="mc-box" style="top:1.60cm;left:5.90cm;width:4.87cm;height:0.40cm;">
<div style="position:absolute;top:0.05cm;left:0.13cm;font-size:6pt;">{{ $service_code }}</div>
<div style="position:absolute;top:0.03cm;left:0.85cm;width:3.89cm;font-size:6pt;">{{ $service_name }}</div>
</div>
{{-- Below: several panel pairs sit a hair apart (or, for the
Συν.Χρέωσης/ΕΠΙΒΑΡΥΝΣΕΙΣ pair, even overlap slightly) per the RDLC's
own geometry — left untouched — close enough that two independent
borders read as one doubled/thick line. border-top/border-bottom is
removed from one side of each pair so only the remaining box's
border draws that shared line. --}}
<div class="mc-box mc-charge" style="top:2.02cm;left:5.90cm;width:4.87cm;height:0.37cm;font-size:5.4pt;padding-top:4px;white-space:nowrap;overflow:hidden;border-top:none;">{{ str_replace(' ΠΙΣΤΩΣΗ', ' ΤΡ.ΠΛΗΡ: ΠΙΣΤΩΣΗ', $xreosi) }}</div>
<div class="mc-box mc-partybox" style="top:2.44cm;left:0.00cm;width:5.98cm;height:0.91cm;overflow:hidden;">
<div class="mc-party-title" style="top:0.03cm;left:0.10cm;font-size:6.5pt;">ΑΠΟΣΤΟΛΕΑΣ</div>
@foreach ($sender_lines as $i => $line)
@if (trim((string) $line) !== '')
<div class="mc-party-line" style="top:{{ 0.30 + $i * 0.19 }}cm;left:0.06cm;width:5.85cm;font-size:5.6pt;">{{ $line }}</div>
@endif
@endforeach
</div>
<div class="mc-box" style="top:2.45cm;left:6.06cm;width:4.71cm;height:1.17cm;border-bottom:none;">
<div style="position:absolute;top:0.02cm;left:0.16cm;font-size:6pt;">Συν.Χρέωσης (€):</div>
<div style="position:absolute;top:0.30cm;left:0.16cm;font-size:6pt;">Πρόσθετες Υπηρεσίες</div>
</div>
<div class="mc-box mc-partybox" style="top:3.35cm;left:0.00cm;width:5.97cm;height:1.63cm;overflow:hidden;border-top:none;">
<div class="mc-party-title" style="top:0.06cm;left:0.10cm;font-size:6.5pt;">ΠΑΡΑΛΗΠΤΗΣ</div>
@foreach ($recipient_lines as $i => $line)
@if (trim((string) $line) !== '')
<div class="mc-party-line" style="top:{{ 0.34 + $i * 0.26 }}cm;left:0.10cm;width:5.79cm;font-size:5.6pt;">{{ $line }}</div>
@endif
@endforeach
</div>
<div class="mc-box" style="top:3.28cm;left:6.06cm;width:4.71cm;height:1.41cm;">
<div style="position:absolute;top:0.05cm;left:0.14cm;font-size:6pt;">* ΕΠΙΒΑΡΥΝΣΕΙΣ *</div>
</div>
<div style="position:absolute;top:4.95cm;left:0.05cm;width:8.75cm;font-size:4.6pt;line-height:1.25;white-space:nowrap;">
Έλαβα γνώση των όρων που αναγράφονται στο αντίγραφο 1 και 6 και τους αποδέχομαι ανεπιφύλακτα
</div>
{{-- rectangle29 --}}
<div class="mc-box" style="top:5.46cm;left:0.03cm;width:2.75cm;height:1.66cm;">
<div style="position:absolute;top:0.08cm;left:0.08cm;font-size:6pt;font-weight:bold;">ΥΠΟΓΡΑΦΗ ΑΠΟΣΤΟΛΕΑ</div>
<div style="position:absolute;top:1.35cm;left:0.08cm;font-size:6pt;">{{ $date }}</div>
</div>
{{-- rectangle43 --}}
<div class="mc-box" style="top:5.47cm;left:2.89cm;width:3.76cm;height:1.66cm;">
<div style="position:absolute;top:0.09cm;left:0.08cm;font-size:6pt;font-weight:bold;">ΟΝΟΜΑ/ΥΠΟΓΡΑΦΗ ΠΑΡΑΛΗΠΤΗ</div>
<div style="position:absolute;top:1.35cm;left:0.08cm;font-size:6pt;">Ημ/νία: &nbsp;&nbsp;&nbsp; Ώρα:</div>
</div>
{{-- rectangle44 --}}
<div class="mc-box" style="top:5.48cm;left:6.76cm;width:3.99cm;height:1.68cm;text-align:center;">
<div style="position:absolute;top:0.04cm;left:0;width:100%;font-size:7pt;">Π Ο Σ Ο</div>
<div style="position:absolute;top:0.75cm;left:0;width:100%;font-size:13pt;font-weight:bold;">{{ number_format($cod_amount ?? 0, 2) }}</div>
</div>
{{-- rectangle45: small ELTA-branded legal box; the real RDLC fills it via
its own Image18 bitmap (baked "ΠΟΡΤΑ-ΠΟΡΤΑ ΕΛΛΗΝΙΚΑ ΤΑΧΥΔΡΟΜΕΙΑ..."
legal text + logo graphic) rather than text fields — approximated
here with our own extracted logo + the same legal text as plain
HTML, since we can't extract that embedded bitmap. --}}
<div class="mc-box ps-brandbox-small" style="top:5.55cm;left:10.84cm;width:2.72cm;height:1.61cm;">
<div class="ps-brand-logo-sm"><img src="{{ $eltaLogo }}" alt=""></div>
<div class="ps-legal-sm">
<strong>ΠΟΡΤΑ-ΠΟΡΤΑ</strong><br>
ΕΛΛΗΝΙΚΑ ΤΑΧΥΔΡΟΜΕΙΑ Α.Ε.<br>
ΕΕΤΤ ΑΜ: 99-150
</div>
</div>
@@ -60,7 +60,6 @@ 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', 'Μεταφορικά'],
@@ -191,10 +190,6 @@ 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,7 +28,6 @@ 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 /
@@ -555,8 +554,6 @@ 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
View File
@@ -65,6 +65,7 @@ class CorePlugin implements Plugin
->brandName('Boboko') ->brandName('Boboko')
->brandLogo(asset('static/logos/core/boboko-logo.svg')) ->brandLogo(asset('static/logos/core/boboko-logo.svg'))
->darkModeBrandLogo(asset('static/logos/core/boboko-logo-white.svg')) ->darkModeBrandLogo(asset('static/logos/core/boboko-logo-white.svg'))
->favicon(asset('static/logos/core/favicon.svg'))
->login(Login::class) ->login(Login::class)
->resources([ ->resources([
LanguageLineResource::class, LanguageLineResource::class,
@@ -39,26 +39,8 @@ class TransactionRecorder
* elsewhere in this codebase (see the old, now-removed * elsewhere in this codebase (see the old, now-removed
* TransactionRecorder this replaces). * TransactionRecorder this replaces).
*/ */
/**
* Idempotent on (order_id, type, reference): a successful payment
* outcome can legitimately be reported twice for the same gateway
* reference — e.g. Stripe's pay()/handleCallback() both call
* resultFromIntent() and both dispatch PaymentCaptured once a
* PaymentIntent reaches "succeeded" (checkout's synchronous capture,
* then the webhook confirming the same outcome asynchronously) — so
* this returns the existing row instead of writing a duplicate.
*/
public function record(Order $order, string $type, string $driver, PaymentResult $result): Transaction public function record(Order $order, string $type, string $driver, PaymentResult $result): Transaction
{ {
$existing = $order->transactions()
->where('type', $type)
->where('reference', $result->reference)
->first();
if ($existing !== null) {
return $existing;
}
return $order->transactions()->create([ return $order->transactions()->create([
'success' => $result->status === PaymentResultStatus::Succeeded, 'success' => $result->status === PaymentResultStatus::Succeeded,
'type' => $type, 'type' => $type,
@@ -33,17 +33,6 @@ class StripeWebhookMiddleware
$secret $secret
); );
} catch (UnexpectedValueException|SignatureVerificationException $e) { } catch (UnexpectedValueException|SignatureVerificationException $e) {
\Illuminate\Support\Facades\Log::error('Stripe webhook signature verification failed', [
'signature_header' => $stripeSig,
'secret_prefix' => substr((string) $secret, 0, 12),
'secret_length' => strlen((string) $secret),
'body_length' => strlen($request->getContent()),
'body_sha256' => hash('sha256', $request->getContent()),
'body_raw' => $request->getContent(),
'content_type' => $request->header('Content-Type'),
'content_encoding' => $request->header('Content-Encoding'),
]);
abort(400, $e->getMessage()); abort(400, $e->getMessage());
} }
@@ -24,6 +24,9 @@ use Modules\Core\Shipping\Carriers\Acs\Jobs\WarmAcsAreaCacheJob;
use Modules\Core\Shipping\Carriers\BoxNow\BoxNowClient; use Modules\Core\Shipping\Carriers\BoxNow\BoxNowClient;
use Modules\Core\Shipping\Carriers\BoxNow\BoxNowFulfillmentService; use Modules\Core\Shipping\Carriers\BoxNow\BoxNowFulfillmentService;
use Modules\Core\Shipping\Carriers\BoxNow\BoxNowRateDriver; use Modules\Core\Shipping\Carriers\BoxNow\BoxNowRateDriver;
use Modules\Core\Shipping\Carriers\Elta\EltaClient;
use Modules\Core\Shipping\Carriers\Elta\EltaFulfillmentService;
use Modules\Core\Shipping\Carriers\Elta\EltaRateDriver;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface; use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\Filament\Pages\ManageShippingRates; use Modules\Core\Shipping\Filament\Pages\ManageShippingRates;
use Modules\Core\Shipping\Carriers\StorePickup\StorePickupRateDriver; use Modules\Core\Shipping\Carriers\StorePickup\StorePickupRateDriver;
@@ -39,14 +42,17 @@ class ShippingServiceProvider extends ServiceProvider
{ {
$this->mergeConfigFrom(__DIR__ . '/../../config/shippingCarriers/acs.php', 'acs'); $this->mergeConfigFrom(__DIR__ . '/../../config/shippingCarriers/acs.php', 'acs');
$this->mergeConfigFrom(__DIR__ . '/../../config/shippingCarriers/boxnow.php', 'boxnow'); $this->mergeConfigFrom(__DIR__ . '/../../config/shippingCarriers/boxnow.php', 'boxnow');
$this->mergeConfigFrom(__DIR__ . '/../../config/shippingCarriers/elta.php', 'elta');
$this->app->singleton(AcsClient::class, fn () => new AcsClient(config('acs'))); $this->app->singleton(AcsClient::class, fn () => new AcsClient(config('acs')));
$this->app->singleton(BoxNowClient::class, fn () => new BoxNowClient(config('boxnow'))); $this->app->singleton(BoxNowClient::class, fn () => new BoxNowClient(config('boxnow')));
$this->app->singleton(EltaClient::class, fn () => new EltaClient(config('elta')));
$this->app->bind(CarrierFulfillmentInterface::class, function ($app, array $params) { $this->app->bind(CarrierFulfillmentInterface::class, function ($app, array $params) {
return match ($params['carrier'] ?? null) { return match ($params['carrier'] ?? null) {
'acs' => $app->make(AcsFulfillmentService::class), 'acs' => $app->make(AcsFulfillmentService::class),
'box-now' => $app->make(BoxNowFulfillmentService::class), 'box-now' => $app->make(BoxNowFulfillmentService::class),
'elta' => $app->make(EltaFulfillmentService::class),
default => null, default => null,
}; };
}); });
@@ -67,6 +73,7 @@ class ShippingServiceProvider extends ServiceProvider
$this->publishes([ $this->publishes([
__DIR__ . '/../../config/shippingCarriers/acs.php' => config_path('shippingCarriers/acs.php'), __DIR__ . '/../../config/shippingCarriers/acs.php' => config_path('shippingCarriers/acs.php'),
__DIR__ . '/../../config/shippingCarriers/boxnow.php' => config_path('shippingCarriers/boxnow.php'), __DIR__ . '/../../config/shippingCarriers/boxnow.php' => config_path('shippingCarriers/boxnow.php'),
__DIR__ . '/../../config/shippingCarriers/elta.php' => config_path('shippingCarriers/elta.php'),
], 'core-config'); ], 'core-config');
// Signed-URL auth only, same model as Lunar's own vendor // Signed-URL auth only, same model as Lunar's own vendor
@@ -137,6 +144,7 @@ class ShippingServiceProvider extends ServiceProvider
Shipping::extend('acs', fn ($app) => $app->make(AcsRateDriver::class)); Shipping::extend('acs', fn ($app) => $app->make(AcsRateDriver::class));
Shipping::extend('box-now', fn ($app) => $app->make(BoxNowRateDriver::class)); Shipping::extend('box-now', fn ($app) => $app->make(BoxNowRateDriver::class));
Shipping::extend('store-pickup', fn ($app) => $app->make(StorePickupRateDriver::class)); Shipping::extend('store-pickup', fn ($app) => $app->make(StorePickupRateDriver::class));
Shipping::extend('elta', fn ($app) => $app->make(EltaRateDriver::class));
$this->app->make(ConsoleSchedule::class) $this->app->make(ConsoleSchedule::class)
->job(new WarmAcsAreaCacheJob) ->job(new WarmAcsAreaCacheJob)
@@ -0,0 +1,39 @@
<?php
namespace Modules\Core\Shipping\Carriers\Elta;
use Illuminate\Support\Facades\Cache;
/**
* Resolves a Greek postcode to its ELTA station code/name via PELTKNEW
* (the *NEW-family replacement for the old, differently-authenticated
* PELSTATION — see EltaClient's docblock). Unlike Acs\AreaResolver,
* there's no daily bulk-warm job — PELTKNEW only accepts a single pel_tk
* (postcode), with no fetch-all variant to warm a whole-country cache
* from, so this caches per-postcode on first lookup instead.
*/
class AreaResolver
{
public function __construct(private readonly EltaClient $client) {}
public function resolve(string $postcode): EltaStation
{
return Cache::remember(
"elta.station.{$postcode}",
now()->addDays(30),
function () use ($postcode) {
$response = $this->client->getStation([
'pel_tk' => $postcode,
'sender_station' => '',
])->throwIfError();
$fields = $response->data['pel_fields'] ?? [];
return new EltaStation(
code: $fields['rec_station'] ?? '',
name: $fields['rec_station_t'] ?? '',
);
},
);
}
}
+156
View File
@@ -0,0 +1,156 @@
<?php
namespace Modules\Core\Shipping\Carriers\Elta;
use DOMDocument;
use DOMElement;
use DOMXPath;
use Illuminate\Support\Facades\Http;
use Modules\Core\Shipping\Carriers\Elta\Exceptions\EltaApiException;
/**
* SOAP transport for ELTA Courier's web services.
*
* ELTA runs two parallel operation families at the same endpoint
* (212.205.47.226:9003). The original CREATEAWB/PELB64VG/PELTT01/PELSTATION
* set (sourced from the unofficial chinchillabrains/eltaws reference client)
* rejects this account's credentials with st_flag=3 ("invalid password").
* Decompiling ELTA's own official desktop client (ELTA_PEL.exe, from their
* public .msi installer) showed it never calls CREATEAWB at all — it
* exclusively uses a newer "*NEW" family (PELVGNEW, PELLOGINNEW,
* PELTTNEW01, PELTKNEW, PELVGDEL, PELPARALVGNEW1). PELVGNEW was
* live-verified directly against the production endpoint with this same
* account and succeeded (st_flag=0, real voucher number returned) — it
* requires no password at all, just the account/user code pair.
*
* No .wsdl files exist for the *NEW family (only reconstructed from the
* decompiled C# proxy classes' method signatures). PHP's SoapClient in
* non-WSDL mode refuses to serialize these calls at all (SoapFault "Error
* in client request message" regardless of style/use/SoapVar wrapping —
* this legacy Micro Focus/CICS gateway's document/literal shape doesn't
* match what ext-soap's non-WSDL client is willing to build), so *NEW
* operations are sent as hand-built SOAP 1.1 envelopes over plain HTTP —
* proven directly against the live endpoint — with the response parsed
* back via DOMDocument/DOMXPath.
*
* The old family's label operation (PELB64VG) is deliberately NOT
* exposed here — it fails with the same st_flag=3 as CREATEAWB (same
* legacy family), and has no *NEW replacement since ELTA's own client
* renders labels locally instead of fetching them (see
* EltaLabelRenderer).
*/
class EltaClient
{
public function __construct(private readonly array $config) {}
public function login(array $params): EltaResponse
{
return $this->call('PELLOGINNEW', $params);
}
public function createVoucher(array $params): EltaResponse
{
return $this->call('PELVGNEW', $params);
}
public function listVouchers(array $params): EltaResponse
{
return $this->call('PELPARALVGNEW1', $params);
}
public function deleteVoucher(array $params): EltaResponse
{
return $this->call('PELVGDEL', $params);
}
public function getTracking(array $params): EltaResponse
{
return $this->call('PELTTNEW01', $params);
}
public function getStation(array $params): EltaResponse
{
return $this->call('PELTKNEW', $params);
}
private function call(string $operation, array $params): EltaResponse
{
$namespace = '/'.$operation;
$body = '';
foreach ($params as $key => $value) {
$body .= '<cre:'.$key.'>'.htmlspecialchars((string) $value, ENT_XML1).'</cre:'.$key.'>';
}
$envelope = '<?xml version="1.0" encoding="UTF-8"?>'
.'<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:cre="'.$namespace.'">'
.'<soapenv:Header/><soapenv:Body><cre:READ>'.$body.'</cre:READ></soapenv:Body></soapenv:Envelope>';
$response = Http::withHeaders([
'SOAPAction' => 'Get',
'Content-Type' => 'text/xml',
])
->timeout($this->config['timeout'])
->withBody($envelope, 'text/xml')
->post($this->config['service_location']);
if ($response->failed()) {
throw new EltaApiException('ELTA request failed: HTTP '.$response->status());
}
return EltaResponse::fromSoapResult($this->parseEnvelope($response->body()));
}
private function parseEnvelope(string $xml): array
{
$dom = new DOMDocument();
if (! $dom->loadXML($xml)) {
throw new EltaApiException('ELTA returned a response that could not be parsed as XML.');
}
$xpath = new DOMXPath($dom);
$xpath->registerNamespace('soapenv', 'http://schemas.xmlsoap.org/soap/envelope/');
$readResponse = $xpath->query('//soapenv:Body/*')->item(0);
if (! $readResponse instanceof DOMElement) {
throw new EltaApiException('ELTA response did not contain a SOAP body.');
}
return $this->nodeToArray($readResponse);
}
private function nodeToArray(DOMElement $element): array
{
$data = [];
foreach ($element->childNodes as $child) {
if (! $child instanceof DOMElement) {
continue;
}
$name = $child->localName;
$hasElementChildren = false;
foreach ($child->childNodes as $grandchild) {
if ($grandchild instanceof DOMElement) {
$hasElementChildren = true;
break;
}
}
$value = $hasElementChildren ? $this->nodeToArray($child) : trim($child->textContent);
if (array_key_exists($name, $data)) {
if (! is_array($data[$name]) || ! array_is_list($data[$name])) {
$data[$name] = [$data[$name]];
}
$data[$name][] = $value;
} else {
$data[$name] = $value;
}
}
return $data;
}
}
@@ -0,0 +1,249 @@
<?php
namespace Modules\Core\Shipping\Carriers\Elta;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Lunar\Models\Order;
use Modules\Core\Shipping\Carriers\Elta\Exceptions\EltaApiException;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\Contracts\SupportsTracking;
use Modules\Core\Shipping\DTOs\ShipmentRequest;
use Modules\Core\Shipping\DTOs\TrackingCheckpoint;
use Modules\Core\Shipping\Enums\TrackingStatus;
use Modules\Core\Shipping\Models\Shipment;
/**
* Not SupportsManifestBatching — no manifest/pickup-list operation was
* found in either ELTA operation family, unlike ACS's
* ACS_Issue_Pickup_List. Same shape as Box Now, which has no manifest
* step either.
*/
class EltaFulfillmentService implements CarrierFulfillmentInterface, SupportsTracking
{
public function __construct(
private readonly EltaClient $client,
private readonly EltaLabelRenderer $labelRenderer,
) {}
public function createShipment(Order $order, ShipmentRequest $request): Shipment
{
$address = $order->shippingAddress;
$weight = $request->weight ?? 0.5;
$params = [
'pel_apost_code' => config('elta.apost_code'),
'pel_paral_name' => trim("{$address->first_name} {$address->last_name}"),
'pel_paral_address' => $address->line_one,
'pel_paral_area' => $address->city,
'pel_paral_tk' => $address->postcode,
'pel_paral_thl_1' => $address->contact_phone,
'pel_paral_thl_2' => '',
'pel_service' => '',
'pel_baros' => number_format($weight, 3, '.', ''),
'pel_baros_xyz' => '',
'pel_x' => '',
'pel_y' => '',
'pel_z' => '',
'pel_temaxia' => (string) $request->packageCount,
'pel_paral_sxolia' => '',
'pel_sur_1' => '',
'pel_sur_2' => '',
'pel_sur_3' => '',
'pel_ant_poso' => '',
'pel_ant_poso1' => '',
'pel_ant_poso2' => '',
'pel_ant_poso3' => '',
'pel_ant_poso4' => '',
'pel_ant_date1' => '',
'pel_ant_date2' => '',
'pel_ant_date3' => '',
'pel_ant_date4' => '',
'pel_asf_poso' => '',
'pel_user' => config('elta.user_code'),
'pel_ref_no' => (string) $order->id,
'pel_insert_flag' => '',
'pel_paral_code' => '',
'pel_retur_code' => '',
];
$codAmount = null;
if ($request->paymentMode === 'cod') {
$codAmount = $request->amountToCollect ?? $order->total->decimal;
$params['pel_ant_poso'] = number_format($codAmount, 2, '.', '');
}
$response = $this->client->createVoucher($params)->throwIfError();
$voucherNo = (string) $response->data['vg_code'];
$shipment = Shipment::create([
'order_id' => $order->id,
'carrier' => 'elta',
'tracking_reference' => $voucherNo,
'meta' => [
'weight' => $weight,
'ocr_line' => $response->data['ocr_line'] ?? null,
'date_time' => $response->data['date_time'] ?? null,
'package_count' => $request->packageCount,
'cod_amount' => $codAmount,
],
]);
// PELVGNEW's own response already carries every child voucher
// number for a multi-piece shipment in vg_child_no — no second
// request needed, same as the old CREATEAWB/vg_child shape.
foreach (array_filter((array) ($response->data['vg_child_no'] ?? [])) as $childVoucherNo) {
Shipment::create([
'order_id' => $order->id,
'carrier' => 'elta',
'tracking_reference' => $childVoucherNo,
'parent_reference' => $voucherNo,
'meta' => $shipment->meta?->toArray() ?? [],
]);
}
return $shipment;
}
/**
* PELB64VG (the old, deprecated operation this used to call) fails
* with the same st_flag=3 as CREATEAWB — it's the same legacy family.
* There's no *NEW replacement: ELTA's own official client renders
* labels entirely locally from a bundled report template, never
* fetching one from the server at all (see EltaLabelRenderer's
* docblock). This reproduces that local rendering instead.
*/
public function printLabel(Shipment $shipment): string
{
$pdf = $this->labelRenderer->render($shipment);
$shipment->update(['label_printed_at' => now()]);
return $pdf;
}
/**
* Unlike the old, deprecated operation set (which genuinely has no
* delete operation), the *NEW family does support cancellation via
* PELVGDEL — but it identifies a voucher by its internal pel_vg_id,
* not the AWB/tracking number PELVGNEW returns (vg_code). Getting
* pel_vg_id requires first listing the account's vouchers via
* PELPARALVGNEW1 and matching the row whose pel_paral_vg equals our
* tracking_reference.
*/
public function cancelShipment(Shipment $shipment): void
{
$voucherId = $this->findVoucherId($shipment->tracking_reference);
if ($voucherId === null) {
throw new EltaApiException(
'Could not find voucher '.$shipment->tracking_reference.' in ELTA\'s voucher list — it may already be too old or cancelled to look up.',
);
}
$this->client->deleteVoucher(['pel_vg_id' => $voucherId])->throwIfError();
$shipment->update(['cancelled_at' => now()]);
}
private function findVoucherId(string $trackingReference): ?string
{
$inId = '0';
// PELPARALVGNEW1 paginates via in_id, same cursor pattern the
// official client uses — walk pages until the voucher is found
// or a page comes back empty. flag_1/flag_2 map to the real
// client's "Show All"/"All Users" checkboxes — live-confirmed
// both must be '1', otherwise even a voucher created moments ago
// is filtered out of the (default, unfiltered-looking) list.
for ($page = 0; $page < 50; $page++) {
$response = $this->client->listVouchers([
'pel_code' => config('elta.apost_code'),
'pel_user_code' => config('elta.user_code'),
'flag_1' => '1',
'flag_2' => '1',
'in_id' => $inId,
])->throwIfError();
$rows = array_filter(
(array) ($response->data['vg_rec'] ?? []),
fn (array $row) => filled($row['pel_vg_id'] ?? null),
);
if ($rows === []) {
return null;
}
$match = collect($rows)->first(
fn (array $row) => ($row['pel_paral_vg'] ?? null) === $trackingReference,
);
if ($match !== null) {
return $match['pel_vg_id'];
}
$inId = (string) end($rows)['pel_vg_id'];
}
return null;
}
public function trackShipment(Shipment $shipment): Collection
{
$response = $this->client->getTracking([
'web_vg' => $shipment->tracking_reference,
'pel_code' => config('elta.apost_code'),
])->throwIfError();
$rows = array_filter(
(array) ($response->data['web_status'] ?? []),
fn (array $row) => filled($row['web_date_time'] ?? null),
);
// No explicit delivered-confirmation field like the old
// PELTT01's pod_name — pel_rec.a_rec_date_time is blank until
// delivery per the real client's own rendering, used as the
// delivered signal here; unconfirmed against a genuinely
// delivered parcel yet.
$isDelivered = filled(trim($response->data['pel_rec']['a_rec_date_time'] ?? '', ' /:'));
return collect(array_values($rows))->map(function (array $row, int $index) use ($rows, $isDelivered) {
$isLast = $index === count($rows) - 1;
$dateTime = $row['web_date_time'] ?? '';
return new TrackingCheckpoint(
status: $isLast && $isDelivered
? TrackingStatus::Delivered
: $this->guessStatusFromTitle($row['web_status_name'] ?? ''),
carrierStatus: $row['web_status_name'] ?? null,
message: $row['web_sxolia'] ?: ($row['web_status_name'] ?? null),
location: $row['web_station'] ?? null,
occurredAt: Carbon::createFromFormat('YmdHi', substr($dateTime, 0, 12)),
meta: $row,
);
});
}
/**
* web_status_name is free text with no structured status code — same
* limitation Acs\AcsFulfillmentService::guessStatusFromAction() has.
* Live-confirmed text seen so far: "ΔΗΜΙΟΥΡΓΙΑ ΣΥ.ΔΕ.ΤΑ. ΑΠΟ ΠΕΛΑΤΗ"
* (voucher created) — matched via a Pending-ish fallback below since
* it isn't yet in transit. Other statuses are best-guess substring
* matches to refine as more real tracking text is observed.
*/
private function guessStatusFromTitle(string $title): TrackingStatus
{
$title = mb_strtolower($title);
return match (true) {
str_contains($title, 'παραδόθηκε') => TrackingStatus::Delivered,
str_contains($title, 'διανομή') => TrackingStatus::OutForDelivery,
str_contains($title, 'παραλαβή') => TrackingStatus::CollectedFromSender,
str_contains($title, 'μεταφορά') || str_contains($title, 'διαμετακόμιση') => TrackingStatus::InTransit,
default => TrackingStatus::Pending,
};
}
}
@@ -0,0 +1,184 @@
<?php
namespace Modules\Core\Shipping\Carriers\Elta;
use Barryvdh\DomPDF\Facade\Pdf;
use Illuminate\Support\Carbon;
use Modules\Core\Shipping\Models\Shipment;
use Picqer\Barcode\BarcodeGeneratorPNG;
/**
* Renders an ELTA shipping label as a PDF ourselves — there is no *NEW
* operation for fetching one (see EltaFulfillmentService::printLabel()'s
* docblock), so this reproduces ELTA's own official label design instead
* of a server-fetched file.
*
* Field layout/text is traced directly from ELTA's own 2024 Customer
* Client Installation Manual, which includes real sample images of both
* the A4 voucher (3 stacked copies: delivery copy, sender's copy, a
* ΤΑΧΥΠΛΗΡΩΜΗ payment-receipt stub) and the A6 thermal label (single
* copy, with a return-reason checkbox list the A4 version doesn't have)
* — not from decompiled client code, which an earlier version of this
* class was built from and turned out to look nothing like a real
* label. The barcode is Code 128 (single barcode per label, matching
* both real samples) encoding the voucher number itself — an earlier
* version guessed Code 39 with a second "OCR" barcode from misreading
* decompiled code; neither survived comparison against the real
* samples.
*/
class EltaLabelRenderer
{
public function __construct(private readonly AreaResolver $areaResolver) {}
public function render(Shipment $shipment): string
{
$order = $shipment->order;
$address = $order->shippingAddress;
$meta = $shipment->meta?->toArray() ?? [];
$destinationStation = $this->areaResolver->resolve($address->postcode);
$dateTime = (string) ($meta['date_time'] ?? '');
$occurredAt = strlen($dateTime) >= 12
? Carbon::createFromFormat('YmdHi', substr($dateTime, 0, 12))
: now();
$voucherNo = $shipment->tracking_reference;
$ocrLine = (string) ($meta['ocr_line'] ?? '');
$packageCount = (int) ($meta['package_count'] ?? 1);
$generator = new BarcodeGeneratorPNG();
$data = [
'sender_name' => config('elta.sender_name'),
'sender_address' => config('elta.sender_address'),
'sender_postcode' => config('elta.sender_postcode'),
'sender_area' => config('elta.sender_area'),
'sender_phone' => config('elta.sender_phone'),
'apost_code' => config('elta.apost_code'),
'recipient_name' => trim("{$address->first_name} {$address->last_name}"),
'recipient_address' => $address->line_one,
'recipient_postcode' => $address->postcode,
'recipient_area' => $address->city,
'recipient_phone' => $address->contact_phone,
// Mirrors Sydeta.cs::print_vg()'s own dataRow["sender_1..5"] /
// dataRow["rec_1..5"] construction: name on its own line, then
// address, then "TK:<postcode> <area> ΤΗΛ:<phone>" — each of
// the RDLC's rectangle8/rectangle9 boxes renders these as 3
// separate textbox lines (not 5; sender_2/sender_3 and
// rec_2/rec_3 only appear when a line's text exceeds the
// report's 40-char wrap width, which our own data never hits
// in practice for a name/address/contact triple).
'sender_lines' => [
config('elta.sender_name'),
config('elta.sender_address'),
'TK:'.config('elta.sender_postcode').' '.config('elta.sender_area').' ΤΗΛ:'.config('elta.sender_phone'),
],
'recipient_lines' => [
trim("{$address->first_name} {$address->last_name}"),
$address->line_one,
'TK:'.$address->postcode.' '.$address->city,
'ΤΗΛ: '.$address->contact_phone,
],
'date' => $occurredAt->format('d/m/Y'),
'time' => $occurredAt->format('H:i'),
'voucher_no' => $voucherNo,
'barcode_voucher' => $this->barcodeDataUri($generator, $voucherNo),
'weight' => $meta['weight'] ?? null,
'package_count' => $packageCount,
'package_label' => $packageCount > 1
? sprintf('001/%03d', $packageCount)
: '001',
'multiPiece' => $packageCount > 1,
'station_apo' => config('elta.origin_station_code'),
'station_pros' => $destinationStation->code,
'station_pros_title' => $destinationStation->name,
'service_code' => '214',
'service_name' => 'ΠΟΛΗ ΠΟΛΗ -ΠΘ- ΝΗΣΙ',
'xreosi' => 'ΧΡΕΩΣΗ ΑΠΟΣΤΟΛΕΑ ΠΙΣΤΩΣΗ',
'siimvasi' => '131775-9',
'cod_amount' => $meta['cod_amount'] ?? null,
'ocr_line' => $ocrLine,
// The A6 layout's 3-cell row shows a 13-digit reference derived
// from the OCR line, not the barcode's own voucher text —
// confirmed against the real sample: ">14260009814363<..."
// yields reference "1426000981436" (13 chars after the '>').
'ocr_reference' => strlen($ocrLine) >= 14 ? substr($ocrLine, 1, 13) : '',
'order_reference' => (string) $order->id,
// sydetaE.rdlc's "Πρόσθετες Υπηρεσίες" (sur_1..4) and the
// ΑΝΤΙΚΑΤΑΒΟΛΗ banner (antik_1) are only populated by ELTA's
// own client when the shipper ticks special-service checkboxes
// (Sydeta.cs::print_vg()'s checkBox2-5) or a COD amount is set
// — neither is tracked on our Shipment model today, so these
// render as empty boxes, matching what a plain "credit" send
// with no extras or COD looks like on a real label.
'sur_1' => null,
'sur_2' => null,
'sur_3' => null,
'sur_4' => null,
'antik_1' => $meta['cod_amount'] ?? null ? '* ΠΡΟΣΟΧΗ ΑΝΤΙΚΑΤΑΒΟΛΗ *' : null,
'volumetric_weight' => $meta['volumetric_weight'] ?? null,
'eltaLogo' => $this->logoDataUri(),
];
$paperSize = config('elta.label_paper_size', 'a4');
$view = $paperSize === 'a6'
? 'core::shipping.carriers.elta.label-a6'
: 'core::shipping.carriers.elta.label-a4';
$pdf = Pdf::loadView($view, $data);
// A6 = 104mm x 148mm, per RDLCPrinter.cs's own DeviceInfo
// override for printer_size==2 (1mm ≈ 2.8346pt) — note this is
// ELTA's own thermal-label size, not the ISO A6 (105x148mm) the
// old comment here named. A 4-value paper array plus an
// orientation string together confuse dompdf into doubling the
// canvas and silently overflowing to a second blank page
// (reproduced with a bare empty page); the array alone is
// already portrait (height > width), so the orientation
// argument is both redundant and the actual bug.
$paperSize === 'a6'
? $pdf->setPaper([0, 0, 294.80, 419.53])
: $pdf->setPaper('a4');
return $pdf->output();
}
/**
* PNG over SVG — dompdf's inline <svg> support doesn't reliably
* render the barcode generator's <rect>-based bars (confirmed: the
* SVG output rendered as literal text in a live test), while a
* base64 PNG <img> is unambiguous.
*/
private function barcodeDataUri(BarcodeGeneratorPNG $generator, string $payload): string
{
$png = $generator->getBarcode($payload, BarcodeGeneratorPNG::TYPE_CODE_128);
return 'data:image/png;base64,'.base64_encode($png);
}
/**
* Embedded as a base64 data URI rather than referenced by URL —
* resources/logos/ is only copied into a consuming app's public/ on
* vendor:publish, which isn't guaranteed to have run, and dompdf
* needs either a real filesystem path or a working absolute URL.
*/
private function logoDataUri(): string
{
$path = __DIR__.'/../../../../resources/logos/elta-courier-logo.png';
return 'data:image/png;base64,'.base64_encode(file_get_contents($path));
}
}
@@ -0,0 +1,70 @@
<?php
namespace Modules\Core\Shipping\Carriers\Elta;
use Lunar\DataTypes\ShippingOption;
use Lunar\Shipping\DataTransferObjects\ShippingOptionRequest;
use Lunar\Shipping\Interfaces\ShippingRateInterface;
use Lunar\Shipping\Models\ShippingRate;
use Modules\Core\Shipping\Concerns\ExcludesRestrictedProducts;
use Modules\Core\Shipping\Concerns\ResolvesFixedPricing;
use Modules\Core\Shipping\Contracts\DeclaresFulfillmentType;
use Modules\Core\Shipping\Contracts\SupportsCashCollection;
/**
* None of ELTA's four SOAP operations (create/label/track/station) expose
* a price-quote/calculation call, so — like Box Now — this always
* resolves the method's own charge_by + price-break configuration, never
* live pricing. Does not implement SupportsLivePricing.
*
* Implements SupportsCashCollection: an ELTA courier hands a parcel to
* the recipient in person, the same fact as AcsRateDriver, unlike Box
* Now's unattended lockers.
*/
class EltaRateDriver implements ShippingRateInterface, DeclaresFulfillmentType, SupportsCashCollection
{
use ResolvesFixedPricing;
use ExcludesRestrictedProducts;
public ShippingRate $shippingRate;
public function name(): string
{
return 'ELTA Courier';
}
public function fulfillmentType(): string
{
return 'carrier';
}
public function collectsCash(): bool
{
return true;
}
public function description(): string
{
return 'Delivery via ELTA Courier.';
}
public function resolve(ShippingOptionRequest $shippingOptionRequest): ?ShippingOption
{
if ($this->cartHasExcludedProducts($shippingOptionRequest->shippingRate, $shippingOptionRequest->cart)) {
return null;
}
return $this->resolveFixedPrice(
$shippingOptionRequest->shippingRate,
$shippingOptionRequest->shippingRate->shippingMethod,
$shippingOptionRequest->cart,
);
}
public function on(ShippingRate $shippingRate): self
{
$this->shippingRate = $shippingRate;
return $this;
}
}
@@ -0,0 +1,43 @@
<?php
namespace Modules\Core\Shipping\Carriers\Elta;
use Modules\Core\Shipping\Carriers\Elta\Exceptions\EltaApiException;
/**
* Normalizes a SoapClient::READ() stdClass result — every ELTA operation's
* response carries the same st_flag (0=success, 1-99=error)/st_title
* shape, live-verified against the real production endpoint (see
* EltaClient's own docblock). Mirrors Acs\AcsResponse's hasError/data
* shape so the rest of the integration (rate driver, fulfillment service)
* reads identically regardless of transport (SOAP here vs. ACS's JSON).
*/
class EltaResponse
{
private function __construct(
public readonly bool $hasError,
public readonly ?string $errorMessage,
public readonly array $data,
) {}
public static function fromSoapResult(mixed $result): self
{
$data = json_decode(json_encode($result), true) ?? [];
$flag = (int) ($data['st_flag'] ?? 0);
return new self(
hasError: $flag !== 0,
errorMessage: $flag !== 0 ? ($data['st_title'] ?? 'Unknown ELTA error') : null,
data: $data,
);
}
public function throwIfError(): self
{
if ($this->hasError) {
throw new EltaApiException($this->errorMessage ?? 'Unknown ELTA API error', $this->data);
}
return $this;
}
}
@@ -0,0 +1,11 @@
<?php
namespace Modules\Core\Shipping\Carriers\Elta;
class EltaStation
{
public function __construct(
public readonly string $code,
public readonly string $name,
) {}
}
@@ -0,0 +1,14 @@
<?php
namespace Modules\Core\Shipping\Carriers\Elta\Exceptions;
use RuntimeException;
use Throwable;
class EltaApiException extends RuntimeException
{
public function __construct(string $message, public readonly array $data = [], ?Throwable $previous = null)
{
parent::__construct($message, previous: $previous);
}
}
+3 -27
View File
@@ -8,7 +8,6 @@ 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;
@@ -40,8 +39,8 @@ class StoreDetailsService
} }
/** /**
* Null for any non-bank-transfer order — the confirmation email and page * Null for any non-bank-transfer order — the confirmation email only
* only show this block when there's actually a wire to send (see * shows 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
@@ -67,30 +66,7 @@ class StoreDetailsService
return null; return null;
} }
return $this->fillOrderReference( return RichContentRenderer::make($content)->toHtml();
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