From 1b1a7d91ee36cafc792252d30159b3effde6964b Mon Sep 17 00:00:00 2001 From: Daveed Date: Sun, 5 Oct 2025 13:48:20 -0400 Subject: [PATCH] Created react receipt component --- README.md | 115 ++++++++++++------------ src/components/receipt.tsx | 164 ++++++++++++++++++++++++++++++++++ src/layouts/PageLayout.astro | 29 +++++- src/pages/receipt.astro | 116 +++++++----------------- src/scripts/receipt.client.ts | 162 --------------------------------- 5 files changed, 285 insertions(+), 301 deletions(-) create mode 100644 src/components/receipt.tsx delete mode 100644 src/scripts/receipt.client.ts diff --git a/README.md b/README.md index 9e3e88e..0577a22 100644 --- a/README.md +++ b/README.md @@ -4,83 +4,88 @@ #### Ready to Sell -### Configuration +# Markkët Space — Storefront starter (Astro) +A small, content-first storefront template built with Astro and Strapi -```env -PUBLIC_STRAPI_URL=https://api.markket.place # Markkët CMS URL -PUBLIC_STORE_SLUG=sell # Store identifier -PUBLIC_POSTHOG_KEY= # Analytics key (optional) -PUBLIC_URL=https://sell.markket.place # Your live site URL -``` +It's designed as an example you can copy from, or fork the repo you, customize and deploy + +This repo contains a minimal set of pages and components you can reuse: + +- /blog — article index and list +- /about — pages and nested pages +- / — home page showing store info and product listing +- /receipt — buyer confirmation page (renders session or payload) +- /newsletter — newsletter landing and subscribe form + +The checkout flow in this template is intentionally minimal: -## Hacktoberfest +- the product UI opens a checkout modal (client side) that redirects to a payment link +- The receipt page decodes a receipt payload or resolves a Stripe session via your backend +## Quick start +Prerequisites: Node.js (16+), npm or pnpm -### How to Contribute +1. Fork or clone this repository. +2. Install dependencies: -1. **🍴 Fork** this repository -2. **📋 Pick an issue** or suggest improvements -3. **🔧 Make your changes** following our guidelines -4. **📤 Submit a PR** with a clear description +```bash +npm install +``` -### Good First Issues +3. Copy environment variables (example in `.env` or your hosting configuration): -- 🎨 UI/UX improvements -- 📱 Mobile responsiveness enhancements -- 🚀 Performance optimizations -- 📚 Documentation improvements -- 🧪 Add tests -- 🌐 Accessibility improvements +```env +PUBLIC_STRAPI_URL=https://api.markket.place # Markket - strapi API +PUBLIC_STORE_SLUG=sell # slug identifier from markket +PUBLIC_POSTHOG_KEY= # Optional analytics key +PUBLIC_URL= # deployment url for canonical and sitemap +``` -### Contribution Guidelines -- Keep PRs focused and atomic -- Write clear commit messages -- Test your changes locally -- Update documentation as needed -- Be respectful and inclusive +4. Run the dev server: + +```bash +npm run dev +``` -## 🛠 Tech Stack +Open http://localhost:3000 and browse the routes above. -- **Framework**: [Astro](https://astro.build) - The web framework for content-driven websites -- **CMS**: [Strapi](https://strapi.io) - Flexible, open-source headless CMS -- **Analytics**: [PostHog](https://posthog.com) - Product analytics platform -- **Deployment**: GitHub Actions → GitHub Pages -- **Styling**: Modern CSS with component-scoped styles +## Routes & responsibilities -## 🌐 Live Demo +- / (home) + - Shows store metadata and product listings. Product cards provide a checkout modal or direct-to-payment link. -🔗 **[https://sell.markket.place](https://sell.markket.place)** +- /blog + - Content-driven article index and single article pages powered by Astro content collections. -We love contributions! Whether you're: +- /about + - Simple content pages, supports nested pages. -- 🐛 Fixing bugs -- ✨ Adding features -- 📝 Improving docs -- 🎨 Enhancing design -- 🧪 Writing tests +- /receipt + - Buyer confirmation page. The client script will either: + - Parse a receipt payload from the URL (?receipt= encoded JSON or ?payload= base64), or + - Resolve a Stripe session by POSTing { action: 'stripe.receipt', session_id } to your backend (configured via PUBLIC_STRAPI_URL or markket.config). + - The page renders amounts, customer info, shipping (if present), and items. -All contributions are welcome! Check out our [issues](https://github.com/calimania/markket-space/issues) or create a new one +- /newsletter + - Newsletter landing and a SubscribeForm component (React) that posts to `/api/subscribers`. -## 📄 License +## Checkout & Receipt notes -This project is open source and available under the [TSL](LICENSE) +- Checkout modal: the front-end reads a product's metadata and redirects the buyer to a payment link -## 🏆 Credits & Recognition +- Receipt resolving: After checkout, markket redirects back to `/receipt?session_id=...` -### 🤖 AI Development Team +## Contributing -- **GitHub Copilot** - Primary coding assistant and project architect -- **Colombian Coffee** - Advanced research and development support -- **Octogatos** - Community-driven AI innovation and testing +We welcome contributions. A few tips: -### 🛠️ Technology Stack +- Keep changes small and focused +- Update README and comment code where behaviors are non-obvious +- Implement additional Markket features +- Abstract components -- Built with ❤️ using [Astro](https://astro.build) -- Powered by [Strapi](https://strapi.io) headless CMS -- Content by [Markkët](https://de.markket.place) -- Analytics by [PostHog](https://posthog.com) -- Schema by [Cafecito](https://www.npmjs.com/package/cafecito) -- Deployed via GitHub Actions +## License +TSL diff --git a/src/components/receipt.tsx b/src/components/receipt.tsx new file mode 100644 index 0000000..bcb792c --- /dev/null +++ b/src/components/receipt.tsx @@ -0,0 +1,164 @@ +import React, { useEffect, useState } from 'react'; + +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; + } +} + +/** + * Read a stripe session_id, or order_id and display information + * + * @param props.api markket.api_url - api.markket.place + * @returns + */ +const ReceiptComponentPage= ({ api }: { api: string }) => { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + let mounted = true; + (async () => { + setLoading(true); + try { + const endpoint = new URL('/api/markket', api).toString(); + const qs = new URLSearchParams(window.location.search); + const sessionId = (qs.get('session_id') || qs.get('session') || qs.get('sid') || '').trim(); + + if (!sessionId) { + // no session id present — nothing to fetch + if (mounted) { + setData(null); + setError(null); + } + return; + } + + const resolved = await fetchFromSession(endpoint, sessionId); + + if (mounted) { + if (resolved) setData(resolved); + else setError('No receipt data returned from server for this session.'); + } + } catch (e: any) { + console.error(e); + if (mounted) setError(String(e?.message || e)); + } finally { + if (mounted) setLoading(false); + } + })(); + + return () => { mounted = false; }; + }, []); + + const getCurrency = (d: any) => (d?.currency || d?.currency_code || (d?.link?.response && d.link.response.currency) || 'USD').toUpperCase(); + const getTotal = (d: any) => d?.amount_total ?? d?.amountTotal ?? d?.total ?? d?.amount ?? 0; + const getSubtotal = (d: any) => d?.amount_subtotal ?? d?.amountSubtotal ?? d?.subtotal ?? 0; + const getCustomerEmail = (d: any) => d?.customer_details?.email || d?.customer_email || d?.email || d?.receipt_email || ''; + const getCustomerName = (d: any) => d?.customer_details?.name || d?.customer || ''; + const getOrderId = (d: any) => d?.id || d?.session_id || d?.transaction || d?.payment_intent || ''; + const getCreated = (d: any) => d?.created ?? d?.created_at ?? null; + const getShipping = (d: any) => d?.shipping_details || d?.shipping || d?.collected_information?.shipping_details || d?.link?.response?.shipping_details || null; + + return ( +
+
+

Order receipt

+

This page decodes a receipt payload from the URL and renders a printable copy for your records.

+ + {!loading && !data && !error && ( +
+

No receipt data detected. Append ?receipt= followed by a URL-encoded JSON object to the URL (example in console).

+
+ )} + + {loading && ( +
Fetching receipt…
+ )} + + {error && ( +
Error loading receipt: {error}
+ )} + + {data && ( +
+
+
+

Order

+

#{getOrderId(data) || '—'}

+

{(function(){ + const ts = getCreated(data); try { if (!ts) return '—'; const d = (typeof ts === 'number' && String(ts).length === 10) ? new Date(ts * 1000) : new Date(ts); return d.toLocaleString(); } catch(e){return '—';} + })()}

+ +

Billed to

+

{getCustomerName(data) || getCustomerEmail(data) || '—'}

+

{(function(){ + const ship = getShipping(data); if (!ship) return '—'; const lines: string[] = []; if (ship.name) lines.push(String(ship.name)); const addr = ship.address || ship; 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)); return lines.join('\n'); + })()}

+
+
+ +
+

Items

+
    + {(function(){ + const items = data.items || data.line_items || data.line_items?.data || []; + if (!Array.isArray(items) || items.length === 0) return null; + return items.map((it: any, i: number) => { + const title = it.name || it.description || 'Item'; + const qty = it.qty ?? it.quantity ?? 1; + const p = Number(it.unit_price ?? it.price ?? it.amount ?? 0); + const currency = getCurrency(data); + const price = (() => { try { return new Intl.NumberFormat(undefined, { style: 'currency', currency }).format((isNaN(p) ? 0 : p) / 100); } catch (e) { return String((isNaN(p) ? 0 : p) / 100); } })(); + return ( +
  • +
    {title}
    qty: {qty}
    +
    {price}
    +
  • + ); + }); + })()} +
+
+ +
+
+

Subtotal

+

{moneyFmt(getSubtotal(data), getCurrency(data))}

+
+
+

Total

+

{moneyFmt(getTotal(data), getCurrency(data))}

+
+
+ +
+ )} +
+
+ ); +}; + +export default ReceiptComponentPage; \ No newline at end of file diff --git a/src/layouts/PageLayout.astro b/src/layouts/PageLayout.astro index eead197..7dded50 100644 --- a/src/layouts/PageLayout.astro +++ b/src/layouts/PageLayout.astro @@ -12,6 +12,7 @@ export interface Props { heroLqip?: string; heroAspect?: string; // CSS aspect like "4/5" or "16/9" heroVariant?: "spacious" | "compact"; + heroSize?: "spacious" | "compact" | "small"; heroCtaText?: string; heroCtaUrl?: string; } @@ -28,11 +29,17 @@ const heroImage = props.heroImage ?? image ?? ""; const heroLqip = props.heroLqip ?? undefined; const heroAspect = props.heroAspect ?? "4/5"; const heroVariant = (props.heroVariant ?? "spacious") as "spacious" | "compact"; +const heroSize = (props.heroSize ?? undefined) as + | "spacious" + | "compact" + | "small" + | undefined; const heroCtaText = props.heroCtaText ?? undefined; const heroCtaUrl = props.heroCtaUrl ?? undefined; // Helper at runtime to pick the variant class (keeps TS happy) const isCompact = heroVariant === "compact"; +const isSmall = heroSize === "small"; --- @@ -42,7 +49,7 @@ const isCompact = heroVariant === "compact"; { heroTitle || heroSubtitle || heroImage ? (
@@ -135,6 +142,26 @@ const isCompact = heroVariant === "compact"; transform-origin: center center; } + /* small hero variant for informational pages */ + .page-hero.hero-small { + padding: 1rem 0 0.75rem; + background: var(--bg-alt); + } + .page-hero.hero-small .page-hero-content { + max-width: 900px; + grid-template-columns: 1fr 260px; + gap: 1rem; + padding: 0 1rem; + } + .page-hero.hero-small .hero-copy h1 { + font-size: clamp(1.2rem, 2.2vw, 1.6rem); + margin-bottom: 0.25rem; + } + .page-hero.hero-small .about-subtitle { + font-size: 0.95rem; + margin-bottom: 0.5rem; + } + .hero-image .img-frame img { width: 100%; height: 100%; diff --git a/src/pages/receipt.astro b/src/pages/receipt.astro index 2c82c60..1e18dc9 100644 --- a/src/pages/receipt.astro +++ b/src/pages/receipt.astro @@ -1,19 +1,21 @@ --- -import { getCollection } from 'astro:content'; -import PageLayout from '../layouts/PageLayout.astro'; -import SubscribeForm from '../components/subscribe-form'; +import { getCollection } from "astro:content"; +import PageLayout from "../layouts/PageLayout.astro"; +import SubscribeForm from "../components/subscribe-form"; -const storeEntries = await getCollection('store'); +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 || ''; - +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"; +import ReceiptComponent from "../components/receipt"; --- -
-
-

Order receipt

-

This page decodes a receipt payload from the URL and renders a printable copy for your records.

- -
-

No receipt data detected. Append ?receipt= followed by a URL-encoded JSON object to the URL (example in console).

+ +
+
+

Keep in touch

+

+ Join our newsletter for seller tips and updates. +

+
+
+
+
- - -
- -
-
-

Keep in touch

-

Join our newsletter for seller tips and updates.

-
- -
-
- -
-
-
-
- - - - - +
+
\ No newline at end of file + .text-muted { + color: var(--text-muted); + } + diff --git a/src/scripts/receipt.client.ts b/src/scripts/receipt.client.ts deleted file mode 100644 index 4b2bbab..0000000 --- a/src/scripts/receipt.client.ts +++ /dev/null @@ -1,162 +0,0 @@ -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 = `
${it.name || it.description || 'Item'}
qty: ${it.qty ?? it.quantity ?? 1}
`; - 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 */ } -}