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
18 changes: 13 additions & 5 deletions src/components/BlocksContent.astro
Original file line number Diff line number Diff line change
Expand Up @@ -214,13 +214,13 @@ const { content, className = "" } = Astro.props;
.filter((child) => child.type === "list-item")
.map((child) => (
<li class="leading-relaxed">
{child.children?.map((grandChild) => (
{child.children?.map((grandChild: any) => (
<Fragment>
{grandChild.type === "link" ? (
<a
href={grandChild.url}
target={
grandChild.url?.startsWith("/")
(grandChild.url || "").startsWith("/")
? "_self"
: "_blank"
}
Expand Down Expand Up @@ -273,16 +273,24 @@ const { content, className = "" } = Astro.props;
<div class="rounded-lg overflow-hidden shadow-lg">
<img
src={block.image.url}
alt={block.image.alternativeText || ""}
alt={
(block.image as any).alternativeText ||
(block.image as any).name ||
""
}
class="w-full h-auto object-cover"
width={block.image.width}
height={block.image.height}
loading="lazy"
/>
</div>
{(block.image.caption || block.image.alternativeText) && (
{((block.image as any).caption ||
(block.image as any).alternativeText ||
(block.image as any).name) && (
<figcaption class="text-center text-sm text-gray-500 mt-3 italic">
{block.image.caption || block.image.alternativeText}
{(block.image as any).caption ||
(block.image as any).alternativeText ||
(block.image as any).name}
</figcaption>
)}
</figure>
Expand Down
219 changes: 219 additions & 0 deletions src/components/checkoutModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
import { type FC, useEffect, useState } from "react";
import { createPaymentLink } from "../scripts/ui.product";

// Loose types: CMS shapes vary between projects
type AnyPrice = any;
type AnyOptions = any;

interface Props {
prices: AnyPrice[];
product: any;
store: any;
hideTrigger?: boolean;
}

const CheckoutModal: FC<Props> = ({ prices, product, store, hideTrigger = false }) => {
const [isModalOpen, setIsModalOpen] = useState(false);
const [selectedPriceId, setSelectedPriceId] = useState("");
const [quantity, setQuantity] = useState(1);
const [tip, setTip] = useState(0);
const [total, setTotal] = useState(0);
const [selectedPrice, setSelectedPrice] = useState({} as AnyPrice);
const [submitting, setSubmitting] = useState(false);
const [serverError, setServerError] = useState<string | null>(null);

const [options, setOptions] = useState({
totalPrice: 0,
product: product?.id || product?.SKU || product?.slug || product?.Name,
prices: [],
stripe_test: !!(product?.Name || product?.Title || "").match(/test/i),
includes_shipping: !/(digital)/i.test(product?.Name || product?.Title || ""),
store_id: store?.data?.documentId || store?.documentId || store?.id || store?.slug,
} as AnyOptions);

useEffect(() => {
const price = prices.find((p: any) => p.STRIPE_ID === selectedPriceId) as AnyPrice;
const basePrice = parseInt(String(price?.Price || price?.price || '0'), 10);
const subtotal = basePrice * quantity;
const newTotal = subtotal + tip;

const option_prices: AnyPrice[] = [
{
quantity,
price: selectedPriceId,
currency: (price?.Currency || price?.currency || 'usd'),
},
];

if (tip > 0) {
option_prices.push({
unit_amount: String(tip),
currency: 'usd',
product: product?.SKU || product?.slug || product?.Name,
});
}

setOptions((prev: AnyOptions) => ({
...prev,
totalPrice: newTotal,
prices: option_prices,
}));

setTotal(Number(newTotal));
setSelectedPrice(price as AnyPrice);
}, [selectedPriceId, quantity, tip]);

const redirectToPaymentLink = async (e: any) => {
e.preventDefault();
setSubmitting(true);
setServerError(null);
try {
const link = await createPaymentLink(options);
console.log("Payment link:", link);
} catch (err: any) {
setServerError(err?.message || String(err));
} finally {
setSubmitting(false);
}
};

useEffect(() => {
const handleEsc = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setIsModalOpen(false);
}
};

if (isModalOpen) {
window.addEventListener('keydown', handleEsc);
}

return () => {
window.removeEventListener('keydown', handleEsc);
};
}, [isModalOpen]);

return (
<>
{!hideTrigger && (
<button
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"
onClick={() => setIsModalOpen(true)}
>
Available Options and Prices
</button>
)}
{isModalOpen && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
onClick={(e) =>
(e.target as Element).classList.contains("fixed") &&
setIsModalOpen(false)
}
>
<div className="relative w-full max-w-md modal-panel p-6 shadow-xl ">
<button
className="absolute top-4 right-4 text-gray-400 hover:text-gray-700 dark:hover:text-white transition"
onClick={() => setIsModalOpen(false)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="22"
height="22"
viewBox="0 0 24 24"
stroke="currentColor"
fill="none"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
<form onSubmit={redirectToPaymentLink}>
<h2 className="text-xl font-semibold text-gray-200 mb-1">
Checkout: {product?.Name || product?.Title}
</h2>
<p className="text-sm text-gray-300 mb-4">
Select your preferred option and customize your order.
</p>
<div className="mb-4">
<label
htmlFor="price"
className="block text-sm font-medium mb-1"
>
Product Options
</label>
<select
id="price"
name="price"
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"
onChange={(e) => setSelectedPriceId(e.target.value)}
>
<option value="">Select an option</option>
{prices.map((price: any) => (
<option key={price.STRIPE_ID} value={price.STRIPE_ID}>
{(price.Name || price.name || '').replace(/_/g, ' ')} — ${price.Price || price.price}{' '}
{price.Currency || price.currency}
</option>
))}
</select>
</div>
<div className="mb-4">
<label
htmlFor="quantity"
className="block text-sm font-medium mb-1"
>
Quantity
</label>
<input
type="number"
id="quantity"
value={quantity}
onChange={(e) => setQuantity(Number(e.target.value))}
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"
/>
</div>
<div className="mb-4">
<label
htmlFor="tip"
className="block text-sm font-medium mb-1"
>
Tip / Custom Price
</label>
<input
type="number"
id="tip"
value={tip}
onChange={(e) => setTip(Number(e.target.value))}
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"
/>
</div>
<div className="mb-4">
<label className="block text-sm font-medium mb-1">Total</label>
<p className="text-2xl font-semibold">${total || 0}</p>
</div>
<div className="mb-4">
<button
type="submit"
disabled={submitting || total <= 0 || !selectedPrice?.Description || quantity < 1}
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"
>
{submitting ? 'Processing...' : 'Continue to Payment'}
</button>
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
{total > 0
? selectedPrice?.Description || "Continue using custom price"
: "Please select an option and enter a valid total."}
</div>
{serverError && <div className="text-sm text-red-600 mt-3">{serverError}</div>}
</form>
</div>
</div>
)}
</>
);
};

export default CheckoutModal;
4 changes: 2 additions & 2 deletions src/content/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ function strapiLoader(query: StrapiQueryOptions) {
name: `strapi-${query.contentType}`,
schema: async () => await fetchStrapiSchema(query.contentType, config.api_url),

async load({ store, logger, meta }) {
async load({ store, logger, meta }: { store: any; logger: any; meta: any }) {
const lastSynced = meta.get("lastSynced");
if (lastSynced && Date.now() - Number(lastSynced) < config.sync_interval) {
logger.info("Skipping sync");
Expand All @@ -47,7 +47,7 @@ function strapiLoader(query: StrapiQueryOptions) {
const pages = defineCollection({
loader: strapiLoader({
contentType: "page",
sort: 'slug:DESC',
sort: 'slug:ASC',
filter: `filters[store][slug][$eq]=${config.store_slug}`,
populate: 'SEO.socialImage,albums,albums.tracks,albums.cover'
}) as Loader,
Expand Down
9 changes: 1 addition & 8 deletions src/layouts/LandingLayout.astro
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,6 @@ const {
)
}

<!-- CTA Section -->
<section class="final-cta-section">
<div class="container">
<div class="cta-content">
Expand All @@ -211,13 +210,7 @@ const {
>
Get Started for Free
</a>
<a
href="https://github.com/calimania/markket-space"
class="cta-secondary"
target="_blank"
>
View Documentation
</a>
<a href="/blog" class="cta-secondary">Blog</a>
</div>
</div>
</div>
Expand Down
63 changes: 63 additions & 0 deletions src/lib/entry.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// Utility helpers for posts: excerpt, image selection, related picks
export function renderExcerpt(post: any): string {
const meta = post.data?.SEO || {};
let excerpt = "";
const blocks = post.data?.Content || [];
for (const b of blocks) {
if (b?.type === "paragraph" && Array.isArray(b.children)) {
const t = b.children.map((c: any) => c?.text || "").join(" ").trim();
if (t) { excerpt = t; break; }
}
}
return excerpt || meta?.metaDescription || "";
}

export function imgFor(post: any): string {
const meta = post.data?.SEO || {};
return (
meta?.socialImage?.formats?.large?.url ||
meta?.socialImage?.formats?.small?.url ||
meta?.socialImage?.url ||
post.data?.cover?.url ||
""
);
}

export function getTags(post: any): string[] {
return (post.data?.Tags || post.data?.tags || [])
.map((t: any) => (typeof t === "string" ? t.toLowerCase() : (t?.name || "").toLowerCase()))
.filter(Boolean);
}

export function titleWords(post: any): string[] {
const t = (post.data?.Title || post.data?.title || "").toLowerCase();
return Array.from(new Set(t.split(/[^a-z0-9]+/).filter(Boolean)));
}

export function daysSince(dateLike: any): number {
const d = new Date(dateLike || 0).getTime();
if (!d || isNaN(d)) return 365 * 10;
return (Date.now() - d) / (1000 * 60 * 60 * 24);
}

export function pickRelated(allPosts: any[], current: any, limit = 3) {
const candidates = allPosts.filter((p) => p.id !== current.id);
const tagsA = getTags(current);

const scored = candidates.map((c) => {
const tagsB = getTags(c);
const tagOverlap = tagsA.filter((t: string) => tagsB.includes(t)).length;

const wordsA = titleWords(current);
const wordsB = titleWords(c);
const titleOverlap = wordsA.filter((w: string) => wordsB.includes(w)).length;

const days = daysSince((c.data as any)?.Date || (c.data as any)?.date || (c.data as any)?.publishedAt);
const recency = Math.max(0, 1 - days / 365);

const score = tagOverlap * 10 + titleOverlap * 2 + recency * 1;
return { post: c, score };
});

return scored.sort((a, b) => b.score - a.score).slice(0, limit).map((r) => r.post);
}
Loading