Skip to content
Open
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 .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ jobs:
run: pnpm install --frozen-lockfile

- name: Build
run: pnpm build
run: pnpm build:github

- name: Setup Pages
uses: actions/configure-pages@v4
Expand Down
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Bunch – Bitcoin Loyalty Punch Cards

[![Edit with Shakespeare](https://shakespeare.diy/badge.svg)](https://shakespeare.diy/clone?url=https%3A%2F%2Fgithub.com%2FNotThatKindOfDrLiz%2Fbunch.git)

Bunch is a drop-in loyalty layer for Bitcoin-accepting merchants. It tracks punches locally alongside existing payment flows, without touching invoices or custody. Made for the btc++ Taipei hackathon.

## Architecture highlights
Expand Down Expand Up @@ -45,7 +47,7 @@ When a payment is confirmed in BTCPay Server or LNbits, send a webhook to your b
// Backend webhook handler (Node.js example)
app.post('/webhooks/btcpay', async (req, res) => {
const { invoiceId, status, metadata } = req.body

if (status === 'paid' && metadata.purchaseNonce) {
// Notify merchant's Bunch instance via WebSocket or Server-Sent Events
notifyMerchant({
Expand All @@ -55,7 +57,7 @@ app.post('/webhooks/btcpay', async (req, res) => {
amount: req.body.amount
})
}

res.status(200).send('OK')
})
```
Expand All @@ -81,12 +83,12 @@ Merchant's Bunch instance polls payment system API to check invoice status:
const checkPaymentStatus = async (purchaseNonce: string) => {
// Get invoice ID from purchase nonce metadata
const invoiceId = await getInvoiceIdForNonce(purchaseNonce)

// Poll BTCPay Server API
const invoice = await fetch(`https://your-btcpay-server.com/api/invoices/${invoiceId}`, {
headers: { 'Authorization': `token ${BTCPAY_API_KEY}` }
}).then(r => r.json())

if (invoice.status === 'paid') {
await markPaid(purchaseNonce, { amount: invoice.amount })
}
Expand Down Expand Up @@ -123,7 +125,7 @@ const invoice = await btcpay.createInvoice({
// 2. Webhook receives payment confirmation
btcpay.on('invoice.paid', async (invoice) => {
const { purchaseNonce } = invoice.metadata

// 3. Verify and award punch
if (await verifyPurchaseNonce(purchaseNonce)) {
await merchantStore.markPaid(purchaseNonce, {
Expand Down
4 changes: 2 additions & 2 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/bunch/logo.png" />
<link rel="shortcut icon" type="image/png" href="/bunch/logo.png" />
<link rel="icon" type="image/png" href="/logo.png" />
<link rel="shortcut icon" type="image/png" href="/logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Bunch – Bitcoin Loyalty Punch Cards</title>
</head>
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"build:github": "tsc && vite build --mode github",
"preview": "vite preview"
},
"devDependencies": {
Expand Down
45 changes: 19 additions & 26 deletions public/404.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,36 +2,29 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/bunch/logo.png" />
<link rel="icon" type="image/png" href="/logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Bunch – Bitcoin Loyalty Punch Cards</title>
<script>
// GitHub Pages SPA redirect
// When GitHub Pages serves 404.html, redirect to index.html with path
var pathSegmentsToKeep = 1;
// SPA redirect for client-side routing
// Works for both root (/) and GitHub Pages (/bunch/) deployments
var l = window.location;
var basePath = '/bunch';

// Get the path after the base path, handling double slashes
var pathname = l.pathname.replace(/\/+/g, '/'); // Normalize double slashes
var pathAfterBase = pathname.startsWith(basePath)
? pathname.slice(basePath.length) || '/'
: pathname;

// Ensure path starts with / and doesn't have double slashes
if (!pathAfterBase.startsWith('/')) {
pathAfterBase = '/' + pathAfterBase;
}
pathAfterBase = pathAfterBase.replace(/\/+/g, '/');

// Build redirect URL
var redirectPath = basePath + '/?/' +
pathAfterBase.slice(1).replace(/&/g, '~and~') +
(l.search ? '&' + l.search.slice(1).replace(/&/g, '~and~') : '') +
l.hash;

// Redirect immediately
window.location.replace(redirectPath);
var pathSegments = l.pathname.split('/').filter(Boolean);

// Check if we're on GitHub Pages (has /bunch/ prefix)
var isGitHubPages = pathSegments[0] === 'bunch';
var basePath = isGitHubPages ? '/bunch' : '';

// Get the path after base
var pathAfterBase = isGitHubPages
? '/' + pathSegments.slice(1).join('/')
: l.pathname;

// Store for React Router to handle
sessionStorage.setItem('redirectPath', pathAfterBase + l.search + l.hash);

// Redirect to index
window.location.replace(basePath + '/');
</script>
</head>
<body>
Expand Down
20 changes: 6 additions & 14 deletions src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,24 +14,16 @@ declare global {

window.bunchVersion = '0.1.0'

// Handle GitHub Pages 404 redirect
// When 404.html redirects here with ?/path, extract and navigate
if (window.location.search.includes('?/')) {
const search = window.location.search
const pathMatch = search.match(/\?\/+(.+?)(?:&|$)/)
if (pathMatch) {
const path = pathMatch[1].replace(/~and~/g, '&')
const newPath = path.startsWith('/') ? path : '/' + path
const baseUrl = import.meta.env.BASE_URL.endsWith('/')
? import.meta.env.BASE_URL.slice(0, -1)
: import.meta.env.BASE_URL
window.history.replaceState({}, '', `${baseUrl}${newPath}`)
}
// Handle 404 redirect for client-side routing
const redirectPath = sessionStorage.getItem('redirectPath')
if (redirectPath) {
sessionStorage.removeItem('redirectPath')
window.history.replaceState({}, '', redirectPath)
}

ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter basename="/bunch">
<BrowserRouter>
<Routes>
<Route path="/" element={<Navigate to="/merchant" replace />} />
<Route path="/merchant" element={<MerchantApp />} />
Expand Down
8 changes: 3 additions & 5 deletions src/screens/MerchantApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { CardStats } from '../components/CardStats'
import { EmptyStateCard } from '../components/EmptyStateCard'
import { SessionCard } from '../components/SessionCard'
import { MerchantStatusPanel } from '../components/MerchantStatusPanel'
import { getAssetPath, getRoutePath } from '../utils/paths'

export const MerchantApp = () => {
const {
Expand Down Expand Up @@ -62,7 +63,7 @@ export const MerchantApp = () => {
<div className="min-h-screen bg-brand-cream text-brand-charcoal">
<header className="flex flex-col md:flex-row md:items-center md:justify-between gap-4 px-6 py-5 border-b border-black/10 bg-white/50 backdrop-blur-sm">
<div className="flex items-center gap-4">
<img src={`${import.meta.env.BASE_URL}logo-name.png`} alt="Bunch" className="h-10 md:h-12" />
<img src={getAssetPath('logo-name.png')} alt="Bunch" className="h-10 md:h-12" />
<div className="border-l border-black/20 pl-4">
<h2 className="text-xl font-bold tracking-tight">Merchant</h2>
<p className="text-xs text-black/70 font-medium">Drop-in Bitcoin loyalty punch cards</p>
Expand All @@ -85,10 +86,7 @@ export const MerchantApp = () => {
<button
className="px-5 py-2.5 rounded-full bg-black text-white text-sm font-bold shadow-md hover:bg-black/90 hover:shadow-lg hover:scale-105 active:scale-95 transition-all duration-200"
onClick={() => {
const baseUrl = import.meta.env.BASE_URL.endsWith('/')
? import.meta.env.BASE_URL.slice(0, -1)
: import.meta.env.BASE_URL
window.open(`${baseUrl}/customer`, '_blank')
window.open(getRoutePath('/customer'), '_blank')
}}
>
Open customer view
Expand Down
31 changes: 31 additions & 0 deletions src/utils/paths.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/**
* Get the base URL for the application
* This handles both GitHub Pages (/bunch/) and root deployments (/)
*/
export function getBaseUrl(): string {
const base = import.meta.env.BASE_URL || './'
// For relative base like './', return empty string
if (base === './' || base === '.') {
return ''
}
// Ensure base ends with / but not double //
return base?.endsWith('/') ? base.slice(0, -1) : base
}

/**
* Get an absolute path for a public asset
*/
export function getAssetPath(path: string): string {
const base = getBaseUrl()
const cleanPath = path.startsWith('/') ? path : `/${path}`
return `${base}${cleanPath}`
}

/**
* Get an absolute path for a route
*/
export function getRoutePath(path: string): string {
const base = getBaseUrl()
const cleanPath = path.startsWith('/') ? path : `/${path}`
return `${base}${cleanPath}`
}
6 changes: 3 additions & 3 deletions vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

// https://vite.dev/config/
export default defineConfig({
base: '/bunch/',
export default defineConfig(({ mode }) => ({
base: mode === 'github' ? '/bunch/' : './',
plugins: [react()],
server: {
host: true, // Listen on all network interfaces (allows phone access)
port: 5173,
},
})
}))