Skip to content

Commit ee8de36

Browse files
author
Daveed
committed
product entry slug pages
1 parent 791033b commit ee8de36

File tree

6 files changed

+721
-24
lines changed

6 files changed

+721
-24
lines changed

src/components/checkoutModal.tsx

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
import { type FC, useEffect, useState } from "react";
2+
import { createPaymentLink } from "../scripts/ui.product";
3+
4+
// Loose types: CMS shapes vary between projects
5+
type AnyPrice = any;
6+
type AnyOptions = any;
7+
8+
interface Props {
9+
prices: AnyPrice[];
10+
product: any;
11+
store: any;
12+
hideTrigger?: boolean;
13+
}
14+
15+
const CheckoutModal: FC<Props> = ({ prices, product, store, hideTrigger = false }) => {
16+
const [isModalOpen, setIsModalOpen] = useState(false);
17+
const [selectedPriceId, setSelectedPriceId] = useState("");
18+
const [quantity, setQuantity] = useState(1);
19+
const [tip, setTip] = useState(0);
20+
const [total, setTotal] = useState(0);
21+
const [selectedPrice, setSelectedPrice] = useState({} as AnyPrice);
22+
const [submitting, setSubmitting] = useState(false);
23+
const [serverError, setServerError] = useState<string | null>(null);
24+
25+
const [options, setOptions] = useState({
26+
totalPrice: 0,
27+
product: product?.id || product?.SKU || product?.slug || product?.Name,
28+
prices: [],
29+
stripe_test: !!(product?.Name || product?.Title || "").match(/test/i),
30+
includes_shipping: !/(digital)/i.test(product?.Name || product?.Title || ""),
31+
store_id: store?.data?.documentId || store?.documentId || store?.id || store?.slug,
32+
} as AnyOptions);
33+
34+
useEffect(() => {
35+
const price = prices.find((p: any) => p.STRIPE_ID === selectedPriceId) as AnyPrice;
36+
const basePrice = parseInt(String(price?.Price || price?.price || '0'), 10);
37+
const subtotal = basePrice * quantity;
38+
const newTotal = subtotal + tip;
39+
40+
const option_prices: AnyPrice[] = [
41+
{
42+
quantity,
43+
price: selectedPriceId,
44+
currency: (price?.Currency || price?.currency || 'usd'),
45+
},
46+
];
47+
48+
if (tip > 0) {
49+
option_prices.push({
50+
unit_amount: String(tip),
51+
currency: 'usd',
52+
product: product?.SKU || product?.slug || product?.Name,
53+
});
54+
}
55+
56+
setOptions((prev: AnyOptions) => ({
57+
...prev,
58+
totalPrice: newTotal,
59+
prices: option_prices,
60+
}));
61+
62+
setTotal(Number(newTotal));
63+
setSelectedPrice(price as AnyPrice);
64+
}, [selectedPriceId, quantity, tip]);
65+
66+
const redirectToPaymentLink = async (e: any) => {
67+
e.preventDefault();
68+
setSubmitting(true);
69+
setServerError(null);
70+
try {
71+
const link = await createPaymentLink(options);
72+
console.log("Payment link:", link);
73+
} catch (err: any) {
74+
setServerError(err?.message || String(err));
75+
} finally {
76+
setSubmitting(false);
77+
}
78+
};
79+
80+
useEffect(() => {
81+
const handleEsc = (event: KeyboardEvent) => {
82+
if (event.key === 'Escape') {
83+
setIsModalOpen(false);
84+
}
85+
};
86+
87+
if (isModalOpen) {
88+
window.addEventListener('keydown', handleEsc);
89+
}
90+
91+
return () => {
92+
window.removeEventListener('keydown', handleEsc);
93+
};
94+
}, [isModalOpen]);
95+
96+
return (
97+
<>
98+
{!hideTrigger && (
99+
<button
100+
className="w-full mb-5 flex items-center justify-center rounded-lg bg-sky-600 px-6 py-3 text-base font-semibold text-white hover:bg-sky-700 transition"
101+
onClick={() => setIsModalOpen(true)}
102+
>
103+
Continue to Payment
104+
</button>
105+
)}
106+
{isModalOpen && (
107+
<div
108+
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
109+
onClick={(e) =>
110+
(e.target as Element).classList.contains("fixed") &&
111+
setIsModalOpen(false)
112+
}
113+
>
114+
<div className="relative w-full max-w-md modal-panel p-6 shadow-xl ">
115+
<button
116+
className="absolute top-4 right-4 text-gray-400 hover:text-gray-700 dark:hover:text-white transition"
117+
onClick={() => setIsModalOpen(false)}
118+
>
119+
<svg
120+
xmlns="http://www.w3.org/2000/svg"
121+
width="22"
122+
height="22"
123+
viewBox="0 0 24 24"
124+
stroke="currentColor"
125+
fill="none"
126+
strokeWidth="2"
127+
strokeLinecap="round"
128+
strokeLinejoin="round"
129+
>
130+
<path d="M18 6L6 18M6 6l12 12" />
131+
</svg>
132+
</button>
133+
<form onSubmit={redirectToPaymentLink}>
134+
<h2 className="text-xl font-semibold text-gray-900 mb-1">
135+
Checkout: {product?.Name || product?.Title}
136+
</h2>
137+
<p className="text-sm text-gray-500 mb-4">
138+
Select your preferred option and customize your order.
139+
</p>
140+
<div className="mb-4">
141+
<label
142+
htmlFor="price"
143+
className="block text-sm font-medium mb-1"
144+
>
145+
Product Options
146+
</label>
147+
<select
148+
id="price"
149+
name="price"
150+
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-base focus:ring-sky-500 focus:border-sky-500"
151+
onChange={(e) => setSelectedPriceId(e.target.value)}
152+
>
153+
<option value="">Select an option</option>
154+
{prices.map((price: any) => (
155+
<option key={price.STRIPE_ID} value={price.STRIPE_ID}>
156+
{(price.Name || price.name || '').replace(/_/g, ' ')} — ${price.Price || price.price}{' '}
157+
{price.Currency || price.currency}
158+
</option>
159+
))}
160+
</select>
161+
</div>
162+
<div className="mb-4">
163+
<label
164+
htmlFor="quantity"
165+
className="block text-sm font-medium mb-1"
166+
>
167+
Quantity
168+
</label>
169+
<input
170+
type="number"
171+
id="quantity"
172+
value={quantity}
173+
onChange={(e) => setQuantity(Number(e.target.value))}
174+
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-base focus:ring-sky-500 focus:border-sky-500"
175+
/>
176+
</div>
177+
<div className="mb-4">
178+
<label
179+
htmlFor="tip"
180+
className="block text-sm font-medium mb-1"
181+
>
182+
Tip / Custom Price
183+
</label>
184+
<input
185+
type="number"
186+
id="tip"
187+
value={tip}
188+
onChange={(e) => setTip(Number(e.target.value))}
189+
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-base focus:ring-sky-500 focus:border-sky-500"
190+
/>
191+
</div>
192+
<div className="mb-4">
193+
<label className="block text-sm font-medium mb-1">Total</label>
194+
<p className="text-2xl font-semibold">${total || 0}</p>
195+
</div>
196+
<div className="mb-4">
197+
<button
198+
type="submit"
199+
disabled={submitting || total <= 0 || !selectedPrice?.Description || quantity < 1}
200+
className="w-full rounded-lg bg-sky-600 px-5 py-3 text-white font-semibold hover:bg-sky-700 transition disabled:opacity-50 disabled:cursor-not-allowed"
201+
>
202+
{submitting ? 'Processing...' : 'Continue to Payment'}
203+
</button>
204+
</div>
205+
<div className="text-sm text-gray-600 dark:text-gray-400">
206+
{total > 0
207+
? selectedPrice?.Description || "Continue using custom price"
208+
: "Please select an option and enter a valid total."}
209+
</div>
210+
{serverError && <div className="text-sm text-red-600 mt-3">{serverError}</div>}
211+
</form>
212+
</div>
213+
</div>
214+
)}
215+
</>
216+
);
217+
};
218+
219+
export default CheckoutModal;

src/pages/about/index.astro

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ const storeCover = store.Cover;
1414
const allPages = await getCollection("pages");
1515
// exclude the about page itself and any that opt-out via ShowInAbout === false
1616
const pageCards = allPages
17-
.filter((p) => p.data?.slug !== "about")
17+
.filter((p) => !["about", "receipt", "product"].includes(p.data.slug))
1818
.filter((p) => (p.data as any)?.ShowInAbout !== false)
1919
.slice(0, 8);
2020

0 commit comments

Comments
 (0)