Skip to content

Commit 9d8d6ff

Browse files
authored
Merge pull request #5 from calimania/feat/receipt-page
created basic receipt page
2 parents fa79b0a + a3bc703 commit 9d8d6ff

File tree

3 files changed

+266
-1
lines changed

3 files changed

+266
-1
lines changed

src/components/subscribe-form.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ export function SubscribeForm({ store }: SubscribeFormProps) {
6969
};
7070

7171
return (
72-
<div className="w-full max-w-4xl mx-auto my-16 px-4">
72+
<div className="w-full max-w-4xl md:max-w-6xl lg:max-w-7xl mx-auto my-16 px-4">
7373
<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%)' }}>
7474
<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" />
7575

src/pages/receipt.astro

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
---
2+
import { getCollection } from 'astro:content';
3+
import PageLayout from '../layouts/PageLayout.astro';
4+
import SubscribeForm from '../components/subscribe-form';
5+
6+
const storeEntries = await getCollection('store');
7+
const entry = storeEntries?.[0] || {};
8+
const store = { documentId: (entry as any)?.documentId || '', ...(entry as any)?.data || {} };
9+
10+
const title = 'Receipt';
11+
const subtitle = 'Thanks for your order — here is a summary.';
12+
const heroImage = store?.Cover?.url || '';
13+
const heroLqip = (store as any)?.Cover?.formats?.small?.url || '';
14+
import { markket }from '../../markket.config';
15+
const __receipt_api_url = markket?.api_url || '';
16+
17+
---
18+
19+
<PageLayout
20+
title={title}
21+
description={subtitle}
22+
image={heroImage}
23+
heroTitle={title}
24+
heroSubtitle={subtitle}
25+
heroImage={heroImage}
26+
heroLqip={heroLqip}
27+
heroAspect="4/5"
28+
heroVariant="compact"
29+
>
30+
<main class="container my-12">
31+
<section class="mx-auto max-w-3xl rounded-lg p-6 bg-surface border" role="region" aria-labelledby="receipt-heading">
32+
<h2 id="receipt-heading" class="text-2xl font-semibold">Order receipt</h2>
33+
<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>
34+
35+
<div id="receipt-empty" class="mt-6">
36+
<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>
37+
</div>
38+
39+
<div id="receipt" class="hidden mt-6" aria-live="polite">
40+
<div class="flex justify-between items-start">
41+
<div>
42+
<p class="text-sm text-muted">Order</p>
43+
<h3 id="order-id" class="text-lg font-medium">#<span id="order-number">—</span></h3>
44+
<p id="order-date" class="text-sm text-muted mt-1">—</p>
45+
46+
<p class="text-sm text-muted mt-4">Billed to</p>
47+
<p id="order-email" class="font-medium break-words">—</p>
48+
<p id="order-shipping" class="text-sm mt-2 text-muted whitespace-pre-line break-words">—</p>
49+
</div>
50+
</div>
51+
52+
<div class="mt-6 border-t pt-4">
53+
<h4 class="text-sm font-semibold">Items</h4>
54+
<ul id="order-items" class="mt-2 space-y-3"></ul>
55+
</div>
56+
57+
<div class="mt-6 border-t pt-4 flex justify-end gap-6">
58+
<div>
59+
<p class="text-sm text-muted">Subtotal</p>
60+
<p id="order-subtotal" class="font-medium">—</p>
61+
</div>
62+
<div>
63+
<p class="text-sm text-muted">Total</p>
64+
<p id="order-total" class="text-xl font-bold">—</p>
65+
</div>
66+
</div>
67+
68+
<!-- actions removed (print/view) per UI preference -->
69+
</div>
70+
</section>
71+
72+
<section class="mt-10">
73+
<div class="mx-auto max-w-6xl px-4">
74+
<h3 class="text-lg font-semibold">Keep in touch</h3>
75+
<p class="text-sm mt-1 text-muted">Join our newsletter for seller tips and updates.</p>
76+
</div>
77+
78+
<div class="mt-6">
79+
<div class="mx-auto max-w-7xl px-4">
80+
<SubscribeForm store={{ documentId: store?.documentId }} client:idle />
81+
</div>
82+
</div>
83+
</section>
84+
</main>
85+
86+
<!-- server-rendered config for client module -->
87+
<div id="receipt-config" data-api-url={__receipt_api_url} style="display:none"></div>
88+
<script type="module">
89+
import('/src/scripts/receipt.client.ts')
90+
.then((mod) => {
91+
const init = mod?.default || mod?.initReceipt || mod?.init;
92+
if (typeof init === 'function') {
93+
try { init(); } catch (e) { console.error('receipt.init failed', e); }
94+
}
95+
})
96+
.catch((err) => console.error('Failed to load receipt client module', err));
97+
</script>
98+
99+
</PageLayout>
100+
101+
<style>
102+
.text-muted { color: var(--text-muted); }
103+
</style>

src/scripts/receipt.client.ts

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
export type ReceiptOptions = {
2+
apiUrl?: string;
3+
};
4+
5+
function moneyFmt(value: any, currency = 'USD') {
6+
try { return new Intl.NumberFormat(undefined, { style: 'currency', currency }).format((value && Number(value)) / 100 || value); } catch (e) { return String(value); }
7+
}
8+
9+
async function fetchFromSession(endpoint: string, sessionId: string) {
10+
try {
11+
const res = await fetch(endpoint, {
12+
method: 'POST',
13+
headers: { 'Content-Type': 'application/json' },
14+
body: JSON.stringify({ action: 'stripe.receipt', session_id: sessionId }),
15+
});
16+
if (!res.ok) {
17+
console.error('receipt.client: server returned', res.status);
18+
return null;
19+
}
20+
const json = await res.json().catch(() => null);
21+
return json?.data?.link?.response || json?.data || null;
22+
} catch (e) {
23+
console.error('receipt.client: failed to fetch receipt from session', e);
24+
return null;
25+
}
26+
}
27+
28+
function parseReceiptFromQuery() {
29+
try {
30+
const qs = new URLSearchParams(location.search);
31+
const raw = qs.get('receipt') || qs.get('data') || qs.get('r') || qs.get('payload');
32+
if (!raw) return null;
33+
try { return JSON.parse(decodeURIComponent(raw)); } catch (e) {}
34+
try { return JSON.parse(atob(raw)); } catch (e) {}
35+
return null;
36+
} catch (e) { return null; }
37+
}
38+
39+
export default async function initReceipt(opts: ReceiptOptions = {}) {
40+
// if apiUrl not provided, try to read server-rendered DOM config or window global
41+
let apiUrl = opts.apiUrl;
42+
if (!apiUrl) {
43+
try {
44+
const cfg = document.getElementById('receipt-config');
45+
if (cfg) apiUrl = cfg.getAttribute('data-api-url') || undefined;
46+
} catch (e) { /* ignore */ }
47+
}
48+
if (!apiUrl && typeof window !== 'undefined') {
49+
// @ts-ignore window global may be set by legacy pages
50+
apiUrl = (window as any).__RECEIPT_API_URL || apiUrl;
51+
}
52+
53+
const apiBase = (apiUrl || '').replace(/\/+$/, '');
54+
const endpoint = apiBase ? apiBase + '/api/markket' : '/api/markket';
55+
56+
const qs = new URLSearchParams(location.search);
57+
const sessionIdParam = qs.get('session_id') || qs.get('session') || qs.get('sid');
58+
59+
let data = null;
60+
if (sessionIdParam) {
61+
data = await fetchFromSession(endpoint, sessionIdParam);
62+
} else {
63+
data = parseReceiptFromQuery();
64+
}
65+
66+
// page uses specific IDs rather than data-output attributes
67+
const outputs = {
68+
total: document.getElementById('order-total'),
69+
subtotal: document.getElementById('order-subtotal'),
70+
email: document.getElementById('order-email'),
71+
number: document.getElementById('order-number'),
72+
date: document.getElementById('order-date'),
73+
};
74+
75+
if (!data) {
76+
console.info('Receipt page: no data found (session_id or receipt payload)');
77+
return;
78+
}
79+
80+
// total and subtotal
81+
const currency = (data.currency || data.currency_code || (data?.link && data.link.response && data.link.response.currency) || 'USD').toUpperCase();
82+
const totalVal = data.amount_total ?? data.amountTotal ?? data.total ?? data.amount ?? data.payment_intent_amount ?? 0;
83+
const subtotalVal = data.amount_subtotal ?? data.amountSubtotal ?? data.subtotal ?? 0;
84+
85+
if (outputs.total) outputs.total.textContent = moneyFmt(totalVal, currency);
86+
if (outputs.subtotal) outputs.subtotal.textContent = moneyFmt(subtotalVal, currency);
87+
88+
// customer email / name
89+
const email = data.customer_details?.email || data.customer_email || data.email || data.receipt_email || '';
90+
const name = data.customer_details?.name || data.customer || '';
91+
if (outputs.email) {
92+
const safe = name ? `${name}${email ? ' <' + email + '>' : ''}` : (email || '—');
93+
outputs.email.textContent = safe;
94+
}
95+
96+
// order number / session id
97+
const orderId = data.id || data.session_id || data.transaction || data.payment_intent || '';
98+
if (outputs.number) outputs.number.textContent = orderId || '—';
99+
100+
// created date (timestamp in seconds)
101+
const createdTs = data.created ?? data.created_at ?? null;
102+
try {
103+
if (outputs.date && createdTs) {
104+
const d = typeof createdTs === 'number' && String(createdTs).length === 10 ? new Date(createdTs * 1000) : new Date(createdTs);
105+
outputs.date.textContent = d.toLocaleString();
106+
}
107+
} catch (e) { /* ignore */ }
108+
109+
// render items if present
110+
const itemsList = document.getElementById('order-items');
111+
const itemsArr = data.items || data.line_items || data.line_items?.data || [];
112+
if (itemsList && Array.isArray(itemsArr)) {
113+
itemsList.innerHTML = '';
114+
const arr = itemsArr;
115+
for (const it of arr) {
116+
const li = document.createElement('li');
117+
li.className = 'flex justify-between items-center';
118+
const title = document.createElement('div');
119+
title.innerHTML = `<div class="font-medium">${it.name || it.description || 'Item'}</div><div class="text-sm">qty: ${it.qty ?? it.quantity ?? 1}</div>`;
120+
const price = document.createElement('div');
121+
price.className = 'font-medium';
122+
const p = Number(it.unit_price ?? it.price ?? it.amount ?? 0);
123+
try {
124+
const currency = data?.currency || data?.currency_code || 'USD';
125+
price.textContent = new Intl.NumberFormat(undefined, { style: 'currency', currency }).format((isNaN(p) ? 0 : p) / 100);
126+
} catch (e) {
127+
price.textContent = String((isNaN(p) ? 0 : p) / 100);
128+
}
129+
li.appendChild(title);
130+
li.appendChild(price);
131+
itemsList.appendChild(li);
132+
}
133+
}
134+
135+
// reveal receipt section if hidden
136+
const emptyEl = document.getElementById('receipt-empty');
137+
const receiptEl = document.getElementById('receipt');
138+
if (emptyEl) emptyEl.classList.add('hidden');
139+
if (receiptEl) receiptEl.classList.remove('hidden');
140+
141+
// shipping details
142+
try {
143+
const shipping = data.shipping_details || data.shipping || data.collected_information?.shipping_details || data.link?.response?.shipping_details || null;
144+
const shipEl = document.getElementById('order-shipping');
145+
if (shipEl) {
146+
if (shipping && (shipping.address || shipping.name)) {
147+
let lines = [];
148+
if (shipping.name) lines.push(String(shipping.name));
149+
const addr = shipping.address || shipping;
150+
if (addr?.line1) lines.push(String(addr.line1));
151+
if (addr?.line2) lines.push(String(addr.line2));
152+
const cityParts = [addr?.city, addr?.state, addr?.postal_code].filter(Boolean).join(', ');
153+
if (cityParts) lines.push(cityParts);
154+
if (addr?.country) lines.push(String(addr.country));
155+
shipEl.textContent = lines.join('\n');
156+
} else {
157+
// no shipping -> hide or keep placeholder
158+
// leave the existing placeholder
159+
}
160+
}
161+
} catch (e) { /* ignore shipping render errors */ }
162+
}

0 commit comments

Comments
 (0)