Merge branch 'cart-temp' into box-now-test-

This commit is contained in:
2026-09-25 21:49:30 +03:00
131 changed files with 4559 additions and 2688 deletions
+1 -1
View File
@@ -331,7 +331,7 @@ @layer components {
transition: transform 0.35s cubic-bezier(0.2, 0.78, 0.12, 0.86);
}
.btn-primary:hover::after {
.btn-primary:not(:disabled):hover::after {
transform: translate(0, 0);
}
+91 -35
View File
@@ -200,8 +200,51 @@ .bbk-cart-item-media img {
.bbk-cart-item-detail { min-width: 0; }
.bbk-cart-item-title {
display: block;
margin: 0 0 0.25rem;
font-weight: 600;
color: inherit;
text-decoration: none;
}
a.bbk-cart-item-title:hover { text-decoration: underline; }
.bbk-cart-item-variant {
margin: 0 0 0.25rem;
font-size: 0.8125rem;
color: var(--bbk-color-muted);
}
/* A line's custom-field answers (checkout::partials.line-custom-fields). */
.bbk-line-fields {
display: grid;
gap: 0.25rem;
margin: 0 0 0.5rem;
font-size: 0.8125rem;
}
.bbk-line-field dt {
color: var(--bbk-color-muted);
}
.bbk-line-field dd {
margin: 0;
white-space: pre-line;
overflow-wrap: anywhere;
}
.bbk-line-field-file {
display: inline-flex;
align-items: center;
gap: 0.5rem;
color: inherit;
}
.bbk-line-field-file img {
width: 40px;
height: 40px;
object-fit: cover;
border-radius: 0;
}
.bbk-cart-item-unit {
@@ -361,6 +404,19 @@ .bbk-cart-coupon-error {
color: var(--bbk-color-danger);
}
.bbk-cart-error {
margin: 0;
padding: 0.75rem 1.5rem 0;
font-size: 0.8125rem;
color: var(--bbk-color-danger);
}
.bbk-add-to-cart-error {
margin: 0.375rem 0 0;
font-size: 0.8125rem;
color: var(--bbk-color-danger);
}
.bbk-cart-checkout {
display: block;
width: 100%;
@@ -448,35 +504,12 @@ .bbk-checkout-note {
font-size: 0.875rem;
}
/* Contact tabs */
/* Contact: "logged in as" line, or the guest login prompt */
.bbk-checkout-tabs { display: flex; flex-direction: column; gap: 1rem; }
.bbk-checkout-logged-in,
.bbk-checkout-login-prompt { margin: 0; }
.bbk-checkout-tab {
display: inline-flex;
width: fit-content;
margin-right: 0.5rem;
padding: 0.5rem 1rem;
border: 1px solid var(--bbk-color-border);
border-radius: var(--bbk-radius-sm);
background: var(--bbk-color-bg);
font: inherit;
font-weight: 600;
color: var(--bbk-color-muted);
cursor: pointer;
transition: background-color 0.15s ease, color 0.15s ease;
}
.bbk-checkout-tab[aria-selected="true"] {
background: var(--bbk-color-text);
border-color: var(--bbk-color-text);
color: var(--bbk-color-bg);
}
.bbk-checkout-tab:focus-visible {
outline: 2px solid var(--bbk-color-accent);
outline-offset: 2px;
}
.bbk-checkout-login-prompt a { color: inherit; font-weight: 600; }
/* Fields */
@@ -546,6 +579,12 @@ .bbk-checkbox {
}
/* For a full-sentence label that can wrap — align the box to the first line. */
/* "I want an invoice": company/ΑΦΜ only while ticked */
.bbk-invoice { display: flex; flex-direction: column; gap: 1rem; }
.bbk-invoice:not(:has(input[name="wants_invoice"]:checked)) .bbk-invoice-fields { display: none; }
.bbk-checkbox--stacked {
display: flex;
align-items: flex-start;
@@ -765,6 +804,8 @@ .bbk-checkout-continue {
font: inherit;
font-weight: 600;
text-align: center;
text-decoration: none;
box-sizing: border-box;
cursor: pointer;
transition: opacity 0.15s ease;
}
@@ -882,6 +923,23 @@ .bbk-confirmation-heading {
.bbk-confirmation-ref { margin: 0 0 0.25rem; }
.bbk-confirmation-meta {
margin: 0 0 1rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.bbk-confirmation-meta-row {
display: flex;
justify-content: space-between;
gap: 1rem;
font-size: 0.9375rem;
}
.bbk-confirmation-meta-row dt { color: var(--bbk-color-muted); }
.bbk-confirmation-meta-row dd { margin: 0; font-weight: 600; }
.bbk-confirmation-body {
margin: 2rem 0;
display: grid;
@@ -899,11 +957,14 @@ .bbk-confirmation-lines {
}
.bbk-confirmation-line {
display: flex;
justify-content: space-between;
gap: 1rem;
display: grid;
grid-template-columns: 72px 1fr auto;
align-items: start;
gap: 0.875rem;
}
.bbk-confirmation-line-detail { min-width: 0; }
.bbk-confirmation-line-qty { color: var(--bbk-color-muted); }
.bbk-confirmation-lines .bbk-cart-summary { margin-top: 0.75rem; }
@@ -928,8 +989,3 @@ .bbk-address-lines {
font-size: 0.875rem;
color: var(--bbk-color-muted);
}
.bbk-confirmation-continue {
max-width: 280px;
text-decoration: none;
}
@@ -6,12 +6,15 @@ import { csrfToken } from './csrf'
// `bbk-cart:changed` window event. No DOM building here — the drawer
// (bbk-cart-controller) owns rendering.
export default class extends Controller {
static targets = ['error']
async add(event) {
event.preventDefault()
const form = this.element
const submit = form.querySelector('[type="submit"]')
this.clearError()
form.setAttribute('data-bbk-add-to-cart-state', 'loading')
if (submit) submit.disabled = true
@@ -21,11 +24,16 @@ export default class extends Controller {
headers: {
'X-CSRF-TOKEN': csrfToken(),
'X-Requested-With': 'XMLHttpRequest',
Accept: 'application/json',
},
body: new FormData(form),
})
if (!response.ok) return
if (!response.ok) {
const data = await response.json().catch(() => null)
this.showError(data?.error)
return
}
window.dispatchEvent(new CustomEvent('bbk-cart:changed', {
detail: { html: await response.text() },
@@ -35,4 +43,15 @@ export default class extends Controller {
if (submit) submit.disabled = false
}
}
showError(message) {
if (!this.hasErrorTarget || !message) return
this.errorTarget.textContent = message
this.errorTarget.hidden = false
}
clearError() {
if (!this.hasErrorTarget) return
this.errorTarget.hidden = true
}
}
+52 -4
View File
@@ -13,11 +13,12 @@ import { csrfToken } from './csrf'
// Appearance is entirely CSS-driven: open state is the data-bbk-cart-state
// attribute on the root, nothing here touches styles or class lists.
export default class extends Controller {
static targets = ['panel', 'body']
static targets = ['panel', 'body', 'error']
connect() {
this.onChanged = this.onChanged.bind(this)
this.onKeydown = this.onKeydown.bind(this)
this.updateTimers = new Map() // line id -> pending debounce timer
window.addEventListener('bbk-cart:changed', this.onChanged)
window.addEventListener('bbk-cart:open', this.open.bind(this))
@@ -30,6 +31,7 @@ export default class extends Controller {
disconnect() {
window.removeEventListener('bbk-cart:changed', this.onChanged)
document.removeEventListener('keydown', this.onKeydown)
this.updateTimers.forEach((timer) => clearTimeout(timer))
}
onChanged(event) {
@@ -63,7 +65,11 @@ export default class extends Controller {
submit(event) {
event.preventDefault()
const form = event.target.closest('form')
if (form) this.send(form)
if (!form) return
// A remove is a deliberate, one-shot action — only the quantity form
// (typing, or the +/- stepper below) benefits from debouncing.
form.classList.contains('bbk-cart-qty') ? this.scheduleSend(form) : this.send(form)
}
// +/- stepper buttons inside a line
@@ -73,11 +79,27 @@ export default class extends Controller {
const input = form.querySelector('input[type="number"]')
const next = Math.max(0, parseInt(input.value || '0', 10) + Number(event.params.dir))
input.value = String(next)
this.send(form)
this.scheduleSend(form)
}
// Repeated clicks (or spinner nudges) update the input instantly but only
// send once they settle for 300ms — sending on every single click was
// firing overlapping requests that raced each other and made the drawer
// visibly flicker/lag under quick clicking.
scheduleSend(form) {
const lineId = form.closest('[data-bbk-line-id]')?.dataset.bbkLineId
if (!lineId) return this.send(form)
clearTimeout(this.updateTimers.get(lineId))
this.updateTimers.set(lineId, setTimeout(() => {
this.updateTimers.delete(lineId)
this.send(form)
}, 300))
}
async send(form) {
this.bodyTarget.setAttribute('aria-busy', 'true')
this.clearError()
try {
const response = await fetch(form.action, {
@@ -85,16 +107,42 @@ export default class extends Controller {
headers: {
'X-CSRF-TOKEN': csrfToken(),
'X-Requested-With': 'XMLHttpRequest',
Accept: 'application/json',
},
body: new FormData(form),
})
if (response.ok) this.replaceBody(await response.text())
if (response.ok) {
this.replaceBody(await response.text())
return
}
const data = await response.json().catch(() => null)
this.showError(data?.error)
// The rejected quantity (typed, or from a +/- click) is left
// sitting in the input with nothing to correct it — the update
// never reached the cart, so the input must be put back to what
// the cart actually still holds, not just left showing whatever
// was rejected.
const input = form.querySelector('[data-bbk-cart-confirmed-quantity]')
if (input) input.value = input.dataset.bbkCartConfirmedQuantity
} finally {
this.bodyTarget.removeAttribute('aria-busy')
}
}
showError(message) {
if (!this.hasErrorTarget || !message) return
this.errorTarget.textContent = message
this.errorTarget.hidden = false
}
clearError() {
if (!this.hasErrorTarget) return
this.errorTarget.hidden = true
}
replaceBody(html) {
this.bodyTarget.innerHTML = html
this.emitUpdated(this.bodyTarget.querySelector('[data-bbk-cart-count]'))
@@ -13,7 +13,6 @@ import { csrfToken } from './csrf'
// already uses). Shipping-method radios post to selectShippingUrl the same way.
export default class extends Controller {
static targets = [
'guestTab', 'loginTab', 'guestPanel', 'loginPanel',
'sameAsBilling', 'shippingFields',
'form', 'shippingOptions', 'status',
]
@@ -41,22 +40,6 @@ export default class extends Controller {
this.saveController?.abort()
}
// ── Contact tabs ────────────────────────────────────────────────────
showGuest() {
this.guestPanelTarget.hidden = false
this.loginPanelTarget.hidden = true
this.guestTabTarget.setAttribute('aria-selected', 'true')
this.loginTabTarget.setAttribute('aria-selected', 'false')
}
showLogin() {
this.guestPanelTarget.hidden = true
this.loginPanelTarget.hidden = false
this.guestTabTarget.setAttribute('aria-selected', 'false')
this.loginTabTarget.setAttribute('aria-selected', 'true')
}
// ── Same as billing ────────────────────────────────────────────────
toggleSameAsBilling() {
@@ -93,7 +76,6 @@ export default class extends Controller {
// react to fields that actually belong to the address form.
const el = event.target
const belongsToForm = el.form?.id === 'bbk-address-form'
|| el.closest('[data-bbk-checkout-form-target="guestPanel"]')
if (!belongsToForm) return
// No status during the wait — it only shows once the request is in flight,
@@ -43,6 +43,16 @@ export default class extends Controller {
this.amountValue = total
this.elements.update({ amount: Math.max(total, 1) })
}
// Removing the last line while sitting on the checkout page (via
// the order summary's own remove form) must not leave "place
// order" clickable with nothing left to charge for — this fires
// from both the drawer and the checkout page's own summary
// instance, whichever the shopper actually used.
const count = event.detail?.count
if (typeof count === 'number' && this.hasSubmitTarget) {
this.submitTarget.disabled = count === 0
}
}
window.addEventListener('bbk-cart:updated', this.onSummaryUpdate)
@@ -0,0 +1,108 @@
import { Controller } from '@hotwired/stimulus'
// One product custom field of type `file` (see x-product-custom-fields).
// Uploads the photo to the storefront's own endpoint (CustomFieldUploadController)
// as soon as it's picked, then writes the returned File row's id (boboko-core's
// Modules\Core\File\Models\File) into the hidden input the add-to-cart form
// actually submits — the checkout module never receives the file itself.
//
// While uploading, the file input is marked invalid via setCustomValidity(),
// so the browser's own form validation blocks add-to-cart until the id is in
// place. A failed upload clears the input, so `required` blocks it too.
export default class extends Controller {
static targets = ['file', 'reference', 'preview', 'error']
static values = {
url: String,
label: String,
uploadingMessage: String,
failedMessage: String,
}
disconnect() {
this.abortController?.abort()
this.revokePreview()
}
async upload() {
this.reset()
const file = this.fileTarget.files[0]
if (!file) return
const abortController = new AbortController()
this.abortController = abortController
this.fileTarget.setCustomValidity(this.uploadingMessageValue)
this.fileTarget.setAttribute('aria-busy', 'true')
const body = new FormData()
body.append('file', file)
body.append('label', this.labelValue)
try {
const response = await fetch(this.urlValue, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content ?? '',
'X-Requested-With': 'XMLHttpRequest',
Accept: 'application/json',
},
body,
signal: abortController.signal,
})
const data = await response.json().catch(() => null)
if (!response.ok || !data?.file_id) {
this.fail(data?.error)
return
}
this.referenceTarget.value = data.file_id
this.showPreview(file)
} catch (error) {
// A newer pick superseded this upload — reset() already handled it.
if (error.name === 'AbortError') return
this.fail()
} finally {
if (!abortController.signal.aborted) this.markIdle()
}
}
reset() {
this.abortController?.abort()
this.referenceTarget.value = ''
this.errorTarget.hidden = true
this.markIdle()
this.revokePreview()
}
fail(message) {
this.fileTarget.value = ''
this.errorTarget.textContent = message || this.failedMessageValue
this.errorTarget.hidden = false
}
markIdle() {
this.fileTarget.setCustomValidity('')
this.fileTarget.removeAttribute('aria-busy')
}
showPreview(file) {
this.previewUrl = URL.createObjectURL(file)
this.previewTarget.src = this.previewUrl
this.previewTarget.hidden = false
}
// Formats the browser can't render (HEIC outside Safari) — the file
// input's own filename is enough there.
hidePreview() {
this.previewTarget.hidden = true
}
revokePreview() {
if (this.previewUrl) URL.revokeObjectURL(this.previewUrl)
this.previewUrl = null
this.previewTarget.removeAttribute('src')
this.previewTarget.hidden = true
}
}
+6
View File
@@ -8,6 +8,7 @@ import AutoSubmitController from './auto-submit-controller'
import BackToTopController from './back-to-top-controller'
import CartCountController from './cart-count-controller'
import CarouselController from './carousel-controller'
import CustomFieldUploadController from './custom-field-upload-controller'
import DropdownController from './dropdown-controller'
import FrameScrollController from './frame-scroll-controller'
import NavSearchController from './nav-search-controller'
@@ -16,7 +17,9 @@ import ProductGalleryController from './product-gallery-controller'
import QuantityController from './quantity-controller'
import RangeSliderController from './range-slider-controller'
import StarRatingController from './star-rating-controller'
import TabLinkController from './tab-link-controller'
import TabsController from './tabs-controller'
import WishlistController from './wishlist-controller'
export function registerControllers(application) {
application.register('appear', AppearController)
@@ -24,6 +27,7 @@ export function registerControllers(application) {
application.register('back-to-top', BackToTopController)
application.register('cart-count', CartCountController)
application.register('carousel', CarouselController)
application.register('custom-field-upload', CustomFieldUploadController)
application.register('dropdown', DropdownController)
application.register('frame-scroll', FrameScrollController)
application.register('nav-search', NavSearchController)
@@ -32,5 +36,7 @@ export function registerControllers(application) {
application.register('quantity', QuantityController)
application.register('range-slider', RangeSliderController)
application.register('star-rating', StarRatingController)
application.register('tab-link', TabLinkController)
application.register('tabs', TabsController)
application.register('wishlist', WishlistController)
}
+136 -14
View File
@@ -2,25 +2,138 @@ import { Controller } from '@hotwired/stimulus'
import { formatPrice } from '../utils/format-price'
export default class extends Controller {
static targets = ['price', 'image', 'swatch', 'colorName']
static values = { variants: Array, selected: Number }
static targets = ['price', 'image', 'swatch', 'colorName', 'stockError', 'submit']
static values = {
variants: Array,
selected: Number,
stockCheckUrl: String,
// Two pre-rendered translated templates (see product/show.blade.php)
// rather than one — this controller doesn't reimplement Laravel's
// pluralization rules, it just picks whichever of these two the
// count actually needs and fills in the number.
stockErrorOne: String,
stockErrorMany: String,
}
connect() {
const params = new URLSearchParams(window.location.search)
const urlId = parseInt(params.get('variant'))
const defaultId = this.variantsValue[0]?.id
const params = new URLSearchParams(window.location.search)
const urlId = parseInt(params.get('variant'))
const urlVariant = this.variantsValue.find(v => v.id === urlId)
const initial = urlVariant ?? this.variantsValue[0]
this.selectedValue = urlId && this.variantsValue.find(v => v.id === urlId)
? urlId
: defaultId
// A product can have several independent options (e.g. size + style
// + person-count) — this tracks the currently-picked value id per
// option handle, and selectVariant() below resolves the full
// combination back to one exact variant on every change.
this.selections = { ...initial?.options }
this.selectedValue = initial?.id
// Capture phase, on this controller's own root element (an ancestor
// of the checkout module's add-to-cart <form>) — runs BEFORE that
// form's own bubble-phase submit handler (bbk-add-to-cart#add), so a
// failed check can stop it from ever reaching the module at all. The
// module itself is never touched or modified for this: it keeps
// validating server-side regardless, this is purely an up-front,
// storefront-owned check (see [[project_checkout_module]] for why
// that split matters — stock UX is a catalog concern, not something
// the portable checkout module should own) — and a REAL, live check
// against the backend (ProductController::checkStock(), reading the
// Eloquent model directly), not page-load data that can go stale.
this.onSubmitCapture = this.checkStock.bind(this)
this.element.addEventListener('submit', this.onSubmitCapture, true)
}
disconnect() {
this.element.removeEventListener('submit', this.onSubmitCapture, true)
}
checkStock(event) {
const form = event.target
if (!form.matches('.bbk-add-to-cart')) return
// The re-submit this itself triggers below, once the backend has
// confirmed the quantity is fine — let that one through to the
// module's own submit handler instead of checking a second time.
if (form.dataset.bbkStockChecked) {
delete form.dataset.bbkStockChecked
return
}
event.preventDefault()
event.stopPropagation()
this.verifyStock(form)
}
async verifyStock(form) {
this.clearStockError()
const submit = form.querySelector('[type="submit"]')
if (submit) submit.disabled = true
const purchasableId = form.querySelector('[data-bbk-purchasable-input]')?.value
const quantity = form.querySelector('[name="quantity"]')?.value || '1'
try {
const url = new URL(this.stockCheckUrlValue, window.location.origin)
url.searchParams.set('variant', purchasableId)
url.searchParams.set('quantity', quantity)
const response = await fetch(url, { headers: { Accept: 'application/json' } })
const data = await response.json()
if (!data.ok) {
this.showStockError(data.stock)
return
}
} catch {
// Network hiccup — fall through and let the checkout module's
// own server-side check have the final word rather than
// silently blocking the shopper here.
} finally {
if (submit) submit.disabled = !this.selectedVariant?.inStock
}
form.dataset.bbkStockChecked = 'true'
form.requestSubmit()
}
showStockError(available) {
if (!this.hasStockErrorTarget) return
this.stockErrorTarget.textContent = available === 1
? this.stockErrorOneValue
: this.stockErrorManyValue.replace(':count', String(available))
this.stockErrorTarget.hidden = false
}
clearStockError() {
if (!this.hasStockErrorTarget) return
this.stockErrorTarget.hidden = true
}
get selectedVariant() {
return this.variantsValue.find(v => v.id === this.selectedValue)
}
selectVariant(event) {
const id = parseInt(event.currentTarget.dataset.variantId)
this.selectedValue = id
const option = event.currentTarget.dataset.option
const valueId = parseInt(event.currentTarget.dataset.valueId)
this.selections = { ...this.selections, [option]: valueId }
const match = this.variantsValue.find(variant =>
Object.keys(this.selections).every(key => variant.options?.[key] === this.selections[key])
)
// No variant exists for this combination (e.g. an option value that
// isn't offered together with another currently-selected value) —
// leave the previous selection in place rather than pointing the
// add-to-cart form at nothing.
if (!match) return
this.selectedValue = match.id
const url = new URL(window.location)
url.searchParams.set('variant', id)
url.searchParams.set('variant', match.id)
window.history.pushState({}, '', url)
}
@@ -30,6 +143,8 @@ export default class extends Controller {
const variant = this.variantsValue.find(v => v.id === id)
if (!variant) return
this.clearStockError()
if (this.hasPriceTarget && variant.price !== null) {
this.priceTarget.textContent = formatPrice(variant.price)
}
@@ -45,13 +160,20 @@ export default class extends Controller {
const purchasableInput = this.element.querySelector('[data-bbk-purchasable-input]')
if (purchasableInput) purchasableInput.value = id
// Out-of-stock variant (as of page load) can't be added at all.
if (this.hasSubmitTarget) this.submitTarget.disabled = !variant.inStock
this.swatchTargets.forEach(swatch => {
const isSelected = parseInt(swatch.dataset.variantId) === id
const isSelected = this.selections[swatch.dataset.option] === parseInt(swatch.dataset.valueId)
swatch.classList.toggle('is-selected', isSelected)
swatch.setAttribute('aria-pressed', String(isSelected))
if (isSelected && this.hasColorNameTarget) {
this.colorNameTarget.textContent = swatch.getAttribute('aria-label')
if (isSelected) {
// Each color-option group has its own colorName echo (see
// x-ui.color-swatch) — matched by option handle so a swatch
// in one group never overwrites another group's label.
const colorName = this.colorNameTargets.find(target => target.dataset.option === swatch.dataset.option)
if (colorName) colorName.textContent = swatch.getAttribute('aria-label')
}
})
}
@@ -1,9 +1,25 @@
import { Controller } from '@hotwired/stimulus'
export default class extends Controller {
static targets = ['star', 'input']
static targets = ['star', 'input', 'error']
static values = { rating: { type: Number, default: 0 } }
connect() {
this.#fill(this.ratingValue)
}
// Bound to the form's submit event (this controller sits on the <form>
// itself, not just the star widget) — a plain `required` on the hidden
// rating input would never surface: browsers exclude type="hidden" from
// constraint validation entirely, so there'd be nothing to see or hear.
validate(event) {
if (this.ratingValue < 1) {
event.preventDefault()
this.errorTarget.hidden = false
this.starTargets[0]?.focus()
}
}
hover(event) {
this.#fill(parseInt(event.currentTarget.dataset.value))
}
@@ -16,6 +32,7 @@ export default class extends Controller {
const val = parseInt(event.currentTarget.dataset.value)
this.ratingValue = val
this.inputTarget.value = val
this.errorTarget.hidden = true
this.starTargets.forEach(star => {
star.setAttribute('aria-pressed', String(parseInt(star.dataset.value) === val))
@@ -0,0 +1,15 @@
import { Controller } from '@hotwired/stimulus'
// Jumps to a tab panel from an element outside the tabs' own markup — the
// review count and the "read more" link both sit elsewhere in the DOM, too
// far apart from the tabs for a plain data-action, hence the outlet.
export default class extends Controller {
static outlets = ['tabs']
static values = { panel: String }
activate(event) {
event?.preventDefault()
this.tabsOutlet.activate(this.panelValue)
this.tabsOutlet.element.scrollIntoView({ block: 'start', behavior: 'smooth' })
}
}
+2 -2
View File
@@ -4,10 +4,10 @@ export default class extends Controller {
static targets = ['button', 'panel']
show(event) {
this.#activate(event.currentTarget.dataset.panel)
this.activate(event.currentTarget.dataset.panel)
}
#activate(panelId) {
activate(panelId) {
this.buttonTargets.forEach(btn => {
const active = btn.dataset.panel === panelId
btn.classList.toggle('is-active', active)
@@ -0,0 +1,42 @@
import { Controller } from '@hotwired/stimulus'
// Heart toggle (x-wishlist-button). Posts the form with fetch and reflects the
// server's answer on aria-pressed, which the CSS uses to swap the outline and
// filled heart. If the request fails, falls back to a normal form submit.
export default class extends Controller {
static targets = ['button', 'status']
static values = {
addLabel: String,
removeLabel: String,
addedMessage: String,
removedMessage: String,
}
async toggle(event) {
event.preventDefault()
if (this.busy) return
this.busy = true
try {
const response = await fetch(this.element.action, {
method: 'POST',
headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
body: new FormData(this.element),
})
if (!response.ok) throw new Error(`Wishlist toggle failed: ${response.status}`)
const { active } = await response.json()
this.buttonTarget.setAttribute('aria-pressed', active ? 'true' : 'false')
this.buttonTarget.setAttribute('aria-label', active ? this.removeLabelValue : this.addLabelValue)
this.statusTarget.textContent = active ? this.addedMessageValue : this.removedMessageValue
} catch {
this.element.submit()
} finally {
this.busy = false
}
}
}
@@ -0,0 +1,65 @@
@extends('layouts.account')
@section('title', __('storefront.auth.enter_code'))
@section('account')
<div class="flex max-w-2xl flex-col gap-12">
<div class="flex flex-col gap-6">
<h1 class="font-display text-h3 font-black tracking-tight sm:text-h2">
{{ __('storefront.auth.enter_code') }}
</h1>
<p>
{{ __('storefront.auth.code_sent_to') }} <strong class="break-all">{{ $email }}</strong>
</p>
<x-ui.status />
</div>
<form method="POST" action="{{ route('account.email.verify') }}" class="flex flex-col gap-8">
@csrf
<x-ui.field
:label="__('storefront.auth.code')"
for="account-email-code"
:required="true"
:error="$errors->first('code')"
>
<x-ui.input
id="account-email-code"
name="code"
inputmode="numeric"
autocomplete="one-time-code"
pattern="[0-9]{6}"
maxlength="6"
:required="true"
autofocus
class="text-2xl font-bold tracking-[0.5em]"
:aria-invalid="$errors->has('code') ? 'true' : null"
:aria-describedby="$errors->has('code') ? 'account-email-code-error' : null"
/>
</x-ui.field>
<div>
<x-ui.button type="submit">{{ __('storefront.account.email_confirm') }}</x-ui.button>
</div>
</form>
<div class="flex flex-wrap items-center gap-x-8 gap-y-4 text-sm">
<form method="POST" action="{{ route('account.email.resend') }}">
@csrf
<button type="submit" class="cursor-pointer underline hover:no-underline">
{{ __('storefront.auth.resend_code') }}
</button>
</form>
<a href="{{ route('account.email.edit') }}" class="underline hover:no-underline">
{{ __('storefront.auth.change_email') }}
</a>
</div>
</div>
@endsection
+53
View File
@@ -0,0 +1,53 @@
@extends('layouts.account')
@section('title', __('storefront.account.email_change_heading'))
@section('account')
<div class="flex max-w-2xl flex-col gap-12">
<div class="flex flex-col gap-6">
<h1 class="font-display text-h3 font-black tracking-tight sm:text-h2">
{{ __('storefront.account.email_change_heading') }}
</h1>
<p>
{{ __('storefront.account.email_current') }} <strong class="break-all">{{ $user->email }}</strong>
</p>
</div>
<form method="POST" action="{{ route('account.email.send') }}" class="flex flex-col gap-8">
@csrf
<x-ui.field
:label="__('storefront.account.email_new')"
for="account-new-email"
:required="true"
:description="__('storefront.account.email_new_hint')"
:error="$errors->first('email')"
>
<x-ui.input
id="account-new-email"
name="email"
type="email"
autocomplete="email"
:value="old('email')"
:required="true"
autofocus
:aria-invalid="$errors->has('email') ? 'true' : null"
:aria-describedby="$errors->has('email') ? 'account-new-email-error' : 'account-new-email-description'"
/>
</x-ui.field>
<div class="flex flex-wrap items-center gap-8">
<x-ui.button type="submit">{{ __('storefront.auth.send_code') }}</x-ui.button>
<a href="{{ route('account') }}" class="text-sm underline hover:no-underline">
{{ __('storefront.account.delete_cancel') }}
</a>
</div>
</form>
</div>
@endsection
@@ -0,0 +1,67 @@
@extends('layouts.account')
@section('title', __('storefront.account.nav_orders'))
@section('account')
<div class="flex max-w-4xl flex-col gap-12">
<h1 class="font-display text-h3 font-black tracking-tight sm:text-h2">
{{ __('storefront.account.nav_orders') }}
</h1>
@if ($orders->isEmpty())
<div class="flex flex-col items-start gap-8">
<p>{{ __('storefront.orders.empty') }}</p>
<x-ui.button :href="route('products')" size="md">{{ __('storefront.orders.shop_now') }}</x-ui.button>
</div>
@else
{{-- A list, not a table, so each order can stack on mobile. The column
headings are visual only; each cell carries its own sr-only label. --}}
<div>
<div class="hidden grid-cols-[1fr_1fr_1.5fr_1fr_auto] gap-6 border-b border-black pb-3 text-sm uppercase font-display font-extrabold sm:grid" aria-hidden="true">
<span>{{ __('storefront.orders.date') }}</span>
<span>{{ __('storefront.orders.number') }}</span>
<span>{{ __('storefront.orders.status') }}</span>
<span>{{ __('storefront.orders.total') }}</span>
<span class="w-24"></span>
</div>
<ul>
@foreach ($orders as $order)
<li class="grid grid-cols-2 gap-x-6 gap-y-2 border-b border-black py-5 sm:grid-cols-[1fr_1fr_1.5fr_1fr_auto] sm:items-center">
<span>
<span class="sr-only">{{ __('storefront.orders.date') }}:</span>
{{ $order->placed_at->format('d/m/Y') }}
</span>
<span class="text-right font-bold sm:text-left">
<span class="sr-only">{{ __('storefront.orders.number') }}:</span>
#{{ $order->reference }}
</span>
<span>
<span class="sr-only">{{ __('storefront.orders.status') }}:</span>
<x-order-status :status="$order->status" />
</span>
<span class="text-right sm:text-left">
<span class="sr-only">{{ __('storefront.orders.total') }}:</span>
{{ $order->total?->formatted() }}
</span>
<a
href="{{ route('account.orders.show', ['orderId' => $order->id]) }}"
class="col-span-2 mt-2 inline-flex w-24 items-center gap-2 font-bold underline hover:no-underline sm:col-span-1 sm:mt-0"
aria-label="{{ __('storefront.orders.view_order', ['number' => $order->reference]) }}"
>
{{ __('storefront.orders.view') }}
<x-ui.icon name="arrow-right" :size="16" />
</a>
</li>
@endforeach
</ul>
</div>
<x-ui.pagination :paginator="$orders" />
@endif
</div>
@endsection
@@ -0,0 +1,158 @@
@extends('layouts.account')
@section('title', __('storefront.orders.order_title', ['number' => $order->reference]))
@php
$productLines = $order->lines->where('type', '!=', 'shipping');
$shippingLine = $order->lines->firstWhere('type', 'shipping');
@endphp
@section('account')
<div class="flex max-w-4xl flex-col gap-12">
<div class="flex flex-col gap-6">
<a href="{{ route('account.orders') }}" class="inline-flex items-center gap-2 self-start text-sm underline hover:no-underline">
<x-ui.icon name="arrow-left" :size="16" />
{{ __('storefront.orders.back') }}
</a>
<h1 class="font-display text-h3 font-black tracking-tight sm:text-h2">
{{ __('storefront.orders.order_title', ['number' => $order->reference]) }}
</h1>
</div>
{{-- Summary --}}
<dl class="grid grid-cols-1 gap-6 border-y border-black py-6 sm:grid-cols-2 lg:grid-cols-4">
<div class="flex flex-col gap-1">
<dt class="text-sm text-neutral-500">{{ __('storefront.orders.date') }}</dt>
<dd>{{ $order->placed_at->format('d/m/Y') }}</dd>
</div>
<div class="flex flex-col gap-1">
<dt class="text-sm text-neutral-500">{{ __('storefront.orders.status') }}</dt>
<dd class="font-bold"><x-order-status :status="$order->status" /></dd>
</div>
@if ($paymentMethodName)
<div class="flex flex-col gap-1">
<dt class="text-sm text-neutral-500">{{ __('storefront.orders.payment') }}</dt>
<dd>{{ $paymentMethodName }}</dd>
</div>
@endif
@if ($shippingLine)
<div class="flex flex-col gap-1">
<dt class="text-sm text-neutral-500">{{ __('storefront.orders.shipping_method') }}</dt>
<dd>{{ $shippingLine->description }}</dd>
</div>
@endif
@foreach ($shipments as $shipment)
<div class="flex flex-col gap-1">
<dt class="text-sm text-neutral-500">{{ __('storefront.orders.tracking') }}</dt>
<dd class="break-all">{{ $shipment->tracking_reference }}</dd>
</div>
@endforeach
</dl>
{{-- Items + totals --}}
<section class="flex flex-col gap-6" aria-labelledby="order-items-heading">
<h2 id="order-items-heading" class="font-display text-h4 font-extrabold">{{ __('storefront.orders.items') }}</h2>
<ul>
@foreach ($productLines as $line)
<li class="flex gap-4 border-b border-black py-5 sm:gap-6">
<div class="size-18 shrink-0 bg-neutral-300 sm:size-24">
@if ($thumb = $line->purchasable?->getThumbnailImage())
<img src="{{ $thumb }}" alt="" aria-hidden="true" width="96" height="96" loading="lazy" class="size-full object-cover">
@endif
</div>
<div class="flex min-w-0 flex-1 flex-col gap-1">
{{-- Linked only while the product still exists and is published;
otherwise the name stays plain text (the order keeps it). --}}
@php($lineProduct = $line->purchasable?->product)
<p class="font-bold">
@if ($lineProduct?->status === 'published')
<a href="{{ route('product.show', ['id' => $lineProduct->id]) }}" class="underline hover:no-underline">{{ $line->description }}</a>
@else
{{ $line->description }}
@endif
<span class="font-normal">&times; {{ $line->quantity }}</span>
</p>
@if ($line->option)
<p class="text-sm text-neutral-500">{{ $line->option }}</p>
@endif
@include('checkout::partials.line-custom-fields', ['line' => $line])
</div>
<p class="shrink-0 font-bold">{{ $line->sub_total?->formatted() }}</p>
</li>
@endforeach
</ul>
<dl class="ml-auto flex w-full max-w-sm flex-col gap-2">
<div class="flex justify-between gap-6">
<dt>{{ __('storefront.orders.subtotal') }}</dt>
<dd>{{ $order->sub_total?->formatted() }}</dd>
</div>
@if ($order->discount_total?->value > 0)
<div class="flex justify-between gap-6">
<dt>{{ __('storefront.orders.discount') }}</dt>
<dd>&minus;{{ $order->discount_total->formatted() }}</dd>
</div>
@endif
<div class="flex justify-between gap-6">
<dt>{{ __('storefront.orders.shipping') }}</dt>
<dd>{{ $order->shipping_total?->formatted() }}</dd>
</div>
@if ($order->tax_total?->value > 0)
<div class="flex justify-between gap-6 text-sm text-neutral-500">
<dt>{{ __('storefront.orders.tax') }}</dt>
<dd>{{ $order->tax_total->formatted() }}</dd>
</div>
@endif
<div class="flex justify-between gap-6 border-t border-black pt-3 font-display text-h4 font-extrabold">
<dt>{{ __('storefront.orders.total') }}</dt>
<dd>{{ $order->total?->formatted() }}</dd>
</div>
</dl>
</section>
{{-- Addresses --}}
<div class="grid grid-cols-1 gap-12 sm:grid-cols-2">
@foreach ([
'shipping_to' => $order->shippingAddress,
'billing' => $order->billingAddress,
] as $heading => $address)
@if ($address)
<section class="flex flex-col gap-4" aria-labelledby="order-{{ $heading }}-heading">
<h2 id="order-{{ $heading }}-heading" class="font-display text-h4 font-extrabold">{{ __("storefront.orders.{$heading}") }}</h2>
<address class="flex flex-col not-italic">
<span>{{ trim($address->first_name.' '.$address->last_name) }}</span>
@if ($address->company_name)<span>{{ $address->company_name }}</span>@endif
@if ($address->tax_identifier)<span>{{ __('storefront.account.tax_identifier') }}: {{ $address->tax_identifier }}</span>@endif
<span>{{ $address->line_one }}</span>
@if ($address->line_two)<span>{{ $address->line_two }}</span>@endif
<span>{{ trim($address->postcode.' '.$address->city) }}</span>
@if ($address->state)
<span>{{ Lang::has("core::states.{$address->state}") ? __("core::states.{$address->state}") : $address->state }}</span>
@endif
@if ($address->contact_phone)<span>{{ $address->contact_phone }}</span>@endif
</address>
</section>
@endif
@endforeach
</div>
</div>
@endsection
+204
View File
@@ -0,0 +1,204 @@
@extends('layouts.account')
@section('title', __('storefront.account.nav_profile'))
@php
$wantsInvoice = (bool) old('invoice', filled($customer?->company_name) || filled($customer?->tax_identifier));
$regionOptions = $regions->map(fn ($region) => [
'value' => $region->name,
'label' => Lang::has("core::states.{$region->name}") ? __("core::states.{$region->name}") : $region->name,
])->all();
@endphp
@section('account')
<div class="flex max-w-2xl flex-col gap-16">
<div class="flex flex-col gap-6">
<h1 class="font-display text-h3 font-black tracking-tight sm:text-h2">
{{ __('storefront.account.nav_profile') }}
</h1>
<x-ui.status />
</div>
{{-- Email: the login itself, so it's changed through its own verified flow --}}
<section class="flex flex-col gap-6" aria-labelledby="account-email-heading">
<h2 id="account-email-heading" class="font-display text-h4 font-extrabold">{{ __('storefront.account.email_heading') }}</h2>
<div class="flex flex-wrap items-center justify-between gap-x-8 gap-y-2 border-b border-black py-2">
<span class="break-all">{{ $user->email }}</span>
<a href="{{ route('account.email.edit') }}" class="text-sm underline hover:no-underline">
{{ __('storefront.account.email_change') }}
</a>
</div>
</section>
<form method="POST" action="{{ route('account.update') }}" class="flex flex-col gap-16">
@csrf
@method('PUT')
{{-- Details --}}
<section class="flex flex-col gap-8" aria-labelledby="account-details-heading">
<h2 id="account-details-heading" class="font-display text-h4 font-extrabold">{{ __('storefront.account.details_heading') }}</h2>
<div class="grid grid-cols-1 gap-8 sm:grid-cols-2">
<x-ui.field :label="__('storefront.account.first_name')" for="account-first-name" :error="$errors->first('first_name')">
<x-ui.input id="account-first-name" name="first_name" autocomplete="given-name"
:value="old('first_name', $customer?->first_name)"
:aria-invalid="$errors->has('first_name') ? 'true' : null" :aria-describedby="$errors->has('first_name') ? 'account-first-name-error' : null" />
</x-ui.field>
<x-ui.field :label="__('storefront.account.last_name')" for="account-last-name" :error="$errors->first('last_name')">
<x-ui.input id="account-last-name" name="last_name" autocomplete="family-name"
:value="old('last_name', $customer?->last_name)"
:aria-invalid="$errors->has('last_name') ? 'true' : null" :aria-describedby="$errors->has('last_name') ? 'account-last-name-error' : null" />
</x-ui.field>
</div>
{{-- Invoice details, revealed by the checkbox (CSS :has(), no JS). Not
`required` in HTML: a hidden required field would block submit, so
the server requires them only when the box is ticked. --}}
<div class="group/invoice flex flex-col gap-8">
<input type="hidden" name="invoice" value="0">
<x-ui.checkbox
id="account-invoice"
name="invoice"
:checked="$wantsInvoice"
:label="__('storefront.account.invoice')"
aria-controls="account-invoice-fields"
/>
<div id="account-invoice-fields" class="hidden grid-cols-1 gap-8 sm:grid-cols-2 group-has-[#account-invoice:checked]/invoice:grid">
<x-ui.field :label="__('storefront.account.company_name')" for="account-company" :error="$errors->first('company_name')">
<x-ui.input id="account-company" name="company_name" autocomplete="organization"
:value="old('company_name', $customer?->company_name)"
:aria-invalid="$errors->has('company_name') ? 'true' : null" :aria-describedby="$errors->has('company_name') ? 'account-company-error' : null" />
</x-ui.field>
<x-ui.field :label="__('storefront.account.tax_identifier')" for="account-tax-id" :error="$errors->first('tax_identifier')">
<x-ui.input id="account-tax-id" name="tax_identifier" inputmode="numeric" maxlength="9" pattern="[0-9]{9}"
:value="old('tax_identifier', $customer?->tax_identifier)"
:aria-invalid="$errors->has('tax_identifier') ? 'true' : null" :aria-describedby="$errors->has('tax_identifier') ? 'account-tax-id-error' : null" />
</x-ui.field>
</div>
</div>
</section>
{{-- Address: one, used as both shipping and billing default --}}
<section class="flex flex-col gap-8" aria-labelledby="account-address-heading">
<h2 id="account-address-heading" class="font-display text-h4 font-extrabold">{{ __('storefront.account.address_heading') }}</h2>
<x-ui.field :label="__('storefront.account.line_one')" for="account-line-one" :error="$errors->first('line_one')">
<x-ui.input id="account-line-one" name="line_one" autocomplete="address-line1"
:value="old('line_one', $address?->line_one)"
:aria-invalid="$errors->has('line_one') ? 'true' : null" :aria-describedby="$errors->has('line_one') ? 'account-line-one-error' : null" />
</x-ui.field>
<div class="grid grid-cols-1 gap-8 sm:grid-cols-2">
<x-ui.field :label="__('storefront.account.city')" for="account-city" :error="$errors->first('city')">
<x-ui.input id="account-city" name="city" autocomplete="address-level2"
:value="old('city', $address?->city)"
:aria-invalid="$errors->has('city') ? 'true' : null" :aria-describedby="$errors->has('city') ? 'account-city-error' : null" />
</x-ui.field>
<x-ui.field :label="__('storefront.account.postcode')" for="account-postcode" :error="$errors->first('postcode')">
<x-ui.input id="account-postcode" name="postcode" autocomplete="postal-code" inputmode="numeric"
:value="old('postcode', $address?->postcode)"
:aria-invalid="$errors->has('postcode') ? 'true' : null" :aria-describedby="$errors->has('postcode') ? 'account-postcode-error' : null" />
</x-ui.field>
</div>
<div class="grid grid-cols-1 gap-8 sm:grid-cols-2">
<x-ui.field :label="__('storefront.account.state')" for="account-state" :error="$errors->first('state')">
<x-ui.select
id="account-state"
name="state"
:block="true"
:options="$regionOptions"
:value="old('state', $address?->state)"
:placeholder="__('storefront.account.state_placeholder')"
:aria-invalid="$errors->has('state') ? 'true' : null"
:aria-describedby="$errors->has('state') ? 'account-state-error' : null"
/>
</x-ui.field>
<x-ui.field :label="__('storefront.account.phone')" for="account-phone" :error="$errors->first('contact_phone')">
<x-ui.input id="account-phone" name="contact_phone" type="tel" autocomplete="tel"
:value="old('contact_phone', $address?->contact_phone)"
:aria-invalid="$errors->has('contact_phone') ? 'true' : null" :aria-describedby="$errors->has('contact_phone') ? 'account-phone-error' : null" />
</x-ui.field>
</div>
</section>
{{-- Standing opt-in for abandoned-cart reminders (explicit, off by
default); checkout starts from it and can change it again. --}}
<section class="flex flex-col gap-8" aria-labelledby="account-emails-heading">
<h2 id="account-emails-heading" class="font-display text-h4 font-extrabold">{{ __('storefront.account.emails_heading') }}</h2>
<div>
<input type="hidden" name="recovery_consent" value="0">
<x-ui.checkbox
id="account-recovery-consent"
name="recovery_consent"
:checked="(bool) old('recovery_consent', data_get($customer, 'meta.recovery_consent'))"
:label="__('storefront.account.recovery_consent')"
/>
</div>
</section>
<div>
<x-ui.button type="submit">{{ __('storefront.account.save') }}</x-ui.button>
</div>
</form>
{{-- Delete account --}}
<section class="flex flex-col gap-6 border-t border-black pt-12" aria-labelledby="account-delete-heading">
<h2 id="account-delete-heading" class="font-display text-h4 font-extrabold">{{ __('storefront.account.delete_heading') }}</h2>
<p>{{ __('storefront.account.delete_text') }}</p>
<div>
<x-ui.button
variant="secondary"
size="md"
popovertarget="account-delete-dialog"
aria-haspopup="dialog"
>{{ __('storefront.account.delete') }}</x-ui.button>
</div>
<div
id="account-delete-dialog"
popover
role="dialog"
aria-modal="true"
aria-labelledby="account-delete-dialog-heading"
class="m-auto w-[calc(100%-2rem)] max-w-lg border border-black bg-neutral-200 p-8 backdrop:bg-black/50"
>
<h3 id="account-delete-dialog-heading" class="mb-4 font-display text-h4 font-extrabold">
{{ __('storefront.account.delete_confirm_heading') }}
</h3>
<p class="mb-8">{{ __('storefront.account.delete_confirm_text') }}</p>
<form method="POST" action="{{ route('account.destroy') }}" class="flex flex-wrap gap-4">
@csrf
@method('DELETE')
<x-ui.button type="submit" size="md">{{ __('storefront.account.delete_confirm') }}</x-ui.button>
<x-ui.button
variant="secondary"
size="md"
popovertarget="account-delete-dialog"
popovertargetaction="hide"
>{{ __('storefront.account.delete_cancel') }}</x-ui.button>
</form>
</div>
</section>
</div>
@endsection
@@ -0,0 +1,17 @@
@extends('layouts.account')
@section('title', __('storefront.account.nav_wishlist'))
@section('account')
<div class="flex flex-col gap-12">
<h1 class="font-display text-h3 font-black tracking-tight sm:text-h2">
{{ __('storefront.account.nav_wishlist') }}
</h1>
@include('wishlist.list')
</div>
@endsection
+67
View File
@@ -0,0 +1,67 @@
@extends('layouts.app')
@section('title', __('storefront.auth.enter_code'))
@push('seo')
<meta name="robots" content="noindex, nofollow">
@endpush
@section('content')
<section class="mx-auto max-w-2xl px-4 py-20 sm:px-8 sm:py-32">
<h1 class="mb-6 font-display text-h3 font-black tracking-tight sm:text-h2">
{{ __('storefront.auth.enter_code') }}
</h1>
<p class="mb-12">
{{ __('storefront.auth.code_sent_to') }} <strong class="break-all">{{ $email }}</strong>
</p>
<x-ui.status class="mb-8" />
<form method="POST" action="{{ route('login.verify') }}" class="flex flex-col gap-8">
@csrf
<x-ui.field
:label="__('storefront.auth.code')"
for="login-code"
:required="true"
:error="$errors->first('code')"
>
<x-ui.input
id="login-code"
name="code"
inputmode="numeric"
autocomplete="one-time-code"
pattern="[0-9]{6}"
maxlength="6"
:required="true"
autofocus
class="text-2xl font-bold tracking-[0.5em]"
:aria-invalid="$errors->has('code') ? 'true' : null"
:aria-describedby="$errors->has('code') ? 'login-code-error' : null"
/>
</x-ui.field>
<div>
<x-ui.button type="submit">{{ __('storefront.auth.login') }}</x-ui.button>
</div>
</form>
<div class="mt-12 flex flex-wrap items-center gap-x-8 gap-y-4 text-sm">
<form method="POST" action="{{ route('login.resend') }}">
@csrf
<button type="submit" class="cursor-pointer underline hover:no-underline">
{{ __('storefront.auth.resend_code') }}
</button>
</form>
<a href="{{ route('login') }}" class="underline hover:no-underline">
{{ __('storefront.auth.change_email') }}
</a>
</div>
</section>
@endsection
+79
View File
@@ -0,0 +1,79 @@
@extends('layouts.app')
@section('title', __('storefront.auth.login'))
@push('seo')
<meta name="robots" content="noindex, follow">
@endpush
{{-- Only on pages with a captcha, not in the global layout. --}}
@push('scripts')
<script src="https://js.hcaptcha.com/1/api.js?hl={{ app()->getLocale() }}" async defer></script>
@endpush
@section('content')
<section class="mx-auto max-w-2xl px-4 py-20 sm:px-8 sm:py-32">
<h1 class="mb-6 font-display text-h3 font-black tracking-tight sm:text-h2">
{{ __('storefront.auth.login') }}
</h1>
{{-- Unescaped: the label holds a <br /> (edited in Filament › Language Lines, staff only). --}}
<p class="mb-12">{!! __('storefront.auth.login_intro') !!}</p>
{{-- e.g. "account deletion scheduled", after AccountController::destroy() --}}
<x-ui.status class="mb-12" />
<form method="POST" action="{{ route('login.send') }}" class="flex flex-col gap-8">
@csrf
<x-ui.field
:label="__('storefront.auth.email')"
for="login-email"
:required="true"
:error="$errors->first('email')"
>
<x-ui.input
id="login-email"
name="email"
type="email"
autocomplete="email"
:value="old('email')"
:required="true"
autofocus
:aria-invalid="$errors->has('email') ? 'true' : null"
:aria-describedby="$errors->has('email') ? 'login-email-error' : null"
/>
</x-ui.field>
{{-- Shown to everyone. Recording acceptance for new accounts is a
boboko-core task (UserOtpService). Unescaped: the label holds the
two links (Language Lines, staff only). --}}
<p class="text-sm text-neutral-500 [&_a]:underline [&_a:hover]:no-underline">
{!! __('storefront.auth.terms_notice', [
'terms' => route('legal.terms'),
'privacy' => route('legal.privacy'),
]) !!}
</p>
{{-- hCaptcha checkbox, verified by App\Rules\HCaptcha. The widget adds
the `h-captcha-response` field itself. Fixed height reserves the
iframe's space so the button doesn't jump when it loads. --}}
<x-ui.field for="login-captcha" :error="$errors->first('h-captcha-response')">
<div
id="login-captcha"
class="h-captcha min-h-[78px]"
data-sitekey="{{ config('services.hcaptcha.sitekey') }}"
@if ($errors->has('h-captcha-response')) aria-describedby="login-captcha-error" @endif
></div>
</x-ui.field>
<div>
<x-ui.button type="submit">{{ __('storefront.auth.send_code') }}</x-ui.button>
</div>
</form>
</section>
@endsection
+2 -1
View File
@@ -14,7 +14,8 @@
@endpush
@section('content')
<div class="max-w-5xl mx-auto pt-26 pb-12">
{{-- <div class="max-w-5xl mx-auto pt-26 pb-12"> --}}
<div class="max-w-7xl mx-auto px-4 sm:px-8 pt-26 pb-12">
<div class="flex items-end justify-between gap-6 flex-wrap mb-16">
<h1 class="font-medium text-h2">{{ $collection['name'] }}</h1>
@@ -1,42 +0,0 @@
{{--
<x-checkout::add-to-cart :purchasable="$variantId" />
A self-contained add-to-cart form. Posts the line via bbk-add-to-cart-controller
(fetch) and hands the rendered cart body to the drawer over the
`bbk-cart:changed` window event.
Props:
purchasable ProductVariant id. Omit to render no hidden id field — the host
must then supply [data-bbk-purchasable-input] itself (e.g. a
variant picker writing the selected id into it).
quantity Integer for the hidden quantity field, or false to omit it
(the host then puts its own name="quantity" control in the slot).
The button and any quantity control come from the slot, so the host owns all
appearance. Extra attributes (class, etc.) land on the <form>.
--}}
@props([
'purchasable' => null,
'quantity' => 1,
'action' => null,
])
<form
method="POST"
action="{{ $action ?? route('checkout.cart.add', app()->getLocale()) }}"
data-controller="bbk-add-to-cart"
data-action="bbk-add-to-cart#add"
{{ $attributes->class('bbk-add-to-cart') }}
>
@csrf
@if (! is_null($purchasable))
<input type="hidden" name="purchasable_id" value="{{ $purchasable }}" data-bbk-purchasable-input>
@endif
@if ($quantity !== false)
<input type="hidden" name="quantity" value="{{ $quantity }}">
@endif
{{ $slot }}
</form>
@@ -1,15 +0,0 @@
{{--
Read-only formatted address. $address is any Lunar address model
(OrderAddress / CartAddress) — same column names on both.
--}}
@props(['address'])
<address class="bbk-address-lines">
<span>{{ trim(($address->first_name ?? '') . ' ' . ($address->last_name ?? '')) }}</span>
@if ($address->company_name)<span>{{ $address->company_name }}</span>@endif
<span>{{ $address->line_one }}</span>
@if ($address->line_two)<span>{{ $address->line_two }}</span>@endif
<span>{{ trim(($address->postcode ?? '') . ' ' . ($address->city ?? '')) }}</span>
@if ($address->state)<span>{{ $address->state }}</span>@endif
@if ($address->contact_phone)<span>{{ $address->contact_phone }}</span>@endif
</address>
@@ -1,29 +0,0 @@
{{--
<x-checkout::field name="billing_first_name" label="First name" required />
Generic labelled text input with old-input repopulation and validation
error display — the module's own equivalent of a host x-ui.field, used
instead of it per the module's independence rule. All styling is .bbk-field*
(resources/css/checkout.css); no host classes.
--}}
@props([
'name',
'label',
'type' => 'text',
'value' => null,
'required' => false,
])
<div class="bbk-field">
<label class="bbk-field-label" for="bbk-{{ $name }}">{{ $label }}</label>
<input
type="{{ $type }}"
name="{{ $name }}"
id="bbk-{{ $name }}"
value="{{ old($name, $value) }}"
@if ($required) required @endif
{{ $attributes->class(['bbk-field-input', 'bbk-field-input--error' => $errors->has($name)]) }}
>
{{-- Always present so bbk-checkout-form can fill it live on an autosave. --}}
<p class="bbk-field-error" data-bbk-field-error="{{ $name }}" @unless ($errors->has($name)) hidden @endunless>{{ $errors->first($name) }}</p>
</div>
@@ -1,52 +0,0 @@
{{--
The state/region + country pair for one address (billing or shipping).
Single-country store ($storeCountry set): region is a <select> of that
country's Lunar states, submitting `->name` (table-rate-shipping resolves
zones with State::whereName()), and country is a fixed hidden field + label.
Otherwise: free-text region + full country <select>, as before.
--}}
@props([
'prefix',
'storeCountry' => null,
'regions' => [],
'countries' => [],
'address' => null,
])
<div class="bbk-field-row">
@if ($storeCountry)
<x-checkout::select
:name="$prefix . '_state'"
label="{{ __('checkout.page.state') }}"
:options="$regions"
value-field="name"
translation-group="states"
:value="$address?->state"
placeholder="{{ __('checkout.page.state_placeholder') }}"
required
/>
<div class="bbk-field">
<span class="bbk-field-label">{{ __('checkout.page.country') }}</span>
<p class="bbk-field-static">
{{ \Illuminate\Support\Facades\Lang::has("core::countries.{$storeCountry->name}")
? __("core::countries.{$storeCountry->name}")
: $storeCountry->name }}
</p>
<input type="hidden" name="{{ $prefix }}_country_id" value="{{ $storeCountry->id }}">
</div>
@else
<x-checkout::field :name="$prefix . '_state'" label="{{ __('checkout.page.state') }}" :value="$address?->state" />
<x-checkout::select
:name="$prefix . '_country_id'"
label="{{ __('checkout.page.country') }}"
:options="$countries"
translation-group="countries"
:value="$address?->country_id"
placeholder="{{ __('checkout.page.country_placeholder') }}"
required
/>
@endif
</div>
@@ -1,62 +0,0 @@
{{--
<x-checkout::select name="billing_country_id" label="Country" :options="$countries" required />
<x-checkout::select name="shipping_state" label="Region" :options="$regions" value-field="name" translation-group="states" required />
`options` is an iterable of models/objects; `label` is always read from
`->name`, the submitted value from `->{$valueField}` (default `id`, but e.g.
`name` for Lunar states — table-rate-shipping resolves those with
State::whereName(), so the address must carry the exact name string).
`translationGroup` (optional, e.g. "countries"/"states") looks the raw
`->name` up in boboko-core's `core::{group}.{name}` lang file (see
boboko-core's lang/el/countries.php, lang/el/states.php) for the
DISPLAYED label only — the submitted `value` is always the untranslated
`->{$valueField}`, since table-rate-shipping/Lunar's Country lookups key
off the original English name. Falls back to the raw name when no
translation exists for the current locale (e.g. English, or a country
outside the covered set).
--}}
@props([
'name',
'label',
'options' => [],
'value' => null,
'placeholder' => null,
'required' => false,
'valueField' => 'id',
'translationGroup' => null,
])
@php
$optionLabel = function ($option) use ($translationGroup) {
if (! $translationGroup) {
return $option->name;
}
$key = "core::{$translationGroup}.{$option->name}";
return \Illuminate\Support\Facades\Lang::has($key) ? __($key) : $option->name;
};
@endphp
@php($selected = old($name, $value))
<div class="bbk-field">
<label class="bbk-field-label" for="bbk-{{ $name }}">{{ $label }}</label>
<select
name="{{ $name }}"
id="bbk-{{ $name }}"
@if ($required) required @endif
{{ $attributes->class(['bbk-field-input', 'bbk-field-input--error' => $errors->has($name)]) }}
>
@if ($placeholder)
<option value="" @selected(! $selected)>{{ $placeholder }}</option>
@endif
@foreach ($options as $option)
<option value="{{ $option->{$valueField} }}" @selected((string) $selected === (string) $option->{$valueField})>
{{ $optionLabel($option) }}
</option>
@endforeach
</select>
<p class="bbk-field-error" data-bbk-field-error="{{ $name }}" @unless ($errors->has($name)) hidden @endunless>{{ $errors->first($name) }}</p>
</div>
@@ -1,18 +0,0 @@
@props([
'name',
'label',
'value' => null,
'required' => false,
])
<div class="bbk-field">
<label class="bbk-field-label" for="bbk-{{ $name }}">{{ $label }}</label>
<textarea
name="{{ $name }}"
id="bbk-{{ $name }}"
rows="3"
@if ($required) required @endif
{{ $attributes->class(['bbk-field-input', 'bbk-field-input--error' => $errors->has($name)]) }}
>{{ old($name, $value) }}</textarea>
<p class="bbk-field-error" data-bbk-field-error="{{ $name }}" @unless ($errors->has($name)) hidden @endunless>{{ $errors->first($name) }}</p>
</div>
@@ -1,84 +0,0 @@
{{--
Order confirmation. Reached only via a session flash of the placed order id
(CheckoutController::confirmation) — not deep-linkable. $order is a
Lunar\Models\Order with lines + shipping/billing addresses eager-loaded.
--}}
@extends('layouts.app')
@section('title', __('checkout.page.confirmation_title') . ' — ' . config('app.name'))
@section('content')
<div class="bbk-confirmation">
<h1 class="bbk-confirmation-heading">{{ __('checkout.page.confirmation_heading') }}</h1>
<p class="bbk-confirmation-ref">
{{ __('checkout.page.confirmation_order_number') }}: <strong>{{ $order->reference }}</strong>
</p>
<p class="bbk-checkout-note">{{ __('checkout.page.confirmation_email_note') }}</p>
<div class="bbk-confirmation-body">
<div class="bbk-confirmation-lines">
@foreach ($order->lines->where('type', '!=', 'shipping') as $line)
<div class="bbk-confirmation-line">
<span class="bbk-confirmation-line-name">
{{ $line->description }}
<span class="bbk-confirmation-line-qty">&times; {{ $line->quantity }}</span>
</span>
<span class="bbk-confirmation-line-total">{{ $line->sub_total?->formatted() }}</span>
</div>
@endforeach
<div class="bbk-cart-summary">
<div class="bbk-cart-summary-row">
<span>{{ __('checkout.cart.subtotal') }}</span>
<span>{{ $order->sub_total?->formatted() }}</span>
</div>
@if ($order->discount_total?->value > 0)
<div class="bbk-cart-summary-row bbk-cart-summary-row--discount">
<span>{{ __('checkout.cart.discount') }}</span>
<span>&minus;{{ $order->discount_total->formatted() }}</span>
</div>
@endif
<div class="bbk-cart-summary-row">
<span>{{ __('checkout.cart.shipping') }}</span>
<span>{{ $order->shipping_total?->formatted() }}</span>
</div>
@if ($order->tax_total?->value > 0)
<div class="bbk-cart-summary-row">
<span>{{ __('checkout.cart.tax') }}</span>
<span>{{ $order->tax_total->formatted() }}</span>
</div>
@endif
<div class="bbk-cart-summary-row bbk-cart-summary-row--total">
<span>{{ __('checkout.cart.total') }}</span>
<span>{{ $order->total?->formatted() }}</span>
</div>
</div>
</div>
<div class="bbk-confirmation-addresses">
@if ($order->shippingAddress)
<div class="bbk-confirmation-address">
<h2 class="bbk-confirmation-address-heading">{{ __('checkout.page.confirmation_shipping_to') }}</h2>
<x-checkout::address-lines :address="$order->shippingAddress" />
</div>
@endif
@if ($order->billingAddress)
<div class="bbk-confirmation-address">
<h2 class="bbk-confirmation-address-heading">{{ __('checkout.page.confirmation_billing') }}</h2>
<x-checkout::address-lines :address="$order->billingAddress" />
</div>
@endif
</div>
</div>
<a class="bbk-checkout-continue bbk-confirmation-continue" href="{{ route('products', app()->getLocale()) }}">
{{ __('checkout.page.confirmation_continue') }}
</a>
</div>
@endsection
-31
View File
@@ -1,31 +0,0 @@
{{--
Slide-in cart drawer. Rendered once, globally, from the app layout
(@include('checkout::drawer')). Structure only — all styling lives in
resources/css/checkout.css under @layer bbk-checkout; the host restyles the
.bbk-* classes from its own stylesheet. No host components, no Tailwind.
--}}
<div class="bbk-cart" data-controller="bbk-cart" hidden>
<div class="bbk-cart-backdrop" data-action="bbk-cart#close"></div>
<aside
class="bbk-cart-panel"
role="dialog"
aria-modal="true"
aria-labelledby="bbk-cart-heading"
data-bbk-cart-target="panel"
>
<header class="bbk-cart-panel-header">
<h2 class="bbk-cart-heading" id="bbk-cart-heading">{{ __('checkout.cart.title') }}</h2>
<button
type="button"
class="bbk-cart-dismiss"
data-action="bbk-cart#close"
aria-label="{{ __('checkout.cart.close') }}"
>&times;</button>
</header>
<div class="bbk-cart-panel-body" data-bbk-cart-target="body" aria-live="polite">
@include('checkout::partials.cart-body')
</div>
</aside>
</div>
-287
View File
@@ -1,287 +0,0 @@
{{--
The checkout page. Two columns: left is contact + billing + shipping +
shipping method, right is the order summary (the same cart-body partial the
drawer uses, minus its own "Checkout" CTA — see .bbk-checkout-summary in
checkout.css). Stops short of payment for this slice — see
CheckoutController's class docblock.
$cart, $lines, $billingAddress, $shippingAddress, $shippingOptions,
$countries come from CheckoutController::show().
--}}
@extends('layouts.app')
@section('title', __('checkout.page.title') . ' — ' . config('app.name'))
@section('content')
<div class="bbk-checkout-page">
<h1 class="bbk-checkout-heading">{{ __('checkout.page.title') }}</h1>
<div class="bbk-checkout">
<div
class="bbk-checkout-main"
data-controller="bbk-checkout-form bbk-payment"
data-action="input->bbk-checkout-form#scheduleSave"
data-bbk-checkout-form-save-url-value="{{ route('checkout.address.save', app()->getLocale()) }}"
data-bbk-checkout-form-select-shipping-url-value="{{ route('checkout.shipping-option.select', app()->getLocale()) }}"
data-bbk-checkout-form-status-saving-value="{{ __('checkout.page.saving') }}"
data-bbk-checkout-form-status-saved-value="{{ __('checkout.page.saved') }}"
data-bbk-checkout-form-status-error-value="{{ __('checkout.page.save_error') }}"
data-bbk-payment-select-url-value="{{ route('checkout.payment-method.select', app()->getLocale()) }}"
data-bbk-payment-place-order-url-value="{{ route('checkout.place-order', app()->getLocale()) }}"
data-bbk-payment-order-status-url-value="{{ route('checkout.order-status', app()->getLocale()) }}"
data-bbk-payment-stripe-key-value="{{ config('services.stripe.public_key') }}"
data-bbk-payment-amount-value="{{ $cart?->total?->value ?? 0 }}"
data-bbk-payment-currency-value="{{ strtolower($cart?->total?->currency?->code ?? 'eur') }}"
data-bbk-payment-terms-required-value="{{ __('checkout.page.terms_required') }}"
data-bbk-payment-choose-method-value="{{ __('checkout.page.choose_payment_method') }}"
data-bbk-payment-generic-error-value="{{ __('checkout.page.payment_failed') }}"
data-bbk-payment-processing-slow-value="{{ __('checkout.page.payment_processing_slow') }}"
>
{{-- Contact --}}
<section class="bbk-checkout-section">
<div
class="bbk-checkout-tabs"
role="tablist"
aria-label="{{ __('checkout.page.contact_heading') }}"
>
<button
type="button"
class="bbk-checkout-tab"
role="tab"
aria-selected="true"
data-bbk-checkout-form-target="guestTab"
data-action="bbk-checkout-form#showGuest"
>{{ __('checkout.page.guest_tab') }}</button>
<button
type="button"
class="bbk-checkout-tab"
role="tab"
aria-selected="false"
data-bbk-checkout-form-target="loginTab"
data-action="bbk-checkout-form#showLogin"
>{{ __('checkout.page.login_tab') }}</button>
<div class="bbk-checkout-tab-panel" role="tabpanel" data-bbk-checkout-form-target="guestPanel">
<x-checkout::field
name="contact_email"
label="{{ __('checkout.page.email_label') }}"
type="email"
:value="$shippingAddress?->contact_email ?? $billingAddress?->contact_email"
required
form="bbk-address-form"
/>
{{-- Abandoned-cart-recovery opt-in. Optional, unticked, never
required — direct marketing under ePrivacy (GR L. 3471/2006
art. 11), so it needs an explicit opt-in and checkout can't be
gated on it. Narrow scope by design (boboko-core's
setRecoveryConsent) — a general newsletter opt-in, if wanted,
is a separate checkbox. --}}
<label class="bbk-checkbox bbk-checkbox--stacked">
<input
type="checkbox"
name="recovery_consent"
value="1"
form="bbk-address-form"
{{ old('recovery_consent', data_get($cart, 'meta.recovery_consent')) ? 'checked' : '' }}
>
{{ __('checkout.page.recovery_consent') }}
</label>
</div>
{{-- Not wired yet — Modules\Core\Auth\Services\UserOtpService exists
(email + one-time code, passwordless) but nothing in the storefront
calls it yet. UI placeholder only; see project notes. --}}
<div class="bbk-checkout-tab-panel" role="tabpanel" hidden data-bbk-checkout-form-target="loginPanel">
<div class="bbk-field">
<label class="bbk-field-label" for="bbk-login-email">{{ __('checkout.page.login_email_label') }}</label>
<input type="email" id="bbk-login-email" class="bbk-field-input" disabled>
</div>
<button type="button" class="bbk-checkout-continue" disabled>
{{ __('checkout.page.send_code') }}
</button>
<p class="bbk-checkout-note">{{ __('checkout.page.login_coming_soon') }}</p>
</div>
</div>
</section>
{{-- Autosaves — no submit button. Any `change` inside .bbk-checkout-main
(this form, plus the contact email/consent which sit outside it but
link via form="bbk-address-form") is debounced and POSTed as the whole
form; the shipping-method radios are excluded in scheduleSave(). --}}
<form
id="bbk-address-form"
method="POST"
action="{{ route('checkout.address.save', app()->getLocale()) }}"
data-bbk-checkout-form-target="form"
>
@csrf
{{-- Billing --}}
<section class="bbk-checkout-section">
<h2 class="bbk-checkout-section-heading">{{ __('checkout.page.billing_heading') }}</h2>
<div class="bbk-field-row">
<x-checkout::field name="billing_first_name" label="{{ __('checkout.page.first_name') }}" :value="$billingAddress?->first_name" required />
<x-checkout::field name="billing_last_name" label="{{ __('checkout.page.last_name') }}" :value="$billingAddress?->last_name" required />
</div>
<div class="bbk-field-row">
<x-checkout::field name="billing_company_name" label="{{ __('checkout.page.company_name') }}" :value="$billingAddress?->company_name" />
<x-checkout::field name="billing_tax_identifier" label="{{ __('checkout.page.tax_identifier') }}" :value="$billingAddress?->tax_identifier" />
</div>
<x-checkout::field name="billing_line_one" label="{{ __('checkout.page.address_line_one') }}" :value="$billingAddress?->line_one" required />
<x-checkout::field name="billing_line_two" label="{{ __('checkout.page.address_line_two') }}" :value="$billingAddress?->line_two" />
<div class="bbk-field-row">
<x-checkout::field name="billing_city" label="{{ __('checkout.page.city') }}" :value="$billingAddress?->city" required />
<x-checkout::field name="billing_postcode" label="{{ __('checkout.page.postcode') }}" :value="$billingAddress?->postcode" required />
</div>
<x-checkout::region-country
prefix="billing"
:store-country="$storeCountry"
:regions="$regions"
:countries="$countries"
:address="$billingAddress"
/>
<x-checkout::field name="billing_contact_phone" label="{{ __('checkout.page.phone') }}" type="tel" :value="$billingAddress?->contact_phone" />
</section>
{{-- Shipping --}}
<section class="bbk-checkout-section">
<h2 class="bbk-checkout-section-heading">{{ __('checkout.page.shipping_heading') }}</h2>
<label class="bbk-checkbox">
<input
type="checkbox"
name="same_as_billing"
value="1"
data-bbk-checkout-form-target="sameAsBilling"
data-action="bbk-checkout-form#toggleSameAsBilling"
@checked($shipToBilling)
>
{{ __('checkout.page.same_as_billing') }}
</label>
<div class="bbk-checkout-shipping-fields" data-bbk-checkout-form-target="shippingFields">
<div class="bbk-field-row">
<x-checkout::field name="shipping_first_name" label="{{ __('checkout.page.first_name') }}" :value="$shippingAddress?->first_name" required />
<x-checkout::field name="shipping_last_name" label="{{ __('checkout.page.last_name') }}" :value="$shippingAddress?->last_name" required />
</div>
<x-checkout::field name="shipping_company_name" label="{{ __('checkout.page.company_name') }}" :value="$shippingAddress?->company_name" />
<x-checkout::field name="shipping_line_one" label="{{ __('checkout.page.address_line_one') }}" :value="$shippingAddress?->line_one" required />
<x-checkout::field name="shipping_line_two" label="{{ __('checkout.page.address_line_two') }}" :value="$shippingAddress?->line_two" />
<div class="bbk-field-row">
<x-checkout::field name="shipping_city" label="{{ __('checkout.page.city') }}" :value="$shippingAddress?->city" required />
<x-checkout::field name="shipping_postcode" label="{{ __('checkout.page.postcode') }}" :value="$shippingAddress?->postcode" required />
</div>
<x-checkout::region-country
prefix="shipping"
:store-country="$storeCountry"
:regions="$regions"
:countries="$countries"
:address="$shippingAddress"
/>
<x-checkout::field name="shipping_contact_phone" label="{{ __('checkout.page.phone') }}" type="tel" :value="$shippingAddress?->contact_phone" />
</div>
<x-checkout::textarea
name="shipping_delivery_instructions"
label="{{ __('checkout.page.delivery_instructions') }}"
:value="$shippingAddress?->delivery_instructions"
/>
</section>
</form>
<p
class="bbk-checkout-status"
data-bbk-checkout-form-target="status"
role="status"
aria-live="polite"
hidden
></p>
{{-- Shipping method — resolves from the saved shipping address;
re-rendered as a fragment by bbk-checkout-form after each
autosave / option change. --}}
<section class="bbk-checkout-section">
<h2 class="bbk-checkout-section-heading">{{ __('checkout.page.shipping_method_heading') }}</h2>
<div id="bbk-shipping-options" data-bbk-checkout-form-target="shippingOptions">
@include('checkout::partials.shipping-options', [
'shippingAddress' => $shippingAddress,
'shippingOptions' => $shippingOptions,
])
</div>
</section>
{{-- Payment --}}
<section class="bbk-checkout-section">
<h2 class="bbk-checkout-section-heading">{{ __('checkout.page.payment_heading') }}</h2>
<div id="bbk-payment-methods">
@include('checkout::partials.payment-methods', [
'paymentMethods' => $paymentMethods,
'cart' => $cart,
])
</div>
{{-- Stripe Payment Element mounts here when a Stripe method is picked. --}}
<div class="bbk-payment-element" data-bbk-payment-target="element" hidden></div>
<label class="bbk-checkbox bbk-checkbox--stacked">
<input type="checkbox" data-bbk-payment-target="terms">
{!! __('checkout.page.terms_accept', [
'terms' => route('legal.terms', app()->getLocale()),
'privacy' => route('legal.privacy', app()->getLocale()),
]) !!}
</label>
<p class="bbk-checkout-withdrawal">
{!! __('checkout.page.withdrawal_notice', [
'link' => route('legal.shipping-returns', app()->getLocale()),
]) !!}
</p>
<p class="bbk-checkout-error" data-bbk-payment-target="error" role="alert" hidden></p>
<button
type="button"
class="bbk-checkout-continue"
data-bbk-payment-target="submit"
data-action="bbk-payment#placeOrder"
>
{{ __('checkout.page.place_order') }}
</button>
</section>
{{-- Fixed overlay while a payment is confirming (3-D Secure / webhook
poll). Inside .bbk-checkout-main so bbk-payment can target it. --}}
<div class="bbk-checkout-processing" data-bbk-payment-target="processing" hidden>
<span class="bbk-spinner" aria-hidden="true"></span>
<p data-bbk-payment-target="processingText">{{ __('checkout.page.payment_processing') }}</p>
</div>
</div>
<aside class="bbk-checkout-aside">
<div class="bbk-checkout-summary" data-controller="bbk-cart">
<h2 class="bbk-checkout-summary-heading">{{ __('checkout.page.order_summary_heading') }}</h2>
<div data-bbk-cart-target="body" aria-live="polite">
@include('checkout::partials.cart-body')
</div>
</div>
</aside>
</div>
</div>
@endsection
@@ -1,121 +0,0 @@
{{--
Server-rendered cart contents. Rendered inline on first page load inside
checkout/drawer.blade.php, and re-fetched + swapped into the drawer by
bbk-cart-controller after every mutation. $cart / $lines come from the view
composer in CheckoutModuleServiceProvider.
The data-bbk-cart-* attributes on the root are the module's read API for the
host (e.g. the header bag-icon count) — bbk-cart-controller reads them after
each swap and re-emits them on the `bbk-cart:updated` window event.
--}}
@php($count = $lines->sum('quantity'))
{{-- @dump($lines) --}}
<div
class="bbk-cart-content"
data-bbk-cart-count="{{ $count }}"
data-bbk-cart-total="{{ $cart?->total?->value ?? 0 }}"
>
@if ($lines->isEmpty())
<p class="bbk-cart-empty">{{ __('checkout.cart.empty') }}</p>
@else
<ul class="bbk-cart-items">
@each('checkout::partials.cart-line', $lines, 'line')
</ul>
<div class="bbk-cart-summary">
<div class="bbk-cart-coupon">
@if ($cart?->coupon_code)
<div class="bbk-cart-coupon-applied">
<span class="bbk-cart-coupon-code">{{ $cart->coupon_code }}</span>
<form
method="POST"
action="{{ route('checkout.cart.coupon.remove', app()->getLocale()) }}"
data-action="submit->bbk-cart#submit"
>
@csrf
@method('DELETE')
<button type="submit" class="bbk-cart-coupon-remove">
{{ __('checkout.cart.coupon_remove') }}
</button>
</form>
</div>
@else
<form
class="bbk-cart-coupon-form"
method="POST"
action="{{ route('checkout.cart.coupon.apply', app()->getLocale()) }}"
data-action="submit->bbk-cart#submit"
>
@csrf
<label class="bbk-visually-hidden" for="bbk-coupon-code">
{{ __('checkout.cart.coupon_label') }}
</label>
<input
type="text"
name="code"
id="bbk-coupon-code"
class="bbk-cart-coupon-input"
placeholder="{{ __('checkout.cart.coupon_placeholder') }}"
autocomplete="off"
required
>
<button type="submit" class="bbk-cart-coupon-submit">
{{ __('checkout.cart.coupon_apply') }}
</button>
</form>
@if ($couponError ?? false)
<p class="bbk-cart-coupon-error" role="alert">{{ __('checkout.cart.coupon_invalid') }}</p>
@endif
@endif
</div>
<div class="bbk-cart-summary-row">
<span>{{ __('checkout.cart.subtotal') }}</span>
<span>{{ $cart?->subTotal?->formatted() }}</span>
</div>
@if ($cart?->discountTotal?->value > 0)
<div class="bbk-cart-summary-row bbk-cart-summary-row--discount">
<span>{{ __('checkout.cart.discount') }}</span>
<span>&minus;{{ $cart->discountTotal->formatted() }}</span>
</div>
@endif
{{-- Shipping + tax appear once the shopper has a shipping address
(i.e. they're on the checkout page). In the drawer, where no
address is set yet, only subtotal + total show. --}}
@if ($cart?->shippingAddress)
<div class="bbk-cart-summary-row">
<span>{{ __('checkout.cart.shipping') }}</span>
@if ($cart->shippingAddress->shipping_option)
<span>{{ $cart->shippingTotal?->formatted() }}</span>
@else
<span class="bbk-cart-summary-pending">{{ __('checkout.cart.shipping_pending') }}</span>
@endif
</div>
@endif
@if ($cart?->taxTotal?->value > 0)
<div class="bbk-cart-summary-row">
<span>{{ __('checkout.cart.tax') }}</span>
<span>{{ $cart->taxTotal->formatted() }}</span>
</div>
@endif
{{-- Always shown — equals subtotal with nothing else applied,
diverges as discount / shipping / tax come in. --}}
<div class="bbk-cart-summary-row bbk-cart-summary-row--total">
<span>{{ __('checkout.cart.total') }}</span>
<span>{{ $cart?->total?->formatted() }}</span>
</div>
<a class="bbk-cart-checkout" href="{{ route('checkout.show', app()->getLocale()) }}">
{{ __('checkout.cart.checkout') }}
</a>
</div>
@endif
</div>
@@ -1,77 +0,0 @@
{{--
One cart line. $line is a Lunar\Models\CartLine (iteration var set by
@each in cart-body). The two forms post through bbk-cart-controller
(fetch + method spoofing) and the response re-renders cart-body.
--}}
@php
$variant = $line->purchasable;
$product = $variant?->product;
$name = $product?->translateAttribute('name') ?? $variant?->sku ?? '—';
$thumb = $product?->getThumbnailImage() ?: null;
@endphp
<li class="bbk-cart-item" data-bbk-line-id="{{ $line->id }}">
<div class="bbk-cart-item-media">
@if ($thumb)
<img src="{{ $thumb }}" alt="{{ $name }}" width="72" height="72" loading="lazy">
@endif
</div>
<div class="bbk-cart-item-detail">
<p class="bbk-cart-item-title">{{ $name }}</p>
<p class="bbk-cart-item-unit">{{ $line->unitPrice?->formatted() }}</p>
<form
class="bbk-cart-qty"
method="POST"
action="{{ route('checkout.cart.update', ['locale' => app()->getLocale(), 'line' => $line->id]) }}"
>
@csrf
@method('PATCH')
<button
type="button"
class="bbk-cart-qty-btn"
data-action="bbk-cart#step"
data-bbk-cart-dir-param="-1"
aria-label="{{ __('checkout.cart.decrease') }}"
>&minus;</button>
<input
type="number"
name="quantity"
value="{{ $line->quantity }}"
min="0"
inputmode="numeric"
class="bbk-cart-qty-input"
data-action="change->bbk-cart#submit"
aria-label="{{ __('checkout.cart.quantity') }}"
>
<button
type="button"
class="bbk-cart-qty-btn"
data-action="bbk-cart#step"
data-bbk-cart-dir-param="1"
aria-label="{{ __('checkout.cart.increase') }}"
>+</button>
</form>
</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>
</li>
@@ -1,30 +0,0 @@
{{--
Payment method radios. $paymentMethods is Collection<Modules\Core\Payment\
Models\PaymentMethod> from CheckoutService::getPaymentMethods() (already
filtered to enabled + driver-resolves + isConfigured()). Selecting one
autosaves via bbk-payment#selectMethod; `data-payment-driver` tells the
controller whether to mount the Stripe Element.
$paymentMethods, $cart come from the page / controller.
--}}
@php($selected = $cart?->meta['payment_method'] ?? null)
@if ($paymentMethods->isEmpty())
<p class="bbk-checkout-note">{{ __('checkout.page.payment_method_none') }}</p>
@else
<div class="bbk-checkout-payment-options">
@foreach ($paymentMethods as $method)
<label class="bbk-checkout-payment-option">
<input
type="radio"
name="payment_type"
value="{{ $method->type }}"
data-payment-driver="{{ $method->driver }}"
@checked($selected === $method->type)
data-action="change->bbk-payment#selectMethod"
>
<span class="bbk-checkout-payment-option-name">{{ $method->translate('name') }}</span>
</label>
@endforeach
</div>
@endif
@@ -1,106 +0,0 @@
{{--
Shipping methods for the checkout page. Rendered inline by page.blade.php on
load, and re-rendered as a fragment by CheckoutController after every
address save / option change (bbk-checkout-form swaps it in). Radios
autosave via bbk-checkout-form#selectShipping — no submit button. A single
resolved option is auto-selected server-side and shown as a fixed line.
$shippingAddress, $shippingOptions come from the controller / page scope.
--}}
@php($selected = $shippingAddress?->shipping_option)
{{-- Rate resolution needs country (always Greece here) + postcode; until a
postcode is saved there's nothing to quote against yet. --}}
@if (! $shippingAddress?->postcode)
<p class="bbk-checkout-note">{{ __('checkout.page.shipping_method_empty') }}</p>
@elseif ($shippingOptions->isEmpty())
<p class="bbk-checkout-note">{{ __('checkout.page.shipping_method_none') }}</p>
@elseif ($shippingOptions->count() === 1)
@php($only = $shippingOptions->first())
<div class="bbk-checkout-shipping-confirmed">
<span class="bbk-checkout-shipping-option-detail">
<span class="bbk-checkout-shipping-option-name">{{ $only->name }}</span>
@if ($only->description)
<span class="bbk-checkout-shipping-option-description">{{ strip_tags($only->description) }}</span>
@endif
</span>
<span class="bbk-checkout-shipping-option-price">{{ $only->price->formatted() }}</span>
</div>
@else
<div class="bbk-checkout-shipping-options">
@foreach ($shippingOptions as $option)
<label class="bbk-checkout-shipping-option">
<input
type="radio"
name="shipping_option"
value="{{ $option->identifier }}"
@checked($selected === $option->identifier)
data-action="change->bbk-checkout-form#selectShipping"
>
<span class="bbk-checkout-shipping-option-detail">
<span class="bbk-checkout-shipping-option-name">{{ $option->name }}</span>
@if ($option->description)
<span class="bbk-checkout-shipping-option-description">{{ strip_tags($option->description) }}</span>
@endif
</span>
<span class="bbk-checkout-shipping-option-price">{{ $option->price->formatted() }}</span>
</label>
@endforeach
</div>
@endif
{{--
Dummy Box Now locker picker — a Leaflet map standing in for Box Now's own
Destination Map JS widget, which only talks to their Production API (not
Stage/sandbox — see their Partner API manual §4.1), making it useless
for local/staging development. Backed by the same GET /destinations data
(including lat/lng) via CheckoutController::boxNowLockers(). Only shown
once the "box-now" shipping option is selected (bbk-checkout-form
toggles [hidden] on shipping-option change; see bbk-box-now-locker
Stimulus controller). Persists the choice via a separate autosave POST
(checkout.box-now.locker.select) rather than piggybacking on the
shipping-option field, since the two are independent pieces of state
(method vs. destination) that CheckoutService models as two calls
(selectShippingOption() / selectBoxNowLocker()).
--}}
<div
id="bbk-box-now-locker"
class="bbk-checkout-box-now-locker"
data-controller="bbk-box-now-locker"
data-bbk-box-now-locker-lockers-url-value="{{ route('checkout.box-now.lockers', app()->getLocale()) }}"
data-bbk-box-now-locker-select-url-value="{{ route('checkout.box-now.locker.select', app()->getLocale()) }}"
data-bbk-box-now-locker-loading-value="{{ __('checkout.page.box_now_locker_loading') }}"
data-bbk-box-now-locker-select-label-value="{{ __('checkout.page.box_now_locker_select') }}"
data-bbk-box-now-locker-selected-label-value="{{ __('checkout.page.box_now_locker_selected') }}"
data-bbk-box-now-locker-no-results-value="{{ __('checkout.page.box_now_locker_no_results') }}"
@if ($selected !== 'box-now') hidden @endif
>
<p class="bbk-checkout-box-now-locker-label">
{{ __('checkout.page.box_now_locker_label') }}
</p>
<input
type="search"
class="bbk-checkout-box-now-locker-search"
placeholder="{{ __('checkout.page.box_now_locker_search') }}"
data-bbk-box-now-locker-target="search"
data-action="input->bbk-box-now-locker#search"
autocomplete="off"
>
<div
id="bbk-box-now-locker-map"
class="bbk-checkout-box-now-locker-map"
data-bbk-box-now-locker-target="map"
></div>
<p
class="bbk-checkout-box-now-locker-chosen"
data-bbk-box-now-locker-target="chosen"
hidden
></p>
<p
class="bbk-checkout-status"
data-bbk-box-now-locker-target="status"
role="status"
aria-live="polite"
hidden
></p>
</div>
@@ -41,6 +41,25 @@
</a>
@endforeach
{{-- Wishlist — the account page, or the cookie-based guest page --}}
<a
href="{{ auth()->check() ? route('account.wishlist') : route('wishlist') }}"
class="flex items-center justify-center w-10 h-10 hover:opacity-70 transition-opacity"
aria-label="{{ __('storefront.account.nav_wishlist') }}"
@if (request()->routeIs('account.wishlist', 'wishlist')) aria-current="page" @endif
>
<x-ui.icon name="heart" :size="40" />
</a>
{{-- Account — the account page, or login for guests --}}
<a
href="{{ auth()->check() ? route('account') : route('login') }}"
class="flex items-center justify-center w-10 h-10 hover:opacity-70 transition-opacity"
aria-label="{{ auth()->check() ? __('storefront.nav.account') : __('storefront.auth.login') }}"
>
<x-ui.icon name="user" :size="40" />
</a>
{{-- Cart — opens the checkout module's drawer, no separate cart page --}}
<button
type="button"
@@ -3,10 +3,10 @@
])
<section {{ $attributes }}>
<div class="max-w-6xl mx-auto grid grid-cols-1 lg:grid-cols-2 border border-black">
<div class="flex flex-col justify-center gap-8 px-8 py-16 lg:px-16">
<div class="grid grid-cols-1 lg:grid-cols-2">
<div class="flex flex-col justify-center gap-8 px-8 py-16 lg:px-16 items-center">
<h2
class="text-h2 leading-tight"
class="text-h1 leading-[1.3] max-w-xl text-center"
aria-label="Γράψου στο newsletter μας"
data-controller="appear"
data-appear-visible-class="is-visible"
@@ -17,13 +17,13 @@ class="text-h2 leading-tight"
μας
</h2>
<p class="max-w-md text-neutral-600">
<p class="max-w-xl text-neutral-600 text-center">
Γίνε μέλος της λίστας μας και κέρδισε <span class="font-bold">5% έκπτωση</span> στην πρώτη σου παραγγελία, μαζί με αποκλειστικές προσφορές και νέα.
</p>
<form method="POST" action="#" class="flex flex-col gap-6 max-w-md">
<form method="POST" action="#" class="flex flex-col gap-6 max-w-xl items-center">
@csrf
<div class="relative">
<div class="relative max-w-xs w-full">
<x-ui.input
name="email"
type="email"
@@ -44,7 +44,7 @@ class="absolute right-0 bottom-2 cursor-pointer"
</div>
<x-ui.checkbox name="gdpr_consent" :required="true" id="newsletter_split_gdpr_consent" class="after:mix-blend-multiply">
<span class="text-sm text-left">Επιθυμώ διακαώς 🔥 να εγγραφώ στη λίστα αποστολής ενημερωτικού υλικού</span>
<span class="text-sm">Επιθυμώ διακαώς 🔥 να εγγραφώ στη λίστα αποστολής ενημερωτικού υλικού</span>
</x-ui.checkbox>
</form>
</div>
@@ -0,0 +1,13 @@
{{--
An order status as text, e.g. "Σε επεξεργασία". Translated through
storefront.order_status.{status}, falling back to Lunar's own (English)
label from config('lunar.orders.statuses') when that key doesn't exist.
--}}
@props(['status'])
@php
$key = "storefront.order_status.{$status}";
$label = Lang::has($key) ? __($key) : config("lunar.orders.statuses.{$status}.label", $status);
@endphp
<span {{ $attributes }}>{{ $label }}</span>
@@ -0,0 +1,77 @@
{{--
<x-product-custom-fields :fields="$product['custom_fields']" />
The product's custom fields (boboko-core's Product::$custom_fields), rendered
inside the add-to-cart <form> so their values travel with it as
custom_fields[key]. text → input, textarea → textarea, file → photo upload.
A photo is uploaded as soon as it's picked (custom-field-upload-controller.js)
and only the resulting File row's id (boboko-core's Modules\Core\File\
Models\File) is submitted with the form — the file input itself has no
name. It still carries `required`, so the browser's own validation blocks
add-to-cart until a photo is picked, and the controller marks it invalid
(setCustomValidity) while the upload is in flight.
--}}
@props(['fields' => []])
@use('App\Http\Controllers\CustomFieldUploadController')
<div class="flex flex-col gap-6">
@foreach ($fields as $field)
@php
$id = 'custom-field-'.$field['key'];
$name = 'custom_fields['.$field['key'].']';
$required = (bool) ($field['required'] ?? false);
@endphp
@switch($field['type'])
@case('textarea')
<x-ui.field :label="$field['label']" :for="$id" :required="$required">
<x-ui.textarea :id="$id" :name="$name" :required="$required" :rows="4" maxlength="2000" />
</x-ui.field>
@break
@case('file')
<x-ui.field
:label="$field['label']"
:for="$id"
:required="$required"
:description="__('storefront.product.custom_field_photo_hint', ['size' => CustomFieldUploadController::MAX_KILOBYTES / 1024])"
data-controller="custom-field-upload"
data-custom-field-upload-url-value="{{ route('custom-field-upload.store') }}"
data-custom-field-upload-label-value="{{ $field['label'] }}"
data-custom-field-upload-uploading-message-value="{{ __('storefront.product.custom_field_uploading') }}"
data-custom-field-upload-failed-message-value="{{ __('storefront.product.custom_field_upload_failed') }}"
>
<input type="hidden" name="{{ $name }}" data-custom-field-upload-target="reference">
<x-ui.file-input
:id="$id"
:accept="CustomFieldUploadController::accept()"
:required="$required"
aria-describedby="{{ $id }}-description {{ $id }}-error"
data-custom-field-upload-target="file"
data-action="change->custom-field-upload#upload"
/>
<img
alt=""
width="96"
height="96"
hidden
class="w-24 h-24 object-cover border border-black"
data-custom-field-upload-target="preview"
data-action="error->custom-field-upload#hidePreview"
>
<p id="{{ $id }}-error" class="text-sm text-red-600" role="alert" hidden data-custom-field-upload-target="error"></p>
</x-ui.field>
@break
@default
<x-ui.field :label="$field['label']" :for="$id" :required="$required">
<x-ui.input :id="$id" :name="$name" :required="$required" maxlength="255" />
</x-ui.field>
@endswitch
@endforeach
</div>
@@ -24,6 +24,9 @@
:price="$product['price'] ?? null"
:image="$product['image'] ?? null"
:href="$product['href'] ?? '#'"
:variant-id="$product['variantId'] ?? null"
:has-custom-fields="$product['hasCustomFields'] ?? false"
:sold-out="$product['soldOut'] ?? false"
/>
@endforeach
</div>
@@ -1,26 +1,39 @@
@props(['review'])
<article class="flex flex-col gap-3 py-6 border-b border-black last:border-0">
<article class="flex flex-col gap-3 py-6">
<div class="flex items-center justify-between gap-4">
<div class="flex items-center gap-4">
<x-reviews-stars :rating="$review['rating']" :size="18" />
<span class="font-bold">
{{ $review['name'] ?? 'Ανώνυμος' }}
</span>
</div>
<time class="text-sm text-neutral-500 shrink-0">{{ $review['date'] }}</time>
<x-reviews-stars :rating="$review['rating']" :size="23" />
<div class="flex items-end gap-4">
<span class="text-2xl font-bold leading-none">
{{ $review['name'] ?? 'Ανώνυμος' }}
</span>
<time class="text-sm text-neutral-500 shrink-0 leading-none pb-0.5">{{ $review['date'] }}</time>
</div>
<p class="leading-relaxed">{{ $review['text'] }}</p>
@if (!empty($review['image']))
<div class="mt-1">
<img
src="{{ $review['image'] }}"
alt="Φωτογραφία από τον αγοραστή"
class="w-24 h-24 object-cover border border-black"
>
<a href="{{ $review['image'] }}" target="_blank" rel="noopener noreferrer">
<img
src="{{ $review['image'] }}"
alt="Φωτογραφία από τον αγοραστή"
class="w-24 h-24 object-cover border border-black"
>
</a>
</div>
@endif
@if (!empty($review['reply']))
<div class="mt-2 pl-4 border-l-2 border-neutral-300 flex flex-col gap-1">
<p class="text-sm font-bold">
{{ __('storefront.review.reply') }}
@if (!empty($review['replyDate']))
<span class="font-normal text-neutral-500">— {{ $review['replyDate'] }}</span>
@endif
</p>
<p class="leading-relaxed">{{ $review['reply'] }}</p>
</div>
@endif
@@ -1,23 +1,28 @@
@props(['product'])
@php $oldRating = (int) old('rating', 0); @endphp
<form
method="POST"
action="#"
action="{{ route('product.reviews.store', ['locale' => app()->getLocale(), 'product' => $product['id']]) }}"
class="flex flex-col gap-8 mt-10"
data-controller="star-rating"
data-star-rating-rating-value="{{ $oldRating }}"
data-action="submit->star-rating#validate"
>
@csrf
{{-- Rating --}}
<x-ui.field :label="__('storefront.review.rating')" for="rating" :required="true">
<x-ui.field :label="__('storefront.review.rating')" for="rating" :required="true" :error="$errors->first('rating')">
<div
id="rating"
data-controller="star-rating"
class="flex items-center gap-1.5 text-brand"
role="radiogroup"
aria-label="{{ __('storefront.review.rating') }}"
aria-required="true"
aria-describedby="rating-client-error"
>
<input type="hidden" name="rating" value="0" data-star-rating-target="input">
<input type="hidden" name="rating" value="{{ $oldRating }}" data-star-rating-target="input">
@for($i = 1; $i <= 5; $i++)
<button
type="button"
@@ -25,7 +30,7 @@ class="flex items-center gap-1.5 text-brand"
data-value="{{ $i }}"
data-action="click->star-rating#select mouseenter->star-rating#hover mouseleave->star-rating#leave"
aria-label="{{ trans_choice('storefront.review.stars_count', $i, ['count' => $i]) }}"
aria-pressed="false"
aria-pressed="{{ $i === $oldRating ? 'true' : 'false' }}"
>
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -41,24 +46,21 @@ class="w-6 h-6"
</button>
@endfor
</div>
<p id="rating-client-error" data-star-rating-target="error" class="text-sm text-red-600" role="alert" hidden>{{ __('storefront.review.rating_required') }}</p>
</x-ui.field>
<x-ui.field :label="__('storefront.review.write_label')" for="review-content" :required="true">
<x-ui.textarea id="review-content" name="content" :required="true" />
<x-ui.field :label="__('storefront.review.write_label')" for="review-content" :required="true" :error="$errors->first('content')">
<x-ui.textarea id="review-content" name="content" :required="true" :rows="6">{{ old('content') }}</x-ui.textarea>
</x-ui.field>
<x-ui.field :label="__('storefront.review.name')" for="review-name" :labelDescription="__('storefront.review.name_optional')">
<x-ui.input id="review-name" name="name" autocomplete="name" />
<x-ui.field :label="__('storefront.review.name')" for="review-name" :labelDescription="__('storefront.review.name_optional')" :error="$errors->first('name')">
<x-ui.input id="review-name" name="name" autocomplete="name" :value="old('name')" />
</x-ui.field>
<x-ui.field :label="__('storefront.review.email')" for="review-email" :required="true" :labelDescription="__('storefront.review.email_not_published')">
<x-ui.input id="review-email" name="email" type="email" :required="true" autocomplete="email" />
<x-ui.field :label="__('storefront.review.email')" for="review-email" :required="true" :labelDescription="__('storefront.review.email_not_published')" :error="$errors->first('email')">
<x-ui.input id="review-email" name="email" type="email" :required="true" autocomplete="email" :value="old('email')" />
</x-ui.field>
<x-ui.checkbox id="review-save-info" name="save_info" >
<span class="text-sm">{{ __('storefront.review.save_info') }}</span>
</x-ui.checkbox>
<div>
<x-ui.button type="submit">{{ __('storefront.review.submit') }}</x-ui.button>
</div>
@@ -3,6 +3,7 @@
'count' => 0,
'showCount' => false,
'size' => 24,
'linkable' => false,
])
<div class="flex items-center gap-3" {{ $attributes }}>
@@ -28,9 +29,17 @@ class="flex items-center gap-1.5 text-brand"
</div>
@if ($showCount && $count > 0)
<span class="text-sm text-neutral-500">
({{ trans_choice('storefront.customer_reviews', $count, ['count' => $count]) }})
</span>
@php $countText = '(' . trans_choice('storefront.customer_reviews', $count, ['count' => $count]) . ')'; @endphp
@if ($linkable)
<button
type="button"
data-action="click->tab-link#activate"
class="text-sm text-neutral-500 hover:underline cursor-pointer"
aria-controls="tab-panel-reviews"
>{{ $countText }}</button>
@else
<span class="text-sm text-neutral-500">{{ $countText }}</span>
@endif
@endif
</div>
+12 -1
View File
@@ -3,6 +3,7 @@
'href' => null,
'type' => 'button',
'size' => 'lg',
'variant' => 'primary',
'position' => 'relative',
])
@@ -15,6 +16,16 @@
default => 'py-5 px-[46px] text-[19px]',
};
// 'primary' is the CTA look (offset-shadow via .btn-primary's ::before/
// ::after, italic, uppercase). 'secondary' is a plain bordered toggle —
// no shadow layers, fills solid on hover/aria-pressed=true instead, used
// for option pickers (see x-ui.option-buttons) and anywhere else a
// secondary/toggle action shouldn't compete visually with the CTA.
$variantClasses = match($variant) {
'secondary' => 'font-semibold hover:bg-black hover:text-neutral-200 aria-pressed:bg-black aria-pressed:text-neutral-200 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-black',
default => 'btn-primary font-bold italic uppercase',
};
// $position defaults to 'relative' (needed so the ::before/::after layers
// in .btn-primary position against the button itself), but callers that
// need to place the button absolutely (e.g. a hover-reveal CTA over a
@@ -22,7 +33,7 @@
// "absolute" via the class prop — Tailwind's generated stylesheet always
// orders the "relative" utility after "absolute", so on a class clash
// "relative" silently wins and the button never actually gets positioned.
$class = 'btn-primary '.$position.' isolate inline-flex items-center justify-center border border-black font-display font-bold italic text-black cursor-pointer no-underline uppercase ' . $sizeClasses;
$class = trim($position.' isolate inline-flex items-center justify-center border border-black text-black cursor-pointer no-underline disabled:cursor-not-allowed disabled:opacity-50 '.$variantClasses.' '.$sizeClasses);
$attrs = $href
? $attributes->merge(['href' => $href, 'class' => $class])
@@ -1,12 +1,11 @@
{{-- $variants: array of Modules\Core\Catalog\Services\ProductIndexer's mapVariant()
shape (id, options: [{option, value, meta}], ...) — plain arrays, not Eloquent
models, since this is fed from Modules\Core\Catalog\Services\ProductService. --}}
@props(['variants', 'option' => null])
{{-- $values: one product option's de-duplicated, ordered values, as built by
ProductController::buildOptionPicker() — [{id, label, hex}]. --}}
@props(['values', 'option' => null, 'optionHandle' => null])
<div {{ $attributes }}>
@if($option)
<p class="font-bold mb-3 text-sm uppercase tracking-wide">
{{ $option }}: <span data-product-form-target="colorName" class="font-normal normal-case"></span>
{{ $option }}: <span data-product-form-target="colorName" data-option="{{ $optionHandle }}" class="font-normal normal-case"></span>
</p>
@endif
@@ -15,22 +14,17 @@ class="flex flex-wrap gap-2"
role="group"
aria-label="{{ $option ?? 'Color' }}"
>
@foreach($variants as $variant)
@php
$value = $variant['options'][0] ?? null;
$label = $value['value'] ?? '';
$bg = $value['meta']['hex'] ?? '#cccccc';
@endphp
@foreach($values as $value)
<button
type="button"
class="color-swatch"
data-product-form-target="swatch"
data-action="click->product-form#selectVariant"
data-variant-id="{{ $variant['id'] }}"
style="background-color: {{ $bg }};"
aria-label="{{ $label }}"
data-option="{{ $optionHandle }}"
data-value-id="{{ $value['id'] }}"
style="background-color: {{ $value['hex'] ?? '#cccccc' }};"
aria-label="{{ $value['label'] }}"
aria-pressed="false"
title="{{ $label }}"
></button>
@endforeach
</div>
@@ -0,0 +1,20 @@
@props([
'accept' => null,
'required' => false,
'disabled' => false,
])
<input
type="file"
@if ($accept) accept="{{ $accept }}" @endif
@required($required)
@disabled($disabled)
{{ $attributes->merge([
'class' => 'w-full py-2 text-sm
file:mr-4 file:py-2 file:px-4 file:rounded-none file:border file:border-black file:bg-transparent
file:font-semibold file:cursor-pointer file:transition-colors
hover:file:bg-black hover:file:text-neutral-200
focus:outline-none focus-visible:ring-2 focus-visible:ring-black
disabled:cursor-not-allowed disabled:opacity-50',
]) }}
/>
+7 -1
View File
@@ -6,7 +6,7 @@
])
@php
$fillIcons = ['facebook', 'instagram', 'tiktok', 'search', 'bag'];
$fillIcons = ['facebook', 'instagram', 'tiktok', 'search', 'bag', 'user', 'heart', 'heart-fill'];
$svgStroke = $stroke ?? (in_array($name, $fillIcons) ? 'none' : $color);
@endphp
@@ -16,6 +16,12 @@
'bag' => '<path stroke-width=0.5 d="M20.0049 22H4.00488C3.4526 22 3.00488 21.5523 3.00488 21V3C3.00488 2.44772 3.4526 2 4.00488 2H20.0049C20.5572 2 21.0049 2.44772 21.0049 3V21C21.0049 21.5523 20.5572 22 20.0049 22ZM19.0049 20V4H5.00488V20H19.0049ZM9.00488 6V8C9.00488 9.65685 10.348 11 12.0049 11C13.6617 11 15.0049 9.65685 15.0049 8V6H17.0049V8C17.0049 10.7614 14.7663 13 12.0049 13C9.24346 13 7.00488 10.7614 7.00488 8V6H9.00488Z"/>',
'user' => '<path stroke-width=0.5 d="M4 22C4 17.5817 7.58172 14 12 14C16.4183 14 20 17.5817 20 22H18C18 18.6863 15.3137 16 12 16C8.68629 16 6 18.6863 6 22H4ZM12 13C8.685 13 6 10.315 6 7C6 3.685 8.685 1 12 1C15.315 1 18 3.685 18 7C18 10.315 15.315 13 12 13ZM12 11C14.21 11 16 9.21 16 7C16 4.79 14.21 3 12 3C9.79 3 8 4.79 8 7C8 9.21 9.79 11 12 11Z"/>',
'heart' => '<path stroke-width=0.5 d="M12.001 4.52853C14.35 2.42 17.98 2.49 20.2426 4.75736C22.5053 7.02472 22.583 10.637 20.4786 12.993L11.9999 21.485L3.52138 12.993C1.41705 10.637 1.49571 7.01901 3.75736 4.75736C6.02157 2.49315 9.64519 2.41687 12.001 4.52853ZM18.827 6.1701C17.3279 4.66794 14.9076 4.60701 13.337 6.01687L12.0019 7.21524L10.6661 6.01781C9.09098 4.60597 6.67506 4.66808 5.17157 6.17157C3.68183 7.66131 3.60704 10.0473 4.97993 11.6232L11.9999 18.6543L19.0201 11.6232C20.3935 10.0467 20.319 7.66525 18.827 6.1701Z"/>',
'heart-fill' => '<path stroke-width=2 d="M12.001 4.52853C14.35 2.42 17.98 2.49 20.2426 4.75736C22.5053 7.02472 22.583 10.637 20.4786 12.993L11.9999 21.485L3.52138 12.993C1.41705 10.637 1.49571 7.01901 3.75736 4.75736C6.02157 2.49315 9.64519 2.41687 12.001 4.52853Z"/>',
'close' => '<path fill="none" stroke-width="1.5" stroke-linecap="round" d="M6 6L18 18M18 6L6 18"/>',
'arrow-left' => '<path fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" d="M19 12H5M5 12L11 6M5 12L11 18"/>',
@@ -0,0 +1,32 @@
{{-- $values: one product option's de-duplicated, ordered values, as built by
ProductController::buildOptionPicker() — [{id, label, hex}]. Use this
instead of <x-ui.color-swatch> for options that aren't a color (no hex),
where a swatch dot has nothing meaningful to show. --}}
@props(['values', 'option' => null, 'optionHandle' => null])
<div {{ $attributes }}>
@if($option)
<p class="font-bold mb-3 text-sm uppercase tracking-wide">
{{ $option }}
</p>
@endif
<div
class="flex flex-wrap gap-2"
role="group"
aria-label="{{ $option ?? 'Option' }}"
>
@foreach($values as $value)
<x-ui.button
variant="secondary"
size="sm"
data-product-form-target="swatch"
data-action="click->product-form#selectVariant"
data-option="{{ $optionHandle }}"
data-value-id="{{ $value['id'] }}"
aria-label="{{ $value['label'] }}"
aria-pressed="false"
>{{ $value['label'] }}</x-ui.button>
@endforeach
</div>
</div>
@@ -1,8 +1,15 @@
@props([
'name' => '',
'price' => null,
'image' => null,
'href' => '#',
'name' => '',
'price' => null,
'image' => null,
'href' => '#',
'variantId' => null,
'hasCustomFields' => false,
// Shows the "sold" star and drops the quick add-to-cart/personalize button.
'soldOut' => false,
// Heart toggle at the top right of the image; needs productId.
'productId' => null,
'wishlist' => false,
])
{{-- data-turbo-frame="_top" on the links: this card renders inside the
@@ -27,9 +34,37 @@ class="w-full h-auto block"
@endif
</a>
<x-ui.button size="md" position="absolute" class="opacity-0 group-hover:opacity-100 transition-opacity duration-100">
{{ __('storefront.product.add_to_cart') }}
</x-ui.button>
@if ($wishlist && $productId)
<x-wishlist-button :product-id="$productId" class="absolute top-4 right-4 z-10" />
@endif
@if ($soldOut)
<x-ui.sold-badge class="absolute top-2 right-2 {{ $wishlist && $productId ? 'mt-12' : '' }}" />
@elseif ($hasCustomFields)
{{-- Custom fields have to be filled in on the product page. --}}
<x-ui.button
:href="$href"
size="md"
position="absolute"
data-turbo-frame="_top"
class="opacity-0 group-hover:opacity-100 focus-visible:opacity-100 transition-opacity duration-100"
>
{{ __('storefront.product.personalize') }}
</x-ui.button>
@elseif ($variantId)
{{-- has-[...] forces the button visible while an add-to-cart error
is showing, so it isn't only readable on hover — a shopper who
already moved off the card (mouse or the click itself) must
still see why nothing happened. --}}
<x-checkout::add-to-cart
:purchasable="$variantId"
class="absolute opacity-0 group-hover:opacity-100 has-[.bbk-add-to-cart-error:not([hidden])]:opacity-100 transition-opacity duration-100"
>
<x-ui.button type="submit" size="md">
{{ __('storefront.product.add_to_cart') }}
</x-ui.button>
</x-checkout::add-to-cart>
@endif
</div>
<div class="flex items-baseline justify-between gap-4">
+11 -6
View File
@@ -1,17 +1,22 @@
@props([
'options' => [],
'value' => null,
'name' => null,
'ariaLabel' => null,
'options' => [],
'value' => null,
'name' => null,
'ariaLabel' => null,
'placeholder' => null,
'block' => false,
])
<div class="relative inline-flex items-center">
<div @class(['relative items-center', 'flex w-full' => $block, 'inline-flex' => ! $block])>
<select
@if($name) name="{{ $name }}" @endif
@if($ariaLabel) aria-label="{{ $ariaLabel }}" @endif
{{ $attributes->merge(['class' => 'appearance-none bg-transparent border-b border-black pr-10 py-2.5 cursor-pointer focus:outline-none']) }}
{{ $attributes->merge(['class' => 'appearance-none bg-transparent border-b border-black pr-10 py-2.5 cursor-pointer focus:outline-none'.($block ? ' w-full' : '')]) }}
>
@if($placeholder !== null)
<option value="" @selected($value === null || $value === '')>{{ $placeholder }}</option>
@endif
@foreach($options as $option)
<option value="{{ $option['value'] }}" @selected($value === $option['value'])>{{ $option['label'] }}</option>
@endforeach
@@ -0,0 +1,8 @@
{{-- Black "sold" star for out-of-stock products. Position it from the caller
(e.g. class="absolute top-2 right-2"). --}}
<div {{ $attributes->class('z-10 flex h-20 w-22 items-center justify-center text-black') }}>
<svg viewBox="0 0 87 80" class="absolute inset-0 size-full" fill="currentColor" aria-hidden="true">
<path d="M56.942,0l2.607,18.642,19.143-3.363L69.468,31.842,87,40,69.468,48.158l9.224,16.563L59.549,61.358,56.942,80,43.5,66.4,30.058,80,27.451,61.358,8.308,64.721l9.224-16.563L0,40l17.532-8.158L8.308,15.279l19.143,3.363L30.058,0,43.5,13.6Z"/>
</svg>
<span class="relative text-base italic font-black text-white uppercase">{{ __('storefront.product.sold') }}</span>
</div>
@@ -0,0 +1,11 @@
{{--
Flash confirmation box ("Changes saved." etc.). Renders nothing when there's
no message; defaults to session('status'). Spacing comes from the caller.
--}}
@props([
'message' => session('status'),
])
@if (filled($message))
<p role="status" aria-live="polite" {{ $attributes->merge(['class' => 'border border-black bg-brand p-6 text-black']) }}>{{ $message }}</p>
@endif
+7 -6
View File
@@ -1,9 +1,10 @@
@props(['tabs' => [], 'size' => 'md'])
@props(['tabs' => [], 'size' => 'md', 'active' => null])
@php
$sizeClasses = match($size) {
'lg' => 'text-[36px] font-extrabold [--slide-h:5px]',
default => 'text-xl font-bold [--slide-h:3px]',
};
$activeId = $active ?? ($tabs[0]['id'] ?? null);
@endphp
{{--
@@ -21,22 +22,22 @@
<div data-controller="tabs" {{ $attributes }}>
<div class="flex gap-10 mb-8" role="tablist">
@foreach($tabs as $i => $tab)
@foreach($tabs as $tab)
<button
type="button"
role="tab"
data-tabs-target="button"
data-action="click->tabs#show"
data-panel="{{ $tab['id'] }}"
class="underline-slide font-display pb-1 {{ $sizeClasses }} {{ $i === 0 ? 'is-active' : '' }}"
aria-selected="{{ $i === 0 ? 'true' : 'false' }}"
class="underline-slide font-display pb-1 {{ $sizeClasses }} {{ $tab['id'] === $activeId ? 'is-active' : '' }}"
aria-selected="{{ $tab['id'] === $activeId ? 'true' : 'false' }}"
aria-controls="tab-panel-{{ $tab['id'] }}"
id="tab-btn-{{ $tab['id'] }}"
>{{ $tab['label'] }}</button>
@endforeach
</div>
@foreach($tabs as $i => $tab)
@foreach($tabs as $tab)
@php $panelKey = $tab['id']; $panel = $$panelKey ?? null; @endphp
<div
role="tabpanel"
@@ -44,7 +45,7 @@ class="underline-slide font-display pb-1 {{ $sizeClasses }} {{ $i === 0 ? 'is-ac
id="tab-panel-{{ $tab['id'] }}"
aria-labelledby="tab-btn-{{ $tab['id'] }}"
tabindex="0"
@if($i !== 0) hidden @endif
@if($tab['id'] !== $activeId) hidden @endif
>{{ $panel }}</div>
@endforeach
</div>
@@ -0,0 +1,41 @@
{{--
Heart toggle for one product. A real form, so it works without JS; the
`wishlist` Stimulus controller turns the submit into a fetch and flips
aria-pressed, which the CSS (group-aria-pressed) uses to swap the icons.
Usage: <x-wishlist-button :product-id="$product['id']" class="absolute top-4 right-4" />
--}}
@props(['productId'])
@php
$active = app(\App\Services\Wishlist::class)->has((int) $productId);
$addLabel = __('storefront.wishlist.add');
$removeLabel = __('storefront.wishlist.remove');
@endphp
<form
method="POST"
action="{{ route('wishlist.toggle', ['productId' => $productId]) }}"
data-controller="wishlist"
data-action="submit->wishlist#toggle"
data-wishlist-add-label-value="{{ $addLabel }}"
data-wishlist-remove-label-value="{{ $removeLabel }}"
data-wishlist-added-message-value="{{ __('storefront.wishlist.added') }}"
data-wishlist-removed-message-value="{{ __('storefront.wishlist.removed') }}"
{{ $attributes }}
>
@csrf
<button
type="submit"
class="group/heart flex size-12 cursor-pointer items-center justify-center"
aria-pressed="{{ $active ? 'true' : 'false' }}"
aria-label="{{ $active ? $removeLabel : $addLabel }}"
data-wishlist-target="button"
>
<x-ui.icon name="heart" :size="30" class="group-aria-pressed/heart:hidden" />
<x-ui.icon name="heart-fill" :size="28" stroke="#000" class="hidden group-aria-pressed/heart:block text-brand" />
</button>
<span class="sr-only" role="status" aria-live="polite" data-wishlist-target="status"></span>
</form>
+34 -7
View File
@@ -3,6 +3,11 @@
@section('title', __('storefront.nav.contact'))
@section('description', 'Επικοινώνησε μαζί μας για οποιαδήποτε απορία ή ιδέα έχεις και θα σου απαντήσουμε το συντομότερο δυνατό.')
{{-- Only on pages with a captcha, not in the global layout. --}}
@push('scripts')
<script src="https://js.hcaptcha.com/1/api.js?hl={{ app()->getLocale() }}" async defer></script>
@endpush
@section('content')
<section class="grid grid-cols-1 lg:grid-cols-2 lg:divide-x lg:divide-black lg:min-h-[calc(100vh-102px)]">
@@ -24,23 +29,45 @@
Έχεις απορία, ιδέα ή θες να μας πεις κάτι; Γράψε μας το μήνυμά σου και θα σου απαντήσουμε το συντομότερο δυνατό.
</p>
<form method="POST" action="#" class="flex flex-col gap-8 max-w-xl">
<x-ui.status class="max-w-xl" />
<form method="POST" action="{{ route('contact.send') }}" class="flex flex-col gap-8 max-w-xl">
@csrf
<div class="grid grid-cols-1 gap-8 sm:grid-cols-2">
<x-ui.field label="Το όνομά σου" for="contact-name">
<x-ui.input id="contact-name" name="name" autocomplete="name" :required="true" />
<x-ui.field label="Το όνομά σου *" for="contact-name" :error="$errors->first('name')">
<x-ui.input id="contact-name" name="name" autocomplete="name" maxlength="100" :value="old('name')" :required="true"
:aria-invalid="$errors->has('name') ? 'true' : null"
:aria-describedby="$errors->has('name') ? 'contact-name-error' : null" />
</x-ui.field>
<x-ui.field label="Το email σου" for="contact-email">
<x-ui.input id="contact-email" name="email" type="email" autocomplete="email" :required="true" />
<x-ui.field label="Το email σου *" for="contact-email" :error="$errors->first('email')">
<x-ui.input id="contact-email" name="email" type="email" autocomplete="email" maxlength="255" :value="old('email')" :required="true"
:aria-invalid="$errors->has('email') ? 'true' : null"
:aria-describedby="$errors->has('email') ? 'contact-email-error' : null" />
</x-ui.field>
</div>
<x-ui.field label="Το μήνυμά σου" for="contact-message">
<x-ui.textarea id="contact-message" name="message" rows="5" :required="true" />
<x-ui.field label="Το μήνυμά σου *" for="contact-message" :error="$errors->first('message')">
<x-ui.textarea id="contact-message" name="message" rows="5" maxlength="5000" :required="true"
:aria-invalid="$errors->has('message') ? 'true' : null"
:aria-describedby="$errors->has('message') ? 'contact-message-error' : null">{{ old('message') }}</x-ui.textarea>
</x-ui.field>
{{-- hCaptcha checkbox, verified by App\Rules\HCaptcha (see auth/login). --}}
<x-ui.field for="contact-captcha" :error="$errors->first('h-captcha-response')">
<div
id="contact-captcha"
class="h-captcha min-h-[78px]"
data-sitekey="{{ config('services.hcaptcha.sitekey') }}"
@if ($errors->has('h-captcha-response')) aria-describedby="contact-captcha-error" @endif
></div>
</x-ui.field>
@if (session('contact_error'))
<p role="alert" class="text-sm text-red-600">{{ session('contact_error') }}</p>
@endif
<div>
<x-ui.button type="submit" size="md">Υποβολή</x-ui.button>
</div>
@@ -0,0 +1,17 @@
@extends('emails.layout')
@section('title', __('storefront.contact.confirmation_subject'))
@section('preheader', __('storefront.contact.confirmation_preheader'))
@section('content')
<h1 style="margin:0 0 20px;font-family:'Manrope',Arial,Helvetica,sans-serif;font-size:22px;line-height:1.3;font-weight:700;color:#000000;">
{{ __('storefront.contact.confirmation_heading') }}
</h1>
<p style="margin:0;">{{ __('storefront.contact.confirmation_body') }}</p>
@endsection
@section('footer')
<p style="margin:0;">{{ __('storefront.contact.confirmation_footer') }}</p>
@endsection
@@ -0,0 +1,21 @@
@extends('emails.layout')
@section('title', 'Νέο μήνυμα επικοινωνίας')
@section('preheader', "Από {$senderName} ({$senderEmail})")
@section('content')
<h1 style="margin:0 0 20px;font-family:'Manrope',Arial,Helvetica,sans-serif;font-size:22px;line-height:1.3;font-weight:700;color:#000000;">
Νέο μήνυμα επικοινωνίας
</h1>
<p style="margin:0 0 4px;"><strong>Όνομα:</strong> {{ $senderName }}</p>
<p style="margin:0 0 4px;"><strong>Email:</strong> <a href="mailto:{{ $senderEmail }}" style="color:#000000;">{{ $senderEmail }}</a></p>
<p style="margin:0 0 24px;"><strong>Γλώσσα:</strong> {{ strtoupper($senderLocale) }}</p>
<p style="margin:0;white-space:pre-line;">{{ $body }}</p>
@endsection
@section('footer')
<p style="margin:0;">Στάλθηκε από τη φόρμα επικοινωνίας του 3dealer. Απάντησε σε αυτό το email για να απαντήσεις στον αποστολέα.</p>
@endsection
+98
View File
@@ -0,0 +1,98 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office" lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="x-apple-disable-message-reformatting">
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no, url=no">
<meta name="color-scheme" content="light">
<meta name="supported-color-schemes" content="light">
<title>@yield('title', '3Dealer')</title>
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:PixelsPerInch>96</o:PixelsPerInch>
<o:AllowPNG/>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<style>
table { border-collapse: collapse !important; }
.email-container { width: 600px !important; }
</style>
<![endif]-->
<!--[if !mso]><!-->
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;600;700;800&display=swap" rel="stylesheet" type="text/css">
<!--<![endif]-->
<style>
body, table, td, a { -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; }
table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
img { -ms-interpolation-mode: bicubic; border: 0; height: auto; line-height: 100%; outline: none; text-decoration: none; }
body { height: 100% !important; margin: 0 !important; padding: 0 !important; width: 100% !important; background-color: #e5e5e5; }
a[x-apple-data-detectors] {
color: inherit !important;
text-decoration: none !important;
font-size: inherit !important;
font-family: inherit !important;
font-weight: inherit !important;
line-height: inherit !important;
}
#MessageViewBody, #MessageWebViewDiv { width: 100% !important; }
u + #email-body a { color: inherit; text-decoration: none; }
@media only screen and (max-width: 480px) {
.email-container { width: 100% !important; max-width: 100% !important; }
.email-header { padding: 24px 20px 12px !important; }
.email-gutter { padding: 0 !important; }
.email-card { padding: 28px 20px !important; }
.email-footer { padding: 0 20px 32px !important; }
}
</style>
</head>
<body id="email-body" style="margin:0;padding:0;background-color:#e5e5e5;">
<div style="display:none;max-height:0;overflow:hidden;mso-hide:all;font-size:1px;line-height:1px;color:#e5e5e5;">
@yield('preheader', '')
&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;
</div>
<center role="article" aria-roledescription="email" style="width:100%;background-color:#e5e5e5;">
<!--[if mso]>
<table role="presentation" width="600" align="center" cellpadding="0" cellspacing="0" bgcolor="#e5e5e5"><tr><td>
<![endif]-->
<table role="presentation" class="email-container" width="100%" cellpadding="0" cellspacing="0" bgcolor="#e5e5e5" style="max-width:600px;margin:0 auto;">
<tr>
<td class="email-header" align="center" style="padding:32px 24px 16px;">
<a href="{{ url('/') }}" target="_blank" style="text-decoration:none;">
<img src="{{ asset('images/logo.png') }}" width="140" height="123" alt="3Dealer" style="display:inline-block;border:0;outline:none;text-decoration:none;height:auto;max-width:140px;width:100%;">
</a>
</td>
</tr>
<tr>
<td class="email-gutter" style="padding:0 24px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" bgcolor="#ffffff" style="background-color:#ffffff;border-radius:0;">
<tr>
<td class="email-card" style="padding:40px 32px;font-family:'Manrope',Arial,Helvetica,sans-serif;font-size:16px;line-height:1.5;color:#000000;text-align:left;">
@yield('content')
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="email-footer" align="center" style="padding:24px 24px 40px;font-family:'Manrope',Arial,Helvetica,sans-serif;font-size:13px;line-height:1.6;color:#7a7a7a;">
@yield('footer')
<p style="margin:12px 0 0;">
<a href="{{ url('/') }}" style="color:#7a7a7a;text-decoration:underline;">3dealer.gr</a>
&nbsp;&middot;&nbsp;&copy; {{ date('Y') }} 3Dealer
</p>
</td>
</tr>
</table>
<!--[if mso]>
</td></tr></table>
<![endif]-->
</center>
</body>
</html>
@@ -0,0 +1,15 @@
{{--
@include('emails.partials.address', ['address' => $order->shippingAddress])
An order address, read-only, same fields as the account order page.
--}}
<p style="margin:0;font-size:15px;line-height:1.6;">
{{ trim($address->first_name.' '.$address->last_name) }}<br>
@if ($address->company_name){{ $address->company_name }}<br>@endif
@if ($address->tax_identifier)ΑΦΜ: {{ $address->tax_identifier }}<br>@endif
{{ $address->line_one }}<br>
@if ($address->line_two){{ $address->line_two }}<br>@endif
{{ trim($address->postcode.' '.$address->city) }}<br>
@if ($address->state){{ Lang::has("core::states.{$address->state}") ? __("core::states.{$address->state}") : $address->state }}<br>@endif
@if ($address->contact_phone){{ $address->contact_phone }}@endif
</p>
@@ -0,0 +1,43 @@
{{--
@include('emails.partials.button', ['url' => ..., 'label' => ...])
The site's primary button (x-ui.button, .btn-primary): neutral-200 fill,
black border, bold italic uppercase, black offset shadow. The shadow is
built from table cells, not box-shadow (Gmail and Outlook drop it): a
black column on the right starting $offset down, and a black row at the
bottom starting $offset in. The button cell spans the first two rows, so
the black column always matches its height.
Uppercased here with the tonos removed, since emails can't run the site's
strip-accents script and CSS uppercase would keep the accents.
--}}
@php
$offset = 8;
$label = strtr(mb_strtoupper($label), [
'Ά' => 'Α', 'Έ' => 'Ε', 'Ή' => 'Η', 'Ί' => 'Ι', 'Ό' => 'Ο', 'Ύ' => 'Υ', 'Ώ' => 'Ω',
'ΐ' => 'Ϊ', 'ΰ' => 'Ϋ',
]);
$spacer = "font-size:1px;line-height:1px;mso-line-height-rule:exactly;";
@endphp
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:0;border-collapse:separate;">
<tr>
<td rowspan="2" bgcolor="#e5e5e5" style="background-color:#e5e5e5;border:1px solid #000000;border-radius:0;mso-padding-alt:14px 28px;">
<a href="{{ $url }}" target="_blank" style="display:inline-block;padding:14px 28px;font-family:'Manrope',Arial,Helvetica,sans-serif;font-size:16px;line-height:20px;mso-line-height-rule:exactly;font-weight:700;font-style:italic;color:#000000;text-decoration:none;">{{ $label }}</a>
</td>
<td width="{{ $offset }}" height="{{ $offset }}" style="width:{{ $offset }}px;height:{{ $offset }}px;{{ $spacer }}">&nbsp;</td>
</tr>
<tr>
<td width="{{ $offset }}" bgcolor="#000000" style="width:{{ $offset }}px;background-color:#000000;{{ $spacer }}">&nbsp;</td>
</tr>
<tr>
<td height="{{ $offset }}" style="height:{{ $offset }}px;padding:0 0 0 {{ $offset }}px;{{ $spacer }}">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td height="{{ $offset }}" bgcolor="#000000" style="height:{{ $offset }}px;background-color:#000000;{{ $spacer }}">&nbsp;</td>
</tr>
</table>
</td>
<td width="{{ $offset }}" height="{{ $offset }}" bgcolor="#000000" style="width:{{ $offset }}px;height:{{ $offset }}px;background-color:#000000;{{ $spacer }}">&nbsp;</td>
</tr>
</table>
@@ -0,0 +1,2 @@
{{-- @include('emails.partials.section-heading', ['text' => ...]) --}}
<h2 style="margin:0 0 12px;padding:0 0 8px;border-bottom:1px solid #000000;font-family:'Manrope',Arial,Helvetica,sans-serif;font-size:18px;line-height:1.3;font-weight:800;color:#000000;">{{ $text }}</h2>
+31 -7
View File
@@ -43,7 +43,7 @@ class="absolute left-4 lg:left-8 top-1/2 z-10 -translate-y-1/2 hover:-translate-
class="{{ $loop->first ? '' : 'hidden' }} group h-full flex flex-col justify-center pb-8 relative"
>
<div class="relative flex items-center justify-center">
<a href="{{ $product['href'] }}" class="block border border-black bg-white flex items-center justify-center overflow-hidden max-w-xs xl:max-w-sm">
<a href="{{ $product['href'] }}" class="relative block border border-black bg-white flex items-center justify-center overflow-hidden max-w-xs xl:max-w-sm">
@if($product['image'])
<img
src="{{ $product['image'] }}"
@@ -58,11 +58,34 @@ class="w-full h-full object-cover"
@else
<span class="text-neutral-500 text-sm">{{ __('storefront.product.no_image') }}</span>
@endif
{{-- Inside the link so it sits on the image's corner, not the full-width slide's. --}}
@if ($product['soldOut'] ?? false)
<x-ui.sold-badge class="absolute top-2 right-2" />
@endif
</a>
<x-ui.button size="md" position="absolute" class="opacity-0 group-hover:opacity-100 transition-opacity duration-100">
{{ __('storefront.product.add_to_cart') }}
</x-ui.button>
@if ($product['soldOut'] ?? false)
{{-- Sold: no quick add-to-cart/personalize, same as x-ui.product-card. --}}
@elseif ($product['hasCustomFields'] ?? false)
<x-ui.button
:href="$product['href']"
size="md"
position="absolute"
class="opacity-0 group-hover:opacity-100 focus-visible:opacity-100 transition-opacity duration-100"
>
{{ __('storefront.product.personalize') }}
</x-ui.button>
@elseif ($product['variantId'] ?? null)
<x-checkout::add-to-cart
:purchasable="$product['variantId']"
class="absolute opacity-0 group-hover:opacity-100 has-[.bbk-add-to-cart-error:not([hidden])]:opacity-100 transition-opacity duration-100"
>
<x-ui.button type="submit" size="md">
{{ __('storefront.product.add_to_cart') }}
</x-ui.button>
</x-checkout::add-to-cart>
@endif
</div>
<div class="flex items-baseline justify-between gap-4 absolute bottom-0 left-0 w-full">
@@ -155,6 +178,9 @@ class="max-w-xs"
:price="$product['price']"
:image="$product['image']"
:href="$product['href']"
:variant-id="$product['variantId'] ?? null"
:has-custom-fields="$product['hasCustomFields'] ?? false"
:sold-out="$product['soldOut'] ?? false"
/>
@endforeach
</div>
@@ -169,7 +195,5 @@ class="max-w-xs"
</div>
</div>
<div class="py-20">
<x-newsletter-split />
</div>
<x-newsletter-split />
@endsection
+71
View File
@@ -0,0 +1,71 @@
{{--
Account area: full-width, fixed-width sidebar + fluid content. Pages set
@section('title') and fill @section('account').
Below lg the sidebar becomes a horizontally scrollable row above the
content.
--}}
@extends('layouts.app')
@push('seo')
<meta name="robots" content="noindex, nofollow">
@endpush
@php
$accountNav = [
[
'label' => __('storefront.account.nav_profile'),
'href' => route('account'),
'active' => request()->routeIs('account', 'account.email.*'),
],
[
'label' => __('storefront.account.nav_orders'),
'href' => route('account.orders'),
'active' => request()->routeIs('account.orders*'),
],
[
'label' => __('storefront.account.nav_wishlist'),
'href' => route('account.wishlist'),
'active' => request()->routeIs('account.wishlist'),
],
];
$accountNavLink = 'nav-link uppercase inline-flex shrink-0 whitespace-nowrap font-display font-extrabold italic text-black no-underline';
@endphp
@section('content')
<div class="flex flex-col lg:flex-row lg:min-h-[calc(100vh-102px)]">
<aside class="border-b border-black lg:w-80 lg:shrink-0 lg:border-b-0 lg:border-r">
<nav aria-label="{{ __('storefront.nav.account') }}">
<ul class="flex gap-8 overflow-x-auto px-4 py-5 sm:px-8 lg:flex-col lg:gap-5 lg:overflow-visible lg:px-10 lg:py-12">
@foreach ($accountNav as $item)
<li class="shrink-0">
<a
href="{{ $item['href'] }}"
@class([$accountNavLink, 'is-active' => $item['active']])
@if ($item['active']) aria-current="page" @endif
><span>{{ $item['label'] }}</span></a>
</li>
@endforeach
<li class="shrink-0">
<form method="POST" action="{{ route('logout') }}">
@csrf
<button type="submit" class="{{ $accountNavLink }} cursor-pointer">
<span>{{ __('storefront.auth.logout') }}</span>
</button>
</form>
</li>
</ul>
</nav>
</aside>
<div class="min-w-0 flex-1 px-4 py-12 sm:px-8 lg:px-16 lg:py-16">
@yield('account')
</div>
</div>
@endsection
+1
View File
@@ -10,6 +10,7 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<meta name="apple-mobile-web-app-title" content="3dealer" />
<link rel="manifest" href="/site.webmanifest" />
<title>@yield('title', config('app.name'))</title>
+62 -13
View File
@@ -17,13 +17,20 @@
class="grid grid-cols-1 md:grid-cols-2 gap-12"
data-controller="product-form"
data-product-form-variants-value="{{ json_encode($variantsData) }}"
data-product-form-stock-check-url-value="{{ route('product.stock-check', app()->getLocale()) }}"
data-product-form-stock-error-one-value="{{ trans_choice('storefront.product.add_to_cart_failed', 1) }}"
data-product-form-stock-error-many-value="{{ trans_choice('storefront.product.add_to_cart_failed', 2, ['count' => ':count']) }}"
>
{{-- Image --}}
{{-- relative: anchors the wishlist heart to the top right, which is
the main image's corner (thumbnails sit on the left). --}}
<div
data-controller="product-gallery"
class="flex gap-4 items-start"
class="relative flex gap-4 items-start"
>
<x-wishlist-button :product-id="$product['id']" class="absolute top-4 right-4 z-10" />
@if(!empty($product['media']))
{{-- Thumbnails --}}
<div class="flex flex-col items-center gap-1 w-[116px] shrink-0 -mt-12.5">
@@ -146,7 +153,15 @@ class="absolute bottom-6 right-8 text-white text-sm"
{{ $product['name'] }}
</h1>
<x-reviews-stars :rating="$product['reviews']['average_rating'] ?? 0" :count="$product['reviews']['count']" :showCount="true" />
<x-reviews-stars
:rating="$product['reviews']['average_rating'] ?? 0"
:count="$product['reviews']['count']"
:showCount="true"
:linkable="true"
data-controller="tab-link"
data-tab-link-tabs-outlet="#product-tabs"
data-tab-link-panel-value="reviews"
/>
@if($product['price'] !== null)
<p class="text-2xl font-bold" data-product-form-target="price">
@@ -155,35 +170,63 @@ class="absolute bottom-6 right-8 text-white text-sm"
@endif
@php
$desc = strip_tags($product['description'] ?? '');
$desc = html_entity_decode(strip_tags($product['description'] ?? ''), ENT_QUOTES | ENT_HTML5, 'UTF-8');
$descTruncated = Str::limit($desc, 137);
$descNeedsMore = mb_strlen($desc) > mb_strlen(rtrim($descTruncated, '.'));
@endphp
<div class="text-base leading-relaxed">
{{ $descTruncated }}
@if($descNeedsMore)
<a href="#tab-panel-description" class="underline-slide font-semibold whitespace-nowrap">{{ __('storefront.product.read_more') }}</a>
<a
href="#tab-panel-description"
class="underline-slide font-semibold whitespace-nowrap"
data-controller="tab-link"
data-tab-link-tabs-outlet="#product-tabs"
data-tab-link-panel-value="description"
data-action="click->tab-link#activate"
>{{ __('storefront.product.read_more') }}</a>
@endif
</div>
@if($option && !empty($product['variants']))
<x-ui.color-swatch :variants="$product['variants']" :option="$option" />
@endif
@foreach($productOptions as $productOption)
@if($productOption['isColor'])
<x-ui.color-swatch :values="$productOption['values']" :option="$productOption['label']" :option-handle="$productOption['handle']" />
@else
<x-ui.option-buttons :values="$productOption['values']" :option="$productOption['label']" :option-handle="$productOption['handle']" />
@endif
@endforeach
@php
// Same initial pick as product-form-controller.js's connect()
// (?variant= or the first), so the button doesn't flip on load.
$initialVariant = collect($variantsData)->firstWhere('id', (int) request('variant')) ?? ($variantsData[0] ?? null);
@endphp
<x-checkout::add-to-cart
:purchasable="$variantsData[0]['id'] ?? null"
:purchasable="$initialVariant['id'] ?? null"
:quantity="false"
class="flex items-stretch gap-10"
class="flex flex-col gap-6"
>
<x-ui.quantity name="quantity" />
<x-ui.button type="submit" class="flex-1">{{ __('storefront.product.add_to_cart') }}</x-ui.button>
@if(!empty($product['custom_fields']))
<x-product-custom-fields :fields="$product['custom_fields']" />
@endif
<div class="flex items-stretch gap-10">
<x-ui.quantity name="quantity" />
<x-ui.button type="submit" class="flex-1" data-product-form-target="submit" :disabled="! ($initialVariant['inStock'] ?? true)">{{ __('storefront.product.add_to_cart') }}</x-ui.button>
</div>
</x-checkout::add-to-cart>
{{-- Storefront-owned, not part of the checkout module — the
local stock pre-check in product-form-controller.js stops
an over-limit submit before it ever reaches the module
and reports it here. --}}
<p class="text-sm text-red-600" data-product-form-target="stockError" hidden role="alert"></p>
</div>
</div>
<x-ui.tabs class="mt-16" size="lg" :tabs="[
<x-ui.tabs id="product-tabs" class="mt-16 scroll-mt-28" size="lg" :active="request('tab')" :tabs="[
['id' => 'description', 'label' => __('storefront.product.description')],
['id' => 'reviews', 'label' => __('storefront.product.reviews') . ' (' . $product['reviews']['count'] . ')'],
]">
@@ -198,6 +241,10 @@ class="flex items-stretch gap-10"
</ul>
</x-slot>
<x-slot name="reviews">
@if(session('reviewSubmitted'))
<p class="mb-8 text-sm font-semibold" role="status">{{ __('storefront.review.thank_you') }}</p>
@endif
@if(!empty($product['reviews']['items']))
<div class="mb-10">
@foreach($product['reviews']['items'] as $review)
@@ -207,6 +254,8 @@ class="flex items-stretch gap-10"
'date' => $review['reviewed_at'] ? \Illuminate\Support\Carbon::createFromTimestamp($review['reviewed_at'])->translatedFormat('d M Y') : '',
'text' => $review['body'],
'image' => $review['media'][0]['url'] ?? null,
'reply' => $review['reply'] ?? null,
'replyDate' => $review['replied_at'] ? \Illuminate\Support\Carbon::createFromTimestamp($review['replied_at'])->translatedFormat('d M Y') : null,
]" />
@endforeach
</div>
@@ -226,7 +275,7 @@ class="flex items-stretch gap-10"
@if(!empty($product['recommendations']))
<x-product-grid
class="mt-20"
title="Σχετικά προϊόντα"
:title="__('storefront.product.related')"
:products="collect($product['recommendations'])->map(fn (array $recommendation) => [
'name' => $recommendation['name'],
'price' => $recommendation['price'],
-236
View File
@@ -1,236 +0,0 @@
@extends('layouts.app')
@section('title', $product->translateAttribute('name') . ' — ' . config('app.name'))
@section('description', $product->translateAttribute('description'))
@section('content')
<div class="max-w-7xl mx-auto px-4 sm:px-8 py-12">
@php $collection = $product->collections->first(); @endphp
<x-breadcrumb class="mb-14 justify-end" :items="[
$collection
? ['label' => $collection->translateAttribute('name'), 'href' => '/category/' . $collection->id]
: ['label' => 'Products', 'href' => '/products'],
['label' => $product->translateAttribute('name')],
]" />
<div
class="grid grid-cols-1 md:grid-cols-2 gap-12"
data-controller="product-form"
data-product-form-variants-value="{{ json_encode($variantsData) }}"
>
{{-- Image --}}
<div
data-controller="product-gallery"
class="flex gap-4 items-start"
>
@if($product->media->isNotEmpty())
{{-- Thumbnails --}}
<div class="flex flex-col items-center gap-1 w-[116px] shrink-0 -mt-12.5">
<button
type="button"
data-action="click->product-gallery#scrollUp"
class="gallery-arrow w-full flex items-center justify-center py-2"
aria-label="Scroll thumbnails up"
><x-ui.icon name="arrow-up" :size="30" /></button>
<div
data-product-gallery-target="track"
class="flex flex-col gap-4 overflow-hidden"
style="max-height: var(--gallery-height, 600px)"
>
@foreach($product->media as $i => $media)
<button
type="button"
data-action="click->product-gallery#select"
data-product-gallery-target="thumb"
data-src="{{ $media->getUrl() }}"
data-alt="{{ $product->translateAttribute('name') }}"
class="block w-full shrink-0 border border-black overflow-hidden "
aria-label="View image {{ $i + 1 }}"
aria-pressed="{{ $i === 0 ? 'true' : 'false' }}"
>
<!-- opacity-50 transition-opacity {{ $i === 0 ? 'opacity-100' : '' }}" -->
<img src="{{ $media->getUrl() }}" alt="" class="w-full h-auto object-cover" aria-hidden="true">
</button>
@endforeach
</div>
<button
type="button"
data-action="click->product-gallery#scrollDown"
class="gallery-arrow w-full flex items-center justify-center py-2"
aria-label="Scroll thumbnails down"
><x-ui.icon name="arrow-down" :size="30" /></button>
</div>
{{-- Main image --}}
<button
type="button"
class="flex-1 border border-black bg-white cursor-zoom-in block p-0"
data-action="click->product-gallery#openLightbox"
aria-label="View full size image"
>
<img
data-product-form-target="image"
data-product-gallery-target="main"
src="{{ $product->media->first()->getUrl() }}"
alt="{{ $product->translateAttribute('name') }}"
class="w-full h-auto block"
>
</button>
{{-- Lightbox --}}
<div
id="product-lightbox"
popover
class="product-lightbox"
data-product-gallery-target="lightbox"
data-action="click->product-gallery#closeLightboxOnBackdrop"
role="dialog"
aria-label="Product image gallery"
aria-modal="true"
>
{{-- Prev --}}
<button
type="button"
data-action="click->product-gallery#prevImage click->product-gallery#noop"
class="lightbox-arrow absolute left-8 top-1/2 -translate-y-1/2"
aria-label="Previous image"
><x-ui.icon name="arrow-left" :size="72" color="white" /></button>
{{-- Image + close button as a unit --}}
<div class="relative" data-action="click->product-gallery#noop">
<img
data-product-gallery-target="lightboxImage"
src=""
alt=""
class="max-h-[90vh] max-w-[80vw] object-contain block"
>
<button
type="button"
popovertarget="product-lightbox"
popovertargetaction="hide"
class="lightbox-close absolute -top-10 -right-10"
aria-label="Close gallery"
><x-ui.icon name="close" :size="36" color="white" /></button>
</div>
{{-- Next --}}
<button
type="button"
data-action="click->product-gallery#nextImage click->product-gallery#noop"
class="lightbox-arrow absolute right-8 top-1/2 -translate-y-1/2"
aria-label="Next image"
><x-ui.icon name="arrow-right" :size="72" color="white" /></button>
{{-- Counter --}}
<p
data-product-gallery-target="lightboxCounter"
class="absolute bottom-6 right-8 text-white text-sm"
aria-live="polite"
></p>
</div>
@else
<div class="w-full bg-neutral-300 flex items-center justify-center text-neutral-500 min-h-64">
No image
</div>
@endif
</div>
{{-- Info --}}
<div class="flex flex-col gap-6">
<h1 class="font-display font-medium text-4xl lg:text-[54px]">
{{ $product->translateAttribute('name') }}
</h1>
<x-reviews-stars :rating="3" :count="24" :showCount="true" />
@if($product->variants->first()?->prices->isNotEmpty())
<p class="text-2xl font-bold" data-product-form-target="price">
<x-ui.price :amount="$product->variants->first()->prices->first()->price->decimal" />
</p>
@endif
@php
$desc = strip_tags($product->translateAttribute('description') ?? '');
$descTruncated = Str::limit($desc, 137);
$descNeedsMore = mb_strlen($desc) > mb_strlen(rtrim($descTruncated, '.'));
@endphp
<div class="text-base leading-relaxed">
{{ $descTruncated }}
@if($descNeedsMore)
<a href="#tab-panel-description" class="underline-slide font-semibold whitespace-nowrap">Περισσότερα</a>
@endif
</div>
@if($option && $product->variants->count() >= 1)
<x-ui.color-swatch :variants="$product->variants" :option="$option" />
@endif
<div class="flex items-stretch gap-10">
<x-ui.quantity name="quantity" />
<x-ui.button class="flex-1">Προσθήκη στο καλάθι</x-ui.button>
</div>
</div>
</div>
<x-ui.tabs class="mt-16" size="lg" :tabs="[
['id' => 'description', 'label' => 'Περιγραφή'],
// ['id' => 'reviews', 'label' => 'Αξιολογήσεις (' . count($reviews) . ')'],
['id' => 'reviews', 'label' => 'Αξιολογήσεις (3)'],
]">
<x-slot name="description">
<div class="leading-7 [&_p]:mt-4">
{!! $product->translateAttribute('description') !!}
</div>
@if($product->translateAttribute('details'))
<div class="mt-6">{!! $product->translateAttribute('details') !!}</div>
@endif
<ul class="mt-8 flex flex-col gap-3 text-neutral-600 list-disc list-outside pl-5">
<li>Όλα τα προϊόντα εκτυπώνονται και προετοιμάζονται κατά παραγγελία. Ο χρόνος προετοιμασίας κυμαίνεται μεταξύ 2 και 7 εργάσιμων ημερών.</li>
<li>Όλα τα προϊόντα κατασκευάζονται με τρισδιάστατη εκτύπωση σε ειδικούς εκτυπωτές πλαστικού υλικού. Πιθανώς να έχουν εμφανείς γραμμές ένωσης, στρώσεις εκτύπωσης υλικού και μικρές ατέλειες. Είναι φυσιολογικό για το αποτέλεσμα αυτής της δημιουργικής διαδικασίας.</li>
<li>Όλα τα προϊόντα είναι σχεδιασμένα και κατασκευασμένα είτε για διακόσμηση είτε για χρήση σε λογικά, ρεαλιστικά πλαίσια. Υπερβολική ισχύς, υψηλότατες θερμοκρασίες και αποσυναρμολόγηση μπορεί να προκαλέσουν ζημιά στο προϊόν για την οποία δεν ευθύνεται το 3Dealer.</li>
</ul>
</x-slot>
<x-slot name="reviews">
{{-- @if(count($reviews) > 0)
<div class="mb-10">
@foreach($reviews as $review)
<x-review-card :review="$review" />
@endforeach
</div>
@else
<p class="text-neutral-500 mb-10">Δεν υπάρχουν αξιολογήσεις ακόμα.</p>
@endif
<h3 class="text-h4 font-bold">
{{ count($reviews) > 0 ? 'Πρόσθεσε μια' : 'Γράψε την πρώτη' }} αξιολόγηση για το «{{ $product->translateAttribute('name') }}»
</h3> --}}
<x-review-form :product="$product" />
</x-slot>
</x-ui.tabs>
<x-product-grid
class="mt-20"
title="Σχετικά προϊόντα"
:products="[
['name' => 'Camper', 'price' => '30', 'image' => null, 'href' => '#'],
['name' => 'Ponderer IPA','price' => '25', 'image' => null, 'href' => '#'],
['name' => 'Squish Red', 'price' => '35', 'image' => null, 'href' => '#'],
['name' => 'Red Light', 'price' => '25', 'image' => null, 'href' => '#'],
]"
/>
</div>
<x-newsletter class="py-20" />
@endsection
+2 -1
View File
@@ -12,7 +12,8 @@
@endpush
@section('content')
<div class="max-w-5xl mx-auto pt-26 pb-12">
{{-- <div class="max-w-5xl mx-auto pt-26 pb-12"> --}}
<div class="max-w-7xl mx-auto px-4 sm:px-8 pt-26 pb-12">
<div class="flex items-end justify-between gap-6 flex-wrap mb-16">
<h1 class="font-medium text-h2">{{ __('storefront.shop.all_products') }}</h1>
+2 -1
View File
@@ -12,7 +12,8 @@
@endpush
@section('content')
<div class="max-w-5xl mx-auto pt-26 pb-12">
{{-- <div class="max-w-5xl mx-auto pt-26 pb-12"> --}}
<div class="max-w-7xl mx-auto px-4 sm:px-8 pt-26 pb-12">
<div class="flex items-end justify-between gap-6 flex-wrap mb-16">
<h1 class="font-medium text-h2">{{ $heading }}</h1>
@@ -0,0 +1,29 @@
@extends('emails.layout')
@section('title', 'Επιβεβαίωσε το νέο σου email')
@section('preheader', "Ο κωδικός επιβεβαίωσης: {$code}")
@section('content')
<h1 style="margin:0 0 20px;font-family:'Manrope',Arial,Helvetica,sans-serif;font-size:22px;line-height:1.3;font-weight:700;color:#000000;">
Επιβεβαίωσε το νέο σου email
</h1>
<p style="margin:0 0 24px;">Βάλε τον παρακάτω κωδικό στο 3dealer για να γίνει αυτή η διεύθυνση το νέο email του λογαριασμού σου.</p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:0 0 24px;">
<tr>
<td align="center" bgcolor="#18c28a" style="background-color:#18c28a;border:1px solid #000000;border-radius:0;padding:20px;">
<span style="font-family:'Manrope',Arial,Helvetica,sans-serif;font-size:32px;font-weight:800;letter-spacing:8px;color:#000000;">{{ $code }}</span>
</td>
</tr>
</table>
<p style="margin:0 0 16px;color:#5c5c5c;font-size:14px;">Ο κωδικός ισχύει για 10 λεπτά.</p>
<p style="margin:0;color:#5c5c5c;font-size:14px;">Αν δεν ζήτησες εσύ αυτή την αλλαγή, αγνόησε αυτό το email. Τίποτα δεν θα αλλάξει.</p>
@endsection
@section('footer')
<p style="margin:0;">Αυτό το email στάλθηκε επειδή ζητήθηκε αλλαγή email σε λογαριασμό του 3dealer.</p>
@endsection
@@ -0,0 +1,21 @@
@extends('emails.layout')
@section('title', 'Το email του λογαριασμού σου άλλαξε')
@section('preheader', "Νέο email λογαριασμού: {$maskedEmail}")
@section('content')
<h1 style="margin:0 0 20px;font-family:'Manrope',Arial,Helvetica,sans-serif;font-size:22px;line-height:1.3;font-weight:700;color:#000000;">
Το email του λογαριασμού σου άλλαξε
</h1>
<p style="margin:0 0 16px;">Το email σύνδεσης του λογαριασμού σου στο 3dealer άλλαξε σε <strong>{{ $maskedEmail }}</strong>.</p>
<p style="margin:0 0 24px;">Από εδώ και πέρα οι κωδικοί σύνδεσης θα πηγαίνουν στη νέα διεύθυνση.</p>
<p style="margin:0;color:#5c5c5c;font-size:14px;">Αν δεν έκανες εσύ αυτή την αλλαγή, <a href="{{ route('contact', ['locale' => 'el']) }}" style="color:#000000;">επικοινώνησε μαζί μας</a> άμεσα.</p>
@endsection
@section('footer')
<p style="margin:0;">Αυτό το email στάλθηκε στην προηγούμενη διεύθυνση του λογαριασμού σου στο 3dealer.</p>
@endsection
+31
View File
@@ -0,0 +1,31 @@
@extends('emails.layout')
@section('title', 'Ο κωδικός σύνδεσής σου')
@section('preheader', "Ο κωδικός σύνδεσής σου στο 3dealer: {$code}")
@section('content')
<h1 style="margin:0 0 20px;font-family:'Manrope',Arial,Helvetica,sans-serif;font-size:22px;line-height:1.3;font-weight:700;color:#000000;">
Ο κωδικός σύνδεσής σου
</h1>
<p style="margin:0 0 16px;">Γεια σου {{ $name }},</p>
<p style="margin:0 0 24px;">Χρησιμοποίησε τον παρακάτω κωδικό για να συνδεθείς στο διαχειριστικό του 3dealer.</p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:0 0 24px;">
<tr>
<td align="center" bgcolor="#18c28a" style="background-color:#18c28a;border:1px solid #000000;border-radius:0;padding:20px;">
<span style="font-family:'Manrope',Arial,Helvetica,sans-serif;font-size:32px;font-weight:800;letter-spacing:8px;color:#000000;">{{ $code }}</span>
</td>
</tr>
</table>
<p style="margin:0 0 16px;color:#5c5c5c;font-size:14px;">Ο κωδικός ισχύει για περιορισμένο χρονικό διάστημα και μπορεί να χρησιμοποιηθεί μία μόνο φορά.</p>
<p style="margin:0;color:#5c5c5c;font-size:14px;">Αν δεν ζήτησες εσύ αυτόν τον κωδικό, αγνόησε αυτό το email &mdash; ο λογαριασμός σου παραμένει ασφαλής.</p>
@endsection
@section('footer')
<p style="margin:0;">Αυτό το email στάλθηκε επειδή ζητήθηκε σύνδεση στο διαχειριστικό του 3dealer.</p>
@endsection
@@ -0,0 +1,32 @@
@extends('emails.layout')
@section('title', 'Ο κωδικός σύνδεσής σου')
@section('preheader', "Ο κωδικός σύνδεσής σου στο 3dealer: {$code}")
@section('content')
<h1 style="margin:0 0 20px;font-family:'Manrope',Arial,Helvetica,sans-serif;font-size:22px;line-height:1.3;font-weight:700;color:#000000;">
Ο κωδικός σύνδεσής σου
</h1>
{{-- A brand-new account has no name yet, and core falls back to the email — skip that. --}}
<p style="margin:0 0 16px;">Γεια σου{{ str_contains($name, '@') ? '' : ' '.$name }},</p>
<p style="margin:0 0 24px;">Χρησιμοποίησε τον παρακάτω κωδικό για να συνδεθείς στον λογαριασμό σου στο 3dealer.</p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:0 0 24px;">
<tr>
<td align="center" bgcolor="#18c28a" style="background-color:#18c28a;border:1px solid #000000;border-radius:0;padding:20px;">
<span style="font-family:'Manrope',Arial,Helvetica,sans-serif;font-size:32px;font-weight:800;letter-spacing:8px;color:#000000;">{{ $code }}</span>
</td>
</tr>
</table>
<p style="margin:0 0 16px;color:#5c5c5c;font-size:14px;">Ο κωδικός ισχύει για 10 λεπτά και μπορεί να χρησιμοποιηθεί μία μόνο φορά.</p>
<p style="margin:0;color:#5c5c5c;font-size:14px;">Αν δεν ζήτησες εσύ αυτόν τον κωδικό, αγνόησε αυτό το email &mdash; ο λογαριασμός σου παραμένει ασφαλής.</p>
@endsection
@section('footer')
<p style="margin:0;">Αυτό το email στάλθηκε επειδή ζητήθηκε σύνδεση στο 3dealer με αυτή τη διεύθυνση email.</p>
@endsection
@@ -1,3 +1,26 @@
<p>Hi,</p>
{{-- Core passes only the order reference here (and the amount for payment emails). Greek on purpose: sent from the queue in the app default locale. --}}
@extends('emails.layout')
<p>Payment of <strong>{{ $amount }}</strong> for your order <strong>{{ $reference }}</strong> has been captured.</p>
@section('title', "Λάβαμε την πληρωμή σου")
@section('preheader', "Λάβαμε την πληρωμή {$amount} για την παραγγελία #{$reference}.")
@section('content')
<h1 style="margin:0 0 16px;font-family:'Manrope',Arial,Helvetica,sans-serif;font-size:22px;line-height:1.3;font-weight:700;color:#000000;">
Λάβαμε την πληρωμή σου
</h1>
<p style="margin:0 0 32px;">Λάβαμε την πληρωμή σου <strong>{{ $amount }}</strong> για την παραγγελία <strong>#{{ $reference }}</strong>.</p>
@include('emails.partials.button', [
'url' => route('account.orders', ['locale' => 'el']),
'label' => 'Οι παραγγελίες μου',
])
@endsection
@section('footer')
<p style="margin:0;">
Έχεις ερώτηση για την παραγγελία σου;
<a href="{{ route('contact', ['locale' => 'el']) }}" style="color:#7a7a7a;text-decoration:underline;">Επικοινώνησε μαζί μας</a>.
</p>
@endsection
@@ -1,3 +1,26 @@
<p>Hi,</p>
{{-- Core passes only the order reference here (and the amount for payment emails). Greek on purpose: sent from the queue in the app default locale. --}}
@extends('emails.layout')
<p>Your order <strong>{{ $reference }}</strong> is complete. Thanks for shopping with us!</p>
@section('title', "Η παραγγελία σου ολοκληρώθηκε")
@section('preheader', "Η παραγγελία σου #{$reference} ολοκληρώθηκε.")
@section('content')
<h1 style="margin:0 0 16px;font-family:'Manrope',Arial,Helvetica,sans-serif;font-size:22px;line-height:1.3;font-weight:700;color:#000000;">
Η παραγγελία σου ολοκληρώθηκε
</h1>
<p style="margin:0 0 32px;">Η παραγγελία σου <strong>#{{ $reference }}</strong> ολοκληρώθηκε. Ευχαριστούμε που ψώνισες από εμάς!</p>
@include('emails.partials.button', [
'url' => route('account.orders', ['locale' => 'el']),
'label' => 'Οι παραγγελίες μου',
])
@endsection
@section('footer')
<p style="margin:0;">
Έχεις ερώτηση για την παραγγελία σου;
<a href="{{ route('contact', ['locale' => 'el']) }}" style="color:#7a7a7a;text-decoration:underline;">Επικοινώνησε μαζί μας</a>.
</p>
@endsection
@@ -1,3 +1,26 @@
<p>Hi,</p>
{{-- Core passes only the order reference here (and the amount for payment emails). Greek on purpose: sent from the queue in the app default locale. --}}
@extends('emails.layout')
<p>Good news — your order <strong>{{ $reference }}</strong> has been delivered.</p>
@section('title', "Η παραγγελία σου παραδόθηκε")
@section('preheader', "Η παραγγελία σου #{$reference} παραδόθηκε.")
@section('content')
<h1 style="margin:0 0 16px;font-family:'Manrope',Arial,Helvetica,sans-serif;font-size:22px;line-height:1.3;font-weight:700;color:#000000;">
Η παραγγελία σου παραδόθηκε
</h1>
<p style="margin:0 0 32px;">Η παραγγελία σου <strong>#{{ $reference }}</strong> παραδόθηκε. Ελπίζουμε να σου αρέσει!</p>
@include('emails.partials.button', [
'url' => route('account.orders', ['locale' => 'el']),
'label' => 'Οι παραγγελίες μου',
])
@endsection
@section('footer')
<p style="margin:0;">
Έχεις ερώτηση για την παραγγελία σου;
<a href="{{ route('contact', ['locale' => 'el']) }}" style="color:#7a7a7a;text-decoration:underline;">Επικοινώνησε μαζί μας</a>.
</p>
@endsection
@@ -1,3 +1,26 @@
<p>Hi,</p>
{{-- Core passes only the order reference here (and the amount for payment emails). Greek on purpose: sent from the queue in the app default locale. --}}
@extends('emails.layout')
<p>Your order <strong>{{ $reference }}</strong> is on its way.</p>
@section('title', "Η παραγγελία σου στάλθηκε")
@section('preheader', "Η παραγγελία σου #{$reference} είναι καθ’ οδόν.")
@section('content')
<h1 style="margin:0 0 16px;font-family:'Manrope',Arial,Helvetica,sans-serif;font-size:22px;line-height:1.3;font-weight:700;color:#000000;">
Η παραγγελία σου στάλθηκε
</h1>
<p style="margin:0 0 32px;">Η παραγγελία σου <strong>#{{ $reference }}</strong> είναι καθ’ οδόν. Θα τη λάβεις σύντομα.</p>
@include('emails.partials.button', [
'url' => route('account.orders', ['locale' => 'el']),
'label' => 'Οι παραγγελίες μου',
])
@endsection
@section('footer')
<p style="margin:0;">
Έχεις ερώτηση για την παραγγελία σου;
<a href="{{ route('contact', ['locale' => 'el']) }}" style="color:#7a7a7a;text-decoration:underline;">Επικοινώνησε μαζί μας</a>.
</p>
@endsection
@@ -1,3 +1,26 @@
<p>Hi,</p>
{{-- Core passes only the order reference here (and the amount for payment emails). Greek on purpose: sent from the queue in the app default locale. --}}
@extends('emails.layout')
<p>Your order <strong>{{ $reference }}</strong> is ready for pickup in store.</p>
@section('title', "Η παραγγελία σου είναι έτοιμη για παραλαβή")
@section('preheader', "Η παραγγελία σου #{$reference} σε περιμένει στο κατάστημα.")
@section('content')
<h1 style="margin:0 0 16px;font-family:'Manrope',Arial,Helvetica,sans-serif;font-size:22px;line-height:1.3;font-weight:700;color:#000000;">
Η παραγγελία σου είναι έτοιμη για παραλαβή
</h1>
<p style="margin:0 0 32px;">Η παραγγελία σου <strong>#{{ $reference }}</strong> είναι έτοιμη και σε περιμένει στο κατάστημα.</p>
@include('emails.partials.button', [
'url' => route('account.orders', ['locale' => 'el']),
'label' => 'Οι παραγγελίες μου',
])
@endsection
@section('footer')
<p style="margin:0;">
Έχεις ερώτηση για την παραγγελία σου;
<a href="{{ route('contact', ['locale' => 'el']) }}" style="color:#7a7a7a;text-decoration:underline;">Επικοινώνησε μαζί μας</a>.
</p>
@endsection
@@ -1,11 +1,175 @@
<p>Hi,</p>
{{--
Order confirmation (boboko-core's OrderPlacedNotification).
<p>Thanks for your order! Your order <strong>{{ $reference }}</strong> is confirmed.</p>
Core only passes $reference, $total and $lines, so the order itself is
reached through a line (every OrderLine belongs to it). Interim until core
passes the Order to this view.
<ul>
@foreach ($lines as $line)
<li>{{ $line->quantity }} &times; {{ $line->description }} — {{ $line->total?->formatted }}</li>
@endforeach
</ul>
Sent from the queue with no locale of its own, so it always renders in the
app default (Greek). Copy is Greek on purpose, like the other core emails.
--}}
@extends('emails.layout')
<p>Total: <strong>{{ $total }}</strong></p>
@php
$order = $lines->first()->order;
$productLines = $lines->where('type', '!=', 'shipping');
$shippingLine = $lines->firstWhere('type', 'shipping');
// By type, like the account order page: the method may since have been
// disabled, but the order still shows what was used.
$paymentMethodName = \Modules\Core\Payment\Models\PaymentMethod::where('type', $order->meta['payment_method'] ?? null)
->first()
?->translate('name');
// Lunar doesn't copy the coupon onto the order; the cart row keeps it.
$couponCode = $order->cart?->coupon_code;
$email = $order->billingAddress?->contact_email ?? $order->shippingAddress?->contact_email;
$comments = $order->shippingAddress?->delivery_instructions;
$muted = 'color:#5c5c5c;font-size:14px;';
$font = "font-family:'Manrope',Arial,Helvetica,sans-serif;";
@endphp
@section('title', "Η παραγγελία σου #{$reference} επιβεβαιώθηκε")
@section('preheader', "Ευχαριστούμε! Λάβαμε την παραγγελία σου #{$reference}, σύνολο {$total}.")
@section('content')
<h1 style="margin:0 0 16px;{{ $font }}font-size:22px;line-height:1.3;font-weight:700;color:#000000;">
Ευχαριστούμε για την παραγγελία σου!
</h1>
<p style="margin:0 0 24px;">
Λάβαμε την παραγγελία σου <strong>#{{ $reference }}</strong>
στις {{ $order->placed_at?->format('d/m/Y') }}.
Θα σε ενημερώσουμε με email μόλις σταλεί.
</p>
<div style="margin:0 0 40px;">
@include('emails.partials.button', [
'url' => route('account.orders.show', ['locale' => 'el', 'orderId' => $order->id]),
'label' => 'Δες την παραγγελία σου',
])
</div>
{{-- Items --}}
@include('emails.partials.section-heading', ['text' => 'Προϊόντα'])
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:0 0 8px;">
@foreach ($productLines as $line)
<tr>
<td width="64" valign="top" style="width:64px;padding:12px 12px 12px 0;{{ $loop->last ? '' : 'border-bottom:1px solid #e5e5e5;' }}">
@if ($thumb = $line->purchasable?->getThumbnailImage())
<img src="{{ $thumb }}" width="64" height="64" alt="{{ $line->description }}" style="display:block;width:64px;height:64px;object-fit:cover;background-color:#e5e5e5;border:0;{{ $font }}font-size:11px;color:#5c5c5c;">
@else
<div style="width:64px;height:64px;background-color:#e5e5e5;"></div>
@endif
</td>
<td valign="top" style="padding:12px 12px 12px 0;{{ $font }}font-size:15px;line-height:1.4;color:#000000;{{ $loop->last ? '' : 'border-bottom:1px solid #e5e5e5;' }}">
<strong>{{ $line->description }}</strong>
@if ($line->option)
<br><span style="{{ $muted }}">{{ $line->option }}</span>
@endif
{{-- Text answers only: photo answers are deliberately left out of emails. --}}
@foreach ($line->meta['custom_fields'] ?? [] as $field)
@if ($field['type'] !== 'file' && filled($field['value'] ?? null))
<br><span style="{{ $muted }}">{{ $field['label'] }}: {!! nl2br(e($field['value'])) !!}</span>
@endif
@endforeach
<br><span style="{{ $muted }}">
Ποσότητα: {{ $line->quantity }}@if ($line->quantity > 1) &times; {{ $line->unit_price->formatted() }}@endif
</span>
</td>
<td valign="top" align="right" style="padding:12px 0;white-space:nowrap;{{ $font }}font-size:15px;line-height:1.4;font-weight:700;color:#000000;{{ $loop->last ? '' : 'border-bottom:1px solid #e5e5e5;' }}">
{{ $line->sub_total?->formatted() }}
</td>
</tr>
@endforeach
</table>
{{-- Totals --}}
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:0 0 40px;border-top:1px solid #000000;">
<tr>
<td style="padding:12px 0 4px;{{ $font }}font-size:15px;color:#000000;">Υποσύνολο</td>
<td align="right" style="padding:12px 0 4px;white-space:nowrap;{{ $font }}font-size:15px;color:#000000;">{{ $order->sub_total?->formatted() }}</td>
</tr>
@if ($order->discount_total?->value > 0)
<tr>
<td style="padding:4px 0;{{ $font }}font-size:15px;color:#000000;">
Έκπτωση@if ($couponCode) <span style="{{ $muted }}">({{ $couponCode }})</span>@endif
</td>
<td align="right" style="padding:4px 0;white-space:nowrap;{{ $font }}font-size:15px;color:#000000;">&minus;{{ $order->discount_total->formatted() }}</td>
</tr>
@endif
<tr>
<td style="padding:4px 0;{{ $font }}font-size:15px;color:#000000;">
Μεταφορικά@if ($shippingLine) <span style="{{ $muted }}">({{ $shippingLine->description }})</span>@endif
</td>
<td align="right" style="padding:4px 0;white-space:nowrap;{{ $font }}font-size:15px;color:#000000;">
{{ $order->shipping_total?->value > 0 ? $order->shipping_total->formatted() : 'Δωρεάν' }}
</td>
</tr>
<tr>
<td style="padding:12px 0 0;border-top:1px solid #000000;{{ $font }}font-size:20px;line-height:1.3;font-weight:800;color:#000000;">Σύνολο</td>
<td align="right" style="padding:12px 0 0;border-top:1px solid #000000;white-space:nowrap;{{ $font }}font-size:20px;line-height:1.3;font-weight:800;color:#000000;">{{ $total }}</td>
</tr>
{{-- Prices include VAT (lunar.pricing.stored_inclusive_of_tax), so this is informational. --}}
@if ($order->tax_total?->value > 0)
<tr>
<td colspan="2" align="right" style="padding:4px 0 0;{{ $font }}{{ $muted }}">Περιλαμβάνεται Φ.Π.Α. {{ $order->tax_total->formatted() }}</td>
</tr>
@endif
</table>
{{-- Payment + shipping --}}
@include('emails.partials.section-heading', ['text' => 'Πληρωμή και αποστολή'])
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:0 0 40px;">
@foreach (array_filter([
'Τρόπος πληρωμής' => $paymentMethodName,
'Τρόπος αποστολής' => $shippingLine?->description,
'Παραστατικό' => ($order->meta['wants_invoice'] ?? false) ? 'Τιμολόγιο' : 'Απόδειξη',
'Email' => $email,
]) as $label => $value)
<tr>
<td valign="top" style="padding:4px 12px 4px 0;{{ $font }}{{ $muted }}line-height:1.5;">{{ $label }}</td>
<td valign="top" align="right" style="padding:4px 0;{{ $font }}font-size:15px;line-height:1.5;color:#000000;word-break:break-word;">{{ $value }}</td>
</tr>
@endforeach
</table>
{{-- Addresses: stacked, never side by side, so they read well on a phone. --}}
@if ($order->shippingAddress)
@include('emails.partials.section-heading', ['text' => 'Διεύθυνση αποστολής'])
<div style="margin:0 0 {{ $comments ? '16px' : '40px' }};">
@include('emails.partials.address', ['address' => $order->shippingAddress])
</div>
@if ($comments)
<p style="margin:0 0 40px;font-size:15px;line-height:1.6;">
<span style="{{ $muted }}">Σχόλια</span><br>
{!! nl2br(e($comments)) !!}
</p>
@endif
@endif
@if ($order->billingAddress)
@include('emails.partials.section-heading', ['text' => 'Στοιχεία χρέωσης'])
@include('emails.partials.address', ['address' => $order->billingAddress])
@endif
@endsection
@section('footer')
<p style="margin:0;">
Έχεις ερώτηση για την παραγγελία σου;
<a href="{{ route('contact', ['locale' => 'el']) }}" style="color:#7a7a7a;text-decoration:underline;">Επικοινώνησε μαζί μας</a>.
</p>
@endsection
@@ -1,3 +1,26 @@
<p>Hi,</p>
{{-- Core passes only the order reference here (and the amount for payment emails). Greek on purpose: sent from the queue in the app default locale. --}}
@extends('emails.layout')
<p>A refund of <strong>{{ $amount }}</strong> has been issued for your order <strong>{{ $reference }}</strong>.</p>
@section('title', "Έγινε επιστροφή χρημάτων")
@section('preheader', "Επιστρέψαμε {$amount} για την παραγγελία #{$reference}.")
@section('content')
<h1 style="margin:0 0 16px;font-family:'Manrope',Arial,Helvetica,sans-serif;font-size:22px;line-height:1.3;font-weight:700;color:#000000;">
Έγινε επιστροφή χρημάτων
</h1>
<p style="margin:0 0 32px;">Σου επιστρέψαμε <strong>{{ $amount }}</strong> για την παραγγελία <strong>#{{ $reference }}</strong>.</p>
@include('emails.partials.button', [
'url' => route('account.orders', ['locale' => 'el']),
'label' => 'Οι παραγγελίες μου',
])
@endsection
@section('footer')
<p style="margin:0;">
Έχεις ερώτηση για την παραγγελία σου;
<a href="{{ route('contact', ['locale' => 'el']) }}" style="color:#7a7a7a;text-decoration:underline;">Επικοινώνησε μαζί μας</a>.
</p>
@endsection
@@ -1,3 +1,34 @@
<p>Hi,</p>
{{-- Core passes only the order reference here (and the amount for payment emails). Greek on purpose: sent from the queue in the app default locale. --}}
@extends('emails.layout')
<p>Your order <strong>{{ $reference }}</strong> is now: <strong>{{ $statusLabel }}</strong></p>
@php
// Core passes Lunar's English label; map it back to the status key so the
// shared <x-order-status> can show the translated one. Interim, until core
// passes the order (or its status) to this view.
$status = collect(config('lunar.orders.statuses'))
->search(fn (array $config) => $config['label'] === $statusLabel) ?: $statusLabel;
@endphp
@section('title', "Ενημέρωση παραγγελίας")
@section('preheader', "Η παραγγελία σου #{$reference} ενημερώθηκε.")
@section('content')
<h1 style="margin:0 0 16px;font-family:'Manrope',Arial,Helvetica,sans-serif;font-size:22px;line-height:1.3;font-weight:700;color:#000000;">
Η παραγγελία σου ενημερώθηκε
</h1>
<p style="margin:0 0 32px;">Η κατάσταση της παραγγελίας σου <strong>#{{ $reference }}</strong> είναι τώρα: <strong><x-order-status :status="$status" /></strong></p>
@include('emails.partials.button', [
'url' => route('account.orders', ['locale' => 'el']),
'label' => 'Οι παραγγελίες μου',
])
@endsection
@section('footer')
<p style="margin:0;">
Έχεις ερώτηση για την παραγγελία σου;
<a href="{{ route('contact', ['locale' => 'el']) }}" style="color:#7a7a7a;text-decoration:underline;">Επικοινώνησε μαζί μας</a>.
</p>
@endsection
+29
View File
@@ -0,0 +1,29 @@
{{-- A guest's wishlist (from the cookie). Logged-in users get account/wishlist instead. --}}
@extends('layouts.app')
@section('title', __('storefront.account.nav_wishlist'))
@push('seo')
<meta name="robots" content="noindex, follow">
@endpush
@section('content')
<section class="flex flex-col gap-12 px-4 py-20 sm:px-8 lg:px-16">
<div class="flex flex-col gap-6">
<h1 class="font-display text-h3 font-black tracking-tight sm:text-h2">
{{ __('storefront.account.nav_wishlist') }}
</h1>
<p>
{{ __('storefront.wishlist.guest_hint') }}
<a href="{{ route('login') }}" class="font-bold underline hover:no-underline">{{ __('storefront.auth.login') }}</a>
</p>
</div>
@include('wishlist.list')
</section>
@endsection
+39
View File
@@ -0,0 +1,39 @@
{{--
The wishlist grid (or its empty state), shared by the account page
(account/wishlist) and the guest page (wishlist/guest). Expects $products
from WishlistController::products().
--}}
@if ($products->isEmpty())
<div class="flex max-w-2xl flex-col items-start gap-8">
<p>{{ __('storefront.wishlist.empty') }}</p>
<x-ui.button :href="route('products')" size="md">{{ __('storefront.orders.shop_now') }}</x-ui.button>
</div>
@else
<ul class="grid grid-cols-2 gap-9 md:grid-cols-3 2xl:grid-cols-4">
@foreach ($products as $product)
<li class="flex flex-col gap-4">
<x-ui.product-card
:name="$product['name']"
:price="$product['price']"
:image="$product['image']"
:href="$product['href']"
:variant-id="$product['variantId']"
:has-custom-fields="$product['hasCustomFields']"
:sold-out="$product['soldOut']"
:product-id="$product['id']"
:wishlist="true"
/>
{{-- Plain post (no fetch): the page reloads without the product. --}}
<form method="POST" action="{{ route('wishlist.toggle', ['productId' => $product['id']]) }}">
@csrf
<button
type="submit"
class="cursor-pointer text-sm underline hover:no-underline"
aria-label="{{ __('storefront.wishlist.remove_named', ['name' => $product['name']]) }}"
>{{ __('storefront.wishlist.remove_short') }}</button>
</form>
</li>
@endforeach
</ul>
@endif