430 lines
19 KiB
HTML
430 lines
19 KiB
HTML
<title>Checkout Feature Survey</title>
|
|
<style>
|
|
:root {
|
|
--paper: #FAFAF7;
|
|
--ink: #1C1C1A;
|
|
--muted: #6B6B63;
|
|
--accent: #2F5D50;
|
|
--accent-soft: #E4EDE9;
|
|
--good: #3F7A5C;
|
|
--good-soft: #E6F0EA;
|
|
--warn: #B8863B;
|
|
--warn-soft: #F5ECDC;
|
|
--miss: #A14B3B;
|
|
--miss-soft: #F5E5E0;
|
|
--hairline: #E4E2DB;
|
|
--card: #FFFFFF;
|
|
}
|
|
|
|
:root:not([data-theme="light"]) {
|
|
@media (prefers-color-scheme: dark) {
|
|
--paper: #17181A;
|
|
--ink: #EDEBE4;
|
|
--muted: #9B9A90;
|
|
--accent: #7FBFA8;
|
|
--accent-soft: #1E2C27;
|
|
--good: #6FBF97;
|
|
--good-soft: #1B2A22;
|
|
--warn: #D9A85C;
|
|
--warn-soft: #2C2418;
|
|
--miss: #D97C68;
|
|
--miss-soft: #2E1E1A;
|
|
--hairline: #2C2D2E;
|
|
--card: #1E1F21;
|
|
}
|
|
}
|
|
|
|
:root[data-theme="dark"] {
|
|
--paper: #17181A;
|
|
--ink: #EDEBE4;
|
|
--muted: #9B9A90;
|
|
--accent: #7FBFA8;
|
|
--accent-soft: #1E2C27;
|
|
--good: #6FBF97;
|
|
--good-soft: #1B2A22;
|
|
--warn: #D9A85C;
|
|
--warn-soft: #2C2418;
|
|
--miss: #D97C68;
|
|
--miss-soft: #2E1E1A;
|
|
--hairline: #2C2D2E;
|
|
--card: #1E1F21;
|
|
}
|
|
|
|
* { box-sizing: border-box; }
|
|
|
|
body {
|
|
background: var(--paper);
|
|
color: var(--ink);
|
|
font-family: "IBM Plex Sans", ui-sans-serif, system-ui, sans-serif;
|
|
font-size: 15.5px;
|
|
line-height: 1.55;
|
|
margin: 0;
|
|
padding: 4.5rem 1.5rem 6rem;
|
|
}
|
|
|
|
.wrap {
|
|
max-width: 780px;
|
|
margin: 0 auto;
|
|
}
|
|
|
|
header.page {
|
|
margin-bottom: 3.25rem;
|
|
}
|
|
|
|
.eyebrow {
|
|
font-family: "IBM Plex Mono", ui-monospace, monospace;
|
|
font-size: 0.72rem;
|
|
letter-spacing: 0.12em;
|
|
text-transform: uppercase;
|
|
color: var(--accent);
|
|
margin-bottom: 0.9rem;
|
|
}
|
|
|
|
h1 {
|
|
font-family: "Fraunces", Georgia, serif;
|
|
font-weight: 560;
|
|
font-size: clamp(2.1rem, 4.5vw, 2.65rem);
|
|
line-height: 1.08;
|
|
letter-spacing: -0.01em;
|
|
margin: 0 0 0.9rem;
|
|
text-wrap: balance;
|
|
}
|
|
|
|
.dek {
|
|
color: var(--muted);
|
|
max-width: 60ch;
|
|
font-size: 1.02rem;
|
|
}
|
|
|
|
.dek strong {
|
|
color: var(--ink);
|
|
font-weight: 600;
|
|
}
|
|
|
|
.legend {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.6rem;
|
|
margin-top: 1.6rem;
|
|
}
|
|
|
|
.chip {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.4rem;
|
|
font-family: "IBM Plex Mono", ui-monospace, monospace;
|
|
font-size: 0.72rem;
|
|
letter-spacing: 0.04em;
|
|
padding: 0.28rem 0.6rem;
|
|
border-radius: 3px;
|
|
}
|
|
|
|
.chip.have { background: var(--good-soft); color: var(--good); }
|
|
.chip.partial { background: var(--warn-soft); color: var(--warn); }
|
|
.chip.missing { background: var(--miss-soft); color: var(--miss); }
|
|
|
|
section.category {
|
|
margin-top: 3rem;
|
|
}
|
|
|
|
.cat-head {
|
|
display: flex;
|
|
align-items: baseline;
|
|
gap: 0.85rem;
|
|
border-bottom: 1px solid var(--hairline);
|
|
padding-bottom: 0.7rem;
|
|
margin-bottom: 1.1rem;
|
|
}
|
|
|
|
.cat-num {
|
|
font-family: "Fraunces", Georgia, serif;
|
|
font-size: 1.05rem;
|
|
color: var(--accent);
|
|
font-variant-numeric: tabular-nums;
|
|
min-width: 1.6rem;
|
|
}
|
|
|
|
.cat-head h2 {
|
|
font-family: "Fraunces", Georgia, serif;
|
|
font-weight: 500;
|
|
font-size: 1.28rem;
|
|
margin: 0;
|
|
letter-spacing: -0.005em;
|
|
}
|
|
|
|
.cat-note {
|
|
color: var(--muted);
|
|
font-size: 0.86rem;
|
|
margin: 0 0 1.2rem;
|
|
max-width: 62ch;
|
|
}
|
|
|
|
.feature {
|
|
display: grid;
|
|
grid-template-columns: 1fr auto;
|
|
gap: 0.3rem 1rem;
|
|
padding: 1.05rem 0;
|
|
border-bottom: 1px solid var(--hairline);
|
|
align-items: start;
|
|
}
|
|
|
|
.feature:last-child { border-bottom: none; }
|
|
|
|
.f-name {
|
|
font-weight: 600;
|
|
font-size: 0.98rem;
|
|
}
|
|
|
|
.f-status {
|
|
font-family: "IBM Plex Mono", ui-monospace, monospace;
|
|
font-size: 0.68rem;
|
|
letter-spacing: 0.06em;
|
|
text-transform: uppercase;
|
|
padding: 0.22rem 0.55rem;
|
|
border-radius: 3px;
|
|
white-space: nowrap;
|
|
height: fit-content;
|
|
}
|
|
|
|
.f-status.have { background: var(--good-soft); color: var(--good); }
|
|
.f-status.partial { background: var(--warn-soft); color: var(--warn); }
|
|
.f-status.missing { background: var(--miss-soft); color: var(--miss); }
|
|
|
|
.f-note {
|
|
grid-column: 1 / -1;
|
|
color: var(--muted);
|
|
font-size: 0.87rem;
|
|
margin-top: 0.15rem;
|
|
max-width: 66ch;
|
|
}
|
|
|
|
.f-note code {
|
|
font-family: "IBM Plex Mono", ui-monospace, monospace;
|
|
font-size: 0.82em;
|
|
background: var(--accent-soft);
|
|
color: var(--accent);
|
|
padding: 0.08em 0.35em;
|
|
border-radius: 3px;
|
|
}
|
|
|
|
footer.page {
|
|
margin-top: 4rem;
|
|
padding-top: 1.5rem;
|
|
border-top: 1px solid var(--hairline);
|
|
color: var(--muted);
|
|
font-size: 0.82rem;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
gap: 1rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
footer.page a { color: var(--accent); }
|
|
|
|
@media (max-width: 560px) {
|
|
body { padding: 3rem 1.1rem 4rem; }
|
|
.feature { grid-template-columns: 1fr; }
|
|
.f-status { justify-self: start; }
|
|
}
|
|
</style>
|
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400..600&family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap">
|
|
|
|
<div class="wrap">
|
|
|
|
<header class="page">
|
|
<div class="eyebrow">boboko / checkout · competitive survey</div>
|
|
<h1>What checkout elsewhere can do that boboko can't yet</h1>
|
|
<p class="dek">
|
|
A feature-by-feature pass across Shopify, WooCommerce, and PrestaShop's checkout
|
|
layer — sourced, not recalled from memory — checked against what
|
|
<strong>Lunar's <code>Cart::createOrder()</code> / order-creation pipeline</strong>
|
|
actually supports today. Companion to the Cart survey: this starts where that one
|
|
left off — address and shipping-option capture through to a placed order. For
|
|
deciding what to design next, not a build order.
|
|
</p>
|
|
<div class="legend">
|
|
<span class="chip have">● have</span>
|
|
<span class="chip partial">◐ partial</span>
|
|
<span class="chip missing">○ missing</span>
|
|
</div>
|
|
</header>
|
|
|
|
<section class="category">
|
|
<div class="cat-head">
|
|
<span class="cat-num">01</span>
|
|
<h2>Getting to checkout</h2>
|
|
</div>
|
|
<p class="cat-note">Who's allowed to check out, and in how many steps.</p>
|
|
|
|
<div class="feature">
|
|
<div class="f-name">Guest checkout (no account required)</div>
|
|
<span class="f-status have">have</span>
|
|
<div class="f-note">Structural, not bolted-on: <code>Order.user_id</code> and <code>customer_id</code> are both nullable, and <code>ValidateCartForOrderCreation</code> never checks for either — it only requires a billing address and, if shippable, a shipping address + option. A cart with no <code>user_id</code> creates an order fine.</div>
|
|
</div>
|
|
|
|
<div class="feature">
|
|
<div class="f-name">One-page vs. multi-step checkout</div>
|
|
<span class="f-status missing">missing</span>
|
|
<div class="f-note">Pure storefront-UI concern — Lunar has no opinion here, it just exposes <code>setShippingAddress()</code>/<code>setBillingAddress()</code>/<code>setShippingOption()</code> as independent calls that a UI can sequence however it likes. WooCommerce and PrestaShop both ship one-page as a plugin/theme layer, not core, so this isn't a Lunar gap so much as storefront work still to do.</div>
|
|
</div>
|
|
|
|
<div class="feature">
|
|
<div class="f-name">Address autocomplete (type-ahead, from Google Places / Loqate)</div>
|
|
<span class="f-status missing">missing</span>
|
|
<div class="f-note">Research: cuts address-entry keystrokes by 70%+ and is a proven abandonment-reduction tactic (Google Maps Platform, Loqate). No Lunar hook for it either way — it's a storefront form concern layered on top of the same <code>setShippingAddress()</code> call.</div>
|
|
</div>
|
|
|
|
<div class="feature">
|
|
<div class="f-name">Express/accelerated checkout (Shop Pay, Apple Pay, Google Pay equivalents)</div>
|
|
<span class="f-status missing">missing</span>
|
|
<div class="f-note">Research: Shopify reports Shop Pay can lift conversion up to 50% over guest checkout, mobile especially. Lunar's <code>Payments</code> facade is driver-based (<code>Payments::driver('card')</code>) so a wallet driver is architecturally pluggable, but none ships, and there's no one-tap "skip the address form" path since address capture still runs through the standard cart-address flow first.</div>
|
|
</div>
|
|
|
|
<div class="feature">
|
|
<div class="f-name">Terms & conditions acceptance at checkout</div>
|
|
<span class="f-status partial">partial</span>
|
|
<div class="f-note"><code>Order.meta</code> and <code>Cart.meta</code> are both free-form JSON columns carried straight through <code>FillOrderFromCart</code> (<code>'meta' => $cart->meta</code>) — technically able to record a timestamp/version of accepted terms today, but no dedicated field, checkbox validation, or admin display exists.</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="category">
|
|
<div class="cat-head">
|
|
<span class="cat-num">02</span>
|
|
<h2>Order creation mechanics</h2>
|
|
</div>
|
|
<p class="cat-note">What actually happens inside <code>createOrder()</code>, verified from source.</p>
|
|
|
|
<div class="feature">
|
|
<div class="f-name">Duplicate-order prevention on repeat submits</div>
|
|
<span class="f-status have">have</span>
|
|
<div class="f-note">Two layers, both real: <code>Cart::draftOrder()</code> matches on <code>fingerprint()</code> + <code>total</code>, so re-running <code>createOrder()</code> on an unchanged cart reuses the same draft order instead of duplicating it (<code>CreateOrder::execute()</code>); once an order is placed, <code>hasCompletedOrders()</code> throws <code>DisallowMultipleCartOrdersException</code> unless <code>allowMultipleOrders</code> is explicitly passed.</div>
|
|
</div>
|
|
|
|
<div class="feature">
|
|
<div class="f-name">Draft order created before payment, finalized after</div>
|
|
<span class="f-status have">have</span>
|
|
<div class="f-note"><code>Order::isDraft()</code>/<code>isPlaced()</code> gate on <code>placed_at</code>; <code>orders.draft_status</code> config (default <code>awaiting-payment</code>) sets the initial status. The order exists — and can be re-run through the pipeline idempotently via the fingerprint match above — before a payment driver ever authorizes anything.</div>
|
|
</div>
|
|
|
|
<div class="feature">
|
|
<div class="f-name">Order address, line, and shipping-line snapshotting from cart</div>
|
|
<span class="f-status have">have</span>
|
|
<div class="f-note">The whole <code>orders.pipelines.creation</code> chain does this explicitly — <code>FillOrderFromCart</code>, <code>CreateOrderLines</code>, <code>CreateOrderAddresses</code>, <code>CreateShippingLine</code>, <code>CleanUpOrderLines</code>, <code>MapDiscountBreakdown</code> — each copying cart state into immutable order rows rather than referencing the cart live.</div>
|
|
</div>
|
|
|
|
<div class="feature">
|
|
<div class="f-name">Address validation before order creation</div>
|
|
<span class="f-status have">have</span>
|
|
<div class="f-note"><code>ValidateCartForOrderCreation</code> requires <code>country_id</code>, <code>first_name</code>, <code>line_one</code>, <code>city</code>, <code>postcode</code> on billing always, and on shipping too unless the chosen <code>ShippingOption->collect</code> is true (in-store pickup skips a shipping address).</div>
|
|
</div>
|
|
|
|
<div class="feature">
|
|
<div class="f-name">Exchange rate and currency locked at order time</div>
|
|
<span class="f-status have">have</span>
|
|
<div class="f-note"><code>FillOrderFromCart</code> copies <code>currency_code</code> and <code>exchange_rate</code> from the cart's currency onto the order at creation — later currency-config changes don't retroactively alter placed orders.</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="category">
|
|
<div class="cat-head">
|
|
<span class="cat-num">03</span>
|
|
<h2>Confirmation & communication</h2>
|
|
</div>
|
|
<p class="cat-note">What tells the customer (and staff) an order happened.</p>
|
|
|
|
<div class="feature">
|
|
<div class="f-name">Order confirmation email on placement</div>
|
|
<span class="f-status missing">missing</span>
|
|
<div class="f-note">Surprising given how close it looks to shipping: every status in <code>config/lunar/orders.php</code> carries a <code>mailers</code> and <code>notifications</code> array, but grep across core turns up exactly one reader of that config (<code>Order::getStatusLabelAttribute()</code>, and it only reads <code>label</code>). Nothing in core ever dispatches a mailer or notification from a status change — those keys are unwired placeholders, not a working feature.</div>
|
|
</div>
|
|
|
|
<div class="feature">
|
|
<div class="f-name">Order-status-changed events</div>
|
|
<span class="f-status missing">missing</span>
|
|
<div class="f-note">Same gap as Cart's event survey found — <code>src/Events/</code> in core contains only <code>PaymentAttemptEvent</code>. No <code>OrderCreated</code>, no <code>OrderStatusUpdated</code>. Confirmation email, staff Slack ping, or customer SMS on status change all have to be built from scratch on plain Eloquent model events (<code>Order::updated()</code>), same pattern as the cart-event gap.</div>
|
|
</div>
|
|
|
|
<div class="feature">
|
|
<div class="f-name">Order tracking / status lookup for guests</div>
|
|
<span class="f-status missing">missing</span>
|
|
<div class="f-note">Research: PrestaShop's order-tracking extensions explicitly cover "non-logged-in customers track their orders." Lunar has the data (<code>Order.reference</code>, <code>status</code>, <code>OrderAddress.contact_email</code>) but no lookup mechanism — a guest with no account has no route back to their order without the confirmation email that also doesn't exist yet.</div>
|
|
</div>
|
|
|
|
<div class="feature">
|
|
<div class="f-name">New-customer detection on first order</div>
|
|
<span class="f-status have">have</span>
|
|
<div class="f-note"><code>CreateOrder::execute()</code> dispatches <code>MarkAsNewCustomer::dispatch($order->id)</code> as a queued job after every order creation — genuinely wired, unlike the mail/notification config above.</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="category">
|
|
<div class="cat-head">
|
|
<span class="cat-num">04</span>
|
|
<h2>Abandoned checkout recovery</h2>
|
|
</div>
|
|
<p class="cat-note">Distinct from abandoned <em>cart</em> recovery (covered in the Cart survey) — this is someone who reached address/email capture and still left.</p>
|
|
|
|
<div class="feature">
|
|
<div class="f-name">Draft orders are queryable and staff-visible</div>
|
|
<span class="f-status partial">partial</span>
|
|
<div class="f-note">The data exists — <code>Order::isDraft()</code> plus the address already captured on it — but per the Cart survey's finding, there's no Filament resource for <code>Cart</code> and (unverified here, likely the same gap) no dedicated "abandoned checkout" view distinguishing a draft order with a captured address from one that never got that far.</div>
|
|
</div>
|
|
|
|
<div class="feature">
|
|
<div class="f-name">Automated recovery email (post-address-capture)</div>
|
|
<span class="f-status missing">missing</span>
|
|
<div class="f-note">Research: Shopify's built-in template fires after a shopper enters details and leaves, with editable wait time and an optional discount. boboko has strictly better raw material for this than the cart-abandonment case — a draft order after address capture always has <code>OrderAddress.contact_email</code>, where an abandoned guest cart usually has none — but nothing sends on it.</div>
|
|
</div>
|
|
|
|
<div class="feature">
|
|
<div class="f-name">Abandoned-checkout stage tracking (email captured vs. shipping selected vs. payment started)</div>
|
|
<span class="f-status missing">missing</span>
|
|
<div class="f-note">No event dispatch anywhere in the checkout pipeline (see 03) means no timestamped record of which step a checkout got to — only the current state of the draft order, not its history.</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="category">
|
|
<div class="cat-head">
|
|
<span class="cat-num">05</span>
|
|
<h2>Pricing, tax & locale at checkout</h2>
|
|
</div>
|
|
<p class="cat-note">What the customer sees the moment money is on screen.</p>
|
|
|
|
<div class="feature">
|
|
<div class="f-name">Tax-inclusive vs. tax-exclusive price display</div>
|
|
<span class="f-status have">have</span>
|
|
<div class="f-note"><code>TaxZone.price_display</code> is a first-class enum (<code>tax_inclusive</code>/<code>tax_exclusive</code>), and <code>Price::priceExTax()</code>/<code>priceIncTax()</code> both exist on the model — more complete than PrestaShop, where dual-price display is a separately-sold addon module, not core.</div>
|
|
</div>
|
|
|
|
<div class="feature">
|
|
<div class="f-name">Full tax breakdown shown at checkout (per-line, per-rate)</div>
|
|
<span class="f-status have">have</span>
|
|
<div class="f-note"><code>Cart.taxBreakdown</code> and <code>OrderLine.tax_breakdown</code> are both populated structured objects (iterate <code>.amounts</code>), not just a lump-sum total — the data supports a itemized tax display, a storefront just has to render it.</div>
|
|
</div>
|
|
|
|
<div class="feature">
|
|
<div class="f-name">Multi-currency checkout (pay in shopper's own currency)</div>
|
|
<span class="f-status have">have</span>
|
|
<div class="f-note"><code>Currency.exchange_rate</code> plus <code>sync_prices</code> per non-default currency, and the rate is snapshotted onto the order at creation (see 02) — the same mechanics PrestaShop needs an addon for.</div>
|
|
</div>
|
|
|
|
<div class="feature">
|
|
<div class="f-name">Multi-language checkout copy</div>
|
|
<span class="f-status partial">partial</span>
|
|
<div class="f-note">Product/collection/attribute copy is fully translatable via <code>attribute_data</code> + <code>Language</code>, but checkout itself — form labels, validation errors, status labels — is storefront-owned Laravel localization, not something Lunar's order pipeline touches either way.</div>
|
|
</div>
|
|
|
|
<div class="feature">
|
|
<div class="f-name">Click-and-collect / in-store pickup as a checkout option</div>
|
|
<span class="f-status have">have</span>
|
|
<div class="f-note"><code>ShippingOption.collect</code> is a real boolean the validator checks directly — when true, <code>ValidateCartForOrderCreation</code> skips the shipping-address requirement entirely. Modeled at the same level as the <code>collection</code> driver in the Table Rate Shipping add-on.</div>
|
|
</div>
|
|
</section>
|
|
|
|
<footer class="page">
|
|
<span>Compiled 2026-08-28 — sources cited inline; <code>vendor/lunarphp/core/src</code> reads are marked by file/class name, Shopify/WooCommerce/PrestaShop claims are marked "Research."</span>
|
|
<span>boboko-core / docs</span>
|
|
</footer>
|
|
|
|
</div>
|