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
115 changes: 60 additions & 55 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
164 changes: 164 additions & 0 deletions src/components/receipt.tsx
Original file line number Diff line number Diff line change
@@ -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<any | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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 (
<main className="container my-12">
<section className="mx-auto max-w-3xl rounded-lg p-6 bg-surface border" role="region" aria-labelledby="receipt-heading">
<h2 id="receipt-heading" className="text-2xl font-semibold">Order receipt</h2>
<p className="text-sm mt-1 text-muted">This page decodes a receipt payload from the URL and renders a printable copy for your records.</p>

{!loading && !data && !error && (
<div id="receipt-empty" className="mt-6">
<p className="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>
)}

{loading && (
<div className="mt-6 text-sm text-muted">Fetching receipt…</div>
)}

{error && (
<div className="mt-6 text-sm text-red-600">Error loading receipt: {error}</div>
)}

{data && (
<div id="receipt" className="mt-6" aria-live="polite">
<div className="flex justify-between items-start">
<div>
<p className="text-sm text-muted">Order</p>
<h3 id="order-id" className="text-lg font-medium">#{getOrderId(data) || '—'}</h3>
<p id="order-date" className="text-sm text-muted mt-1">{(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 '—';}
})()}</p>

<p className="text-sm text-muted mt-4">Billed to</p>
<p id="order-email" className="font-medium break-words">{getCustomerName(data) || getCustomerEmail(data) || '—'}</p>
<p id="order-shipping" className="text-sm mt-2 text-muted whitespace-pre-line break-words">{(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');
})()}</p>
</div>
</div>

<div className="mt-6 border-t pt-4">
<h4 className="text-sm font-semibold">Items</h4>
<ul id="order-items" className="mt-2 space-y-3">
{(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 (
<li key={i} className="flex justify-between items-center">
<div className="font-medium">{title}<div className="text-sm">qty: {qty}</div></div>
<div className="font-medium">{price}</div>
</li>
);
});
})()}
</ul>
</div>

<div className="mt-6 border-t pt-4 flex justify-end gap-6">
<div>
<p className="text-sm text-muted">Subtotal</p>
<p id="order-subtotal" className="font-medium">{moneyFmt(getSubtotal(data), getCurrency(data))}</p>
</div>
<div>
<p className="text-sm text-muted">Total</p>
<p id="order-total" className="text-xl font-bold">{moneyFmt(getTotal(data), getCurrency(data))}</p>
</div>
</div>

</div>
)}
</section>
</main>
);
};

export default ReceiptComponentPage;
29 changes: 28 additions & 1 deletion src/layouts/PageLayout.astro
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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";
---

<BaseLayout title={title} description={description} image={image}>
Expand All @@ -42,7 +49,7 @@ const isCompact = heroVariant === "compact";
{
heroTitle || heroSubtitle || heroImage ? (
<section
class={`page-hero ${isCompact ? "hero-compact" : "hero-spacious"}`}
class={`page-hero ${isSmall ? "hero-small" : isCompact ? "hero-compact" : "hero-spacious"}`}
aria-labelledby="page-hero-title"
>
<div class="page-hero-content">
Expand Down Expand Up @@ -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%;
Expand Down
Loading