Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/components/subscribe-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export function SubscribeForm({ store }: SubscribeFormProps) {
};

return (
<div className="w-full max-w-4xl mx-auto my-16 px-4">
<div className="w-full max-w-4xl md:max-w-6xl lg:max-w-7xl mx-auto my-16 px-4">
<div className="relative overflow-hidden rounded-2xl shadow-2xl" style={{ background: 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(250,250,255,0.9) 100%)' }}>
<div className="pointer-events-none absolute -right-24 -top-24 w-64 h-64 rounded-full bg-gradient-to-tr from-pink-300 to-indigo-400 opacity-30 blur-3xl" />

Expand Down
103 changes: 103 additions & 0 deletions src/pages/receipt.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
---
import { getCollection } from 'astro:content';
import PageLayout from '../layouts/PageLayout.astro';
import SubscribeForm from '../components/subscribe-form';

const storeEntries = await getCollection('store');
const entry = storeEntries?.[0] || {};
const store = { documentId: (entry as any)?.documentId || '', ...(entry as any)?.data || {} };

const title = 'Receipt';
const subtitle = 'Thanks for your order — here is a summary.';
const heroImage = store?.Cover?.url || '';
const heroLqip = (store as any)?.Cover?.formats?.small?.url || '';
import { markket }from '../../markket.config';
const __receipt_api_url = markket?.api_url || '';

---

<PageLayout
title={title}
description={subtitle}
image={heroImage}
heroTitle={title}
heroSubtitle={subtitle}
heroImage={heroImage}
heroLqip={heroLqip}
heroAspect="4/5"
heroVariant="compact"
>
<main class="container my-12">
<section class="mx-auto max-w-3xl rounded-lg p-6 bg-surface border" role="region" aria-labelledby="receipt-heading">
<h2 id="receipt-heading" class="text-2xl font-semibold">Order receipt</h2>
<p class="text-sm mt-1 text-muted">This page decodes a receipt payload from the URL and renders a printable copy for your records.</p>

<div id="receipt-empty" class="mt-6">
<p class="text-muted">No receipt data detected. Append <code>?receipt=</code> followed by a URL-encoded JSON object to the URL (example in console).</p>
</div>

<div id="receipt" class="hidden mt-6" aria-live="polite">
<div class="flex justify-between items-start">
<div>
<p class="text-sm text-muted">Order</p>
<h3 id="order-id" class="text-lg font-medium">#<span id="order-number">—</span></h3>
<p id="order-date" class="text-sm text-muted mt-1">—</p>

<p class="text-sm text-muted mt-4">Billed to</p>
<p id="order-email" class="font-medium break-words">—</p>
<p id="order-shipping" class="text-sm mt-2 text-muted whitespace-pre-line break-words">—</p>
</div>
</div>

<div class="mt-6 border-t pt-4">
<h4 class="text-sm font-semibold">Items</h4>
<ul id="order-items" class="mt-2 space-y-3"></ul>
</div>

<div class="mt-6 border-t pt-4 flex justify-end gap-6">
<div>
<p class="text-sm text-muted">Subtotal</p>
<p id="order-subtotal" class="font-medium">—</p>
</div>
<div>
<p class="text-sm text-muted">Total</p>
<p id="order-total" class="text-xl font-bold">—</p>
</div>
</div>

<!-- actions removed (print/view) per UI preference -->
</div>
</section>

<section class="mt-10">
<div class="mx-auto max-w-6xl px-4">
<h3 class="text-lg font-semibold">Keep in touch</h3>
<p class="text-sm mt-1 text-muted">Join our newsletter for seller tips and updates.</p>
</div>

<div class="mt-6">
<div class="mx-auto max-w-7xl px-4">
<SubscribeForm store={{ documentId: store?.documentId }} client:idle />
</div>
</div>
</section>
</main>

<!-- server-rendered config for client module -->
<div id="receipt-config" data-api-url={__receipt_api_url} style="display:none"></div>
<script type="module">
import('/src/scripts/receipt.client.ts')
.then((mod) => {
const init = mod?.default || mod?.initReceipt || mod?.init;
if (typeof init === 'function') {
try { init(); } catch (e) { console.error('receipt.init failed', e); }
}
})
.catch((err) => console.error('Failed to load receipt client module', err));
</script>

</PageLayout>

<style>
.text-muted { color: var(--text-muted); }
</style>
162 changes: 162 additions & 0 deletions src/scripts/receipt.client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
export type ReceiptOptions = {
apiUrl?: string;
};

function moneyFmt(value: any, currency = 'USD') {
try { return new Intl.NumberFormat(undefined, { style: 'currency', currency }).format((value && Number(value)) / 100 || value); } catch (e) { return String(value); }
}

async function fetchFromSession(endpoint: string, sessionId: string) {
try {
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'stripe.receipt', session_id: sessionId }),
});
if (!res.ok) {
console.error('receipt.client: server returned', res.status);
return null;
}
const json = await res.json().catch(() => null);
return json?.data?.link?.response || json?.data || null;
} catch (e) {
console.error('receipt.client: failed to fetch receipt from session', e);
return null;
}
}

function parseReceiptFromQuery() {
try {
const qs = new URLSearchParams(location.search);
const raw = qs.get('receipt') || qs.get('data') || qs.get('r') || qs.get('payload');
if (!raw) return null;
try { return JSON.parse(decodeURIComponent(raw)); } catch (e) {}
try { return JSON.parse(atob(raw)); } catch (e) {}
return null;
} catch (e) { return null; }
}

export default async function initReceipt(opts: ReceiptOptions = {}) {
// if apiUrl not provided, try to read server-rendered DOM config or window global
let apiUrl = opts.apiUrl;
if (!apiUrl) {
try {
const cfg = document.getElementById('receipt-config');
if (cfg) apiUrl = cfg.getAttribute('data-api-url') || undefined;
} catch (e) { /* ignore */ }
}
if (!apiUrl && typeof window !== 'undefined') {
// @ts-ignore window global may be set by legacy pages
apiUrl = (window as any).__RECEIPT_API_URL || apiUrl;
}

const apiBase = (apiUrl || '').replace(/\/+$/, '');
const endpoint = apiBase ? apiBase + '/api/markket' : '/api/markket';

const qs = new URLSearchParams(location.search);
const sessionIdParam = qs.get('session_id') || qs.get('session') || qs.get('sid');

let data = null;
if (sessionIdParam) {
data = await fetchFromSession(endpoint, sessionIdParam);
} else {
data = parseReceiptFromQuery();
}

// page uses specific IDs rather than data-output attributes
const outputs = {
total: document.getElementById('order-total'),
subtotal: document.getElementById('order-subtotal'),
email: document.getElementById('order-email'),
number: document.getElementById('order-number'),
date: document.getElementById('order-date'),
};

if (!data) {
console.info('Receipt page: no data found (session_id or receipt payload)');
return;
}

// total and subtotal
const currency = (data.currency || data.currency_code || (data?.link && data.link.response && data.link.response.currency) || 'USD').toUpperCase();
const totalVal = data.amount_total ?? data.amountTotal ?? data.total ?? data.amount ?? data.payment_intent_amount ?? 0;
const subtotalVal = data.amount_subtotal ?? data.amountSubtotal ?? data.subtotal ?? 0;

if (outputs.total) outputs.total.textContent = moneyFmt(totalVal, currency);
if (outputs.subtotal) outputs.subtotal.textContent = moneyFmt(subtotalVal, currency);

// customer email / name
const email = data.customer_details?.email || data.customer_email || data.email || data.receipt_email || '';
const name = data.customer_details?.name || data.customer || '';
if (outputs.email) {
const safe = name ? `${name}${email ? ' <' + email + '>' : ''}` : (email || '—');
outputs.email.textContent = safe;
}

// order number / session id
const orderId = data.id || data.session_id || data.transaction || data.payment_intent || '';
if (outputs.number) outputs.number.textContent = orderId || '—';

// created date (timestamp in seconds)
const createdTs = data.created ?? data.created_at ?? null;
try {
if (outputs.date && createdTs) {
const d = typeof createdTs === 'number' && String(createdTs).length === 10 ? new Date(createdTs * 1000) : new Date(createdTs);
outputs.date.textContent = d.toLocaleString();
}
} catch (e) { /* ignore */ }

// render items if present
const itemsList = document.getElementById('order-items');
const itemsArr = data.items || data.line_items || data.line_items?.data || [];
if (itemsList && Array.isArray(itemsArr)) {
itemsList.innerHTML = '';
const arr = itemsArr;
for (const it of arr) {
const li = document.createElement('li');
li.className = 'flex justify-between items-center';
const title = document.createElement('div');
title.innerHTML = `<div class="font-medium">${it.name || it.description || 'Item'}</div><div class="text-sm">qty: ${it.qty ?? it.quantity ?? 1}</div>`;
const price = document.createElement('div');
price.className = 'font-medium';
const p = Number(it.unit_price ?? it.price ?? it.amount ?? 0);
try {
const currency = data?.currency || data?.currency_code || 'USD';
price.textContent = new Intl.NumberFormat(undefined, { style: 'currency', currency }).format((isNaN(p) ? 0 : p) / 100);
} catch (e) {
price.textContent = String((isNaN(p) ? 0 : p) / 100);
}
li.appendChild(title);
li.appendChild(price);
itemsList.appendChild(li);
}
}

// reveal receipt section if hidden
const emptyEl = document.getElementById('receipt-empty');
const receiptEl = document.getElementById('receipt');
if (emptyEl) emptyEl.classList.add('hidden');
if (receiptEl) receiptEl.classList.remove('hidden');

// shipping details
try {
const shipping = data.shipping_details || data.shipping || data.collected_information?.shipping_details || data.link?.response?.shipping_details || null;
const shipEl = document.getElementById('order-shipping');
if (shipEl) {
if (shipping && (shipping.address || shipping.name)) {
let lines = [];
if (shipping.name) lines.push(String(shipping.name));
const addr = shipping.address || shipping;
if (addr?.line1) lines.push(String(addr.line1));
if (addr?.line2) lines.push(String(addr.line2));
const cityParts = [addr?.city, addr?.state, addr?.postal_code].filter(Boolean).join(', ');
if (cityParts) lines.push(cityParts);
if (addr?.country) lines.push(String(addr.country));
shipEl.textContent = lines.join('\n');
} else {
// no shipping -> hide or keep placeholder
// leave the existing placeholder
}
}
} catch (e) { /* ignore shipping render errors */ }
}