Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
ce4570f
feat: add Spire webhook integration for Blog CMS
yuriassuncx May 27, 2026
10bf7c3
feat: implement Spire webhook for post synchronization and add Pendin…
yuriassuncx May 27, 2026
03b88f7
feat: secure and URL-encode SpireDashboard embedded iframe origin and…
yuriassuncx May 27, 2026
7ccde83
style: auto-format and optimize all files in the spire module with de…
yuriassuncx May 27, 2026
c4af58c
style: auto-format all files in the blog module with deno fmt
yuriassuncx May 27, 2026
d0fbc96
feat: implement Deco-Spire quick approvals action and bidirectional m…
yuriassuncx May 27, 2026
1585aeb
feat: enhance webhook token verification to support query param auth
yuriassuncx May 27, 2026
210abec
feat(blog): implement HMAC-SHA256 signature verification for webhook
yuriassuncx May 27, 2026
f3f4886
refactor(webhook): rename importSpirePost action to a generic standar…
yuriassuncx May 27, 2026
fa2939e
feat(webhook): secure parsing, validation whitelists, request timeout…
yuriassuncx May 27, 2026
33603e2
feat: add Spire ↔ Deco bidirectional integration
yuriassuncx May 27, 2026
9ff93c4
fix(blog): authenticate syncAllPosts via webhook secret from ctx
yuriassuncx May 27, 2026
7d206e1
fix(blog): security, HTML validity and error handling hardening
yuriassuncx May 27, 2026
b2434d3
fix(blog): write blocks in Deco-native URL-encoded format with name f…
yuriassuncx May 27, 2026
92e61e4
fix(blog): correct date format and spireWarning type for Deco admin
yuriassuncx May 27, 2026
cfc8b3b
feat(blog): extract syncBlogPosts util and add auto startup/cron sync
yuriassuncx May 28, 2026
06b9e2d
feat(blog, spire): integrate Spire API directly into blog loaders
yuriassuncx May 29, 2026
0b1fc05
feat(blog): restore block writes for Studio CMS visibility
yuriassuncx May 29, 2026
9de04ff
feat(blog): auto-sync Spire posts to Studio CMS on startup
yuriassuncx May 29, 2026
042634b
fix(blog): handle HttpError from fetchSafe in Spire API loaders
yuriassuncx May 29, 2026
6a39e62
fix: address code review findings — security, cache, data integrity
yuriassuncx May 30, 2026
7bcad36
fix(SpireSyncPreviewTab): restore full webhook URL template
yuriassuncx May 30, 2026
9b306d3
feat(blog): add hourly Deno.cron reconciliation for Spire post updates
yuriassuncx May 30, 2026
36ca682
fix(spireImport): improve JSON parse error context and add string-lis…
yuriassuncx May 30, 2026
9c1c41f
fix(blog/mod): resolve double-sync, HMR restart, and loader hang
yuriassuncx May 30, 2026
b233435
feat(blog): add 30-min setInterval fallback for local dev reconciliation
yuriassuncx May 30, 2026
37a813a
refactor(blog): proper TypeScript patterns + security + full audit
yuriassuncx May 31, 2026
dede921
feat(blog): enhance Spire post integration and improve content fetchi…
yuriassuncx Jun 1, 2026
f4bfe88
merge(main): resolve conflicts, fix blog app bugs and refactor mod.ts
yuriassuncx Jun 1, 2026
b376be8
refactor(resolveSpireGate): remove unused request authorization and c…
yuriassuncx Jun 1, 2026
231beff
feat(blog+spire): Spire sections migrated to blog as source of truth;…
yuriassuncx Jun 1, 2026
bfe28aa
fix(blog+spire): deno task bundle regenerated manifests + spire block…
yuriassuncx Jun 1, 2026
9214f0d
fix(Comparison): remove conflicting text-base alongside text-xs (same…
yuriassuncx Jun 1, 2026
d7a84fa
feat(blog): delete spire app — blog is now the single app
yuriassuncx Jun 1, 2026
b48e082
feat(blog): add product block sections + rename Cta→CallToAction, rem…
yuriassuncx Jun 2, 2026
5a3c31d
merge: resolve conflicts with origin/main (keep product block section…
yuriassuncx Jun 2, 2026
b64491f
fix(blog): regenerate manifest.gen.ts with correct section/loader ord…
yuriassuncx Jun 2, 2026
ee381fb
refactor(blog): remove spireWebhookSecret from State type definition
yuriassuncx Jun 3, 2026
72d930d
feat(blog): add spireWebhookSecret to blog app State
yuriassuncx Jun 3, 2026
dd18b56
chore(blog): remove spireWebhookSecret from State — stored in sites.m…
yuriassuncx Jun 3, 2026
bc33060
refactor(blog): remove SpireBlogPost interface + dead blocksToSection…
yuriassuncx Jun 3, 2026
7bb820c
refactor(blog): minimal Spire integration — revert non-essential changes
yuriassuncx Jun 3, 2026
8c59bd0
fix(blog): revert logo url + exclude ui-templates from deno fmt
yuriassuncx Jun 3, 2026
73bd4ea
ci: retrigger after esm.sh 522 transient failure
yuriassuncx Jun 3, 2026
71cdcc7
fix(ci): move ui-templates exclusion to fmt.exclude only
yuriassuncx Jun 3, 2026
8b10435
refactor(blog): remove spireBlogSlug from State type
yuriassuncx Jun 5, 2026
7980bd6
feat(blog): implement dynamic product selection and loading functiona…
yuriassuncx Jun 5, 2026
457336b
fix(blog): format product resolver and product block files to satisfy CI
yuriassuncx Jun 5, 2026
9b30255
refactor(blog): consolidate product blocks into blog-only resolver
yuriassuncx Jul 2, 2026
178b42b
fix(blog): address review findings for product blocks and loaders
yuriassuncx Jul 3, 2026
b741c87
feat(blog): add shoppable product block sections
yuriassuncx Jul 3, 2026
f36063c
fix(blog): skip product resolution when shelf has no refs
yuriassuncx Jul 3, 2026
31cacb9
feat(blog): hybrid product input coercion for block loaders
yuriassuncx Jul 3, 2026
55e7cc7
fix(blog): detect Shopify when VTEX invoke is absent
yuriassuncx Jul 3, 2026
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
25 changes: 25 additions & 0 deletions blog/loaders/options/productsByTerm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { allowCorsFor } from "@deco/deco";
import { searchProductOptions } from "../../utils/productResolver.ts";
import { AppContext } from "../../mod.ts";

export interface Props {
term?: string;
}

/**
* @title Products by term
* @description Searchable product picker for blog product blocks.
*/
export default async function productsByTerm(
props: Props,
req: Request,
ctx: AppContext,
) {
Object.entries(allowCorsFor(req)).forEach(([name, value]) => {
ctx.response.headers.set(name, value);
});

return await searchProductOptions(props.term ?? "", req, ctx);
}

export const cache = "no-cache";
28 changes: 18 additions & 10 deletions blog/manifest.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import * as $$$15 from "./loaders/extensions/BlogpostPage.ts";
import * as $$$16 from "./loaders/extensions/BlogpostPage/ratings.ts";
import * as $$$17 from "./loaders/extensions/BlogpostPage/reviews.ts";
import * as $$$8 from "./loaders/GetCategories.ts";
import * as $$$18 from "./loaders/options/productsByTerm.ts";
import * as $$$$$$3 from "./sections/blocks/BlockImage.tsx";
import * as $$$$$$4 from "./sections/blocks/Callout.tsx";
import * as $$$$$$5 from "./sections/blocks/CardGroup.tsx";
Expand All @@ -34,11 +35,14 @@ import * as $$$$$$10 from "./sections/blocks/Divider.tsx";
import * as $$$$$$11 from "./sections/blocks/Heading.tsx";
import * as $$$$$$12 from "./sections/blocks/List.tsx";
import * as $$$$$$13 from "./sections/blocks/Paragraph.tsx";
import * as $$$$$$14 from "./sections/blocks/Quote.tsx";
import * as $$$$$$15 from "./sections/blocks/Stat.tsx";
import * as $$$$$$16 from "./sections/blocks/StatGroup.tsx";
import * as $$$$$$17 from "./sections/blocks/Steps.tsx";
import * as $$$$$$18 from "./sections/blocks/Video.tsx";
import * as $$$$$$14 from "./sections/blocks/ProductCard.tsx";
import * as $$$$$$15 from "./sections/blocks/ProductHighlight.tsx";
import * as $$$$$$16 from "./sections/blocks/ProductShelf.tsx";
import * as $$$$$$17 from "./sections/blocks/Quote.tsx";
import * as $$$$$$18 from "./sections/blocks/Stat.tsx";
import * as $$$$$$19 from "./sections/blocks/StatGroup.tsx";
import * as $$$$$$20 from "./sections/blocks/Steps.tsx";
import * as $$$$$$21 from "./sections/blocks/Video.tsx";
import * as $$$$$$0 from "./sections/Seo/SeoBlogPost.tsx";
import * as $$$$$$1 from "./sections/Seo/SeoBlogPostListing.tsx";
import * as $$$$$$2 from "./sections/Template.tsx";
Expand All @@ -63,6 +67,7 @@ const manifest = {
"blog/loaders/extensions/BlogpostPage/ratings.ts": $$$16,
"blog/loaders/extensions/BlogpostPage/reviews.ts": $$$17,
"blog/loaders/GetCategories.ts": $$$8,
"blog/loaders/options/productsByTerm.ts": $$$18,
},
"sections": {
"blog/sections/blocks/BlockImage.tsx": $$$$$$3,
Expand All @@ -76,11 +81,14 @@ const manifest = {
"blog/sections/blocks/Heading.tsx": $$$$$$11,
"blog/sections/blocks/List.tsx": $$$$$$12,
"blog/sections/blocks/Paragraph.tsx": $$$$$$13,
"blog/sections/blocks/Quote.tsx": $$$$$$14,
"blog/sections/blocks/Stat.tsx": $$$$$$15,
"blog/sections/blocks/StatGroup.tsx": $$$$$$16,
"blog/sections/blocks/Steps.tsx": $$$$$$17,
"blog/sections/blocks/Video.tsx": $$$$$$18,
"blog/sections/blocks/ProductCard.tsx": $$$$$$14,
"blog/sections/blocks/ProductHighlight.tsx": $$$$$$15,
"blog/sections/blocks/ProductShelf.tsx": $$$$$$16,
"blog/sections/blocks/Quote.tsx": $$$$$$17,
"blog/sections/blocks/Stat.tsx": $$$$$$18,
"blog/sections/blocks/StatGroup.tsx": $$$$$$19,
"blog/sections/blocks/Steps.tsx": $$$$$$20,
"blog/sections/blocks/Video.tsx": $$$$$$21,
"blog/sections/Seo/SeoBlogPost.tsx": $$$$$$0,
"blog/sections/Seo/SeoBlogPostListing.tsx": $$$$$$1,
"blog/sections/Template.tsx": $$$$$$2,
Expand Down
136 changes: 136 additions & 0 deletions blog/sections/blocks/ProductCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import Image from "../../../website/components/Image.tsx";
import { getProductDisplay } from "../../utils/productData.ts";
import { coerceProduct } from "../../utils/coerceProductInput.ts";
import { sanitizeHtml } from "../../utils/sanitizeHtml.ts";
import { AppContext } from "../../mod.ts";
import type { Product } from "../../../commerce/types.ts";

/**
* @title Product
* @description Search and select a storefront product dynamically.
* @format dynamic-options
* @options blog/loaders/options/productsByTerm.ts
*/
type ProductReference = string;

export interface Props {
/**
* @title Product
* @description Product reference as `platform:kind:id` (e.g. `vtex:product:123`). Loader-ref shapes work at runtime but strings are preferred.
* @format dynamic-options
* @options blog/loaders/options/productsByTerm.ts
*/
product?: ProductReference;
/** Badge text (e.g. "New", "Sale") */
badge?: string;
/** Short description (HTML) */
description?: string;
/** Call-to-action button text */
cta?: string;
}

type RuntimeProps = Omit<Props, "product"> & {
product: Product | null;
};

export async function loader(props: Props, req: Request, ctx: AppContext) {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
const product = await coerceProduct(props.product, req, ctx);
return {
badge: props.badge,
description: props.description,
cta: props.cta,
product,
} as RuntimeProps;
}

/**
* @title Product Card
* @description Displays a shoppable product card with image, name, price and CTA.
*/
export default function ProductCard(
{ product, badge, description, cta }: RuntimeProps,
) {
if (!product) return null;

const {
name,
imageUrl,
width,
height,
price,
listPrice,
safeUrl,
isExternal,
} = getProductDisplay(product);
if (!name) return null;

const productDescription = description ?? product.description;

return (
<div class="not-prose my-8 border border-line rounded-brand overflow-hidden bg-base max-w-xs mx-auto">
<div class="relative overflow-hidden bg-alt aspect-square">
<Image
src={imageUrl}
alt={name}
width={width}
height={height}
fit="cover"
loading="lazy"
fetchPriority="low"
class="w-full h-full object-cover block"
/>
{badge && (
<span class="absolute top-3 left-3 text-xs font-semibold px-2 py-1 rounded-full bg-accent text-inverted leading-none">
{badge}
</span>
)}
</div>
<div class="p-5 flex flex-col gap-2">
<h3 class="text-[1.0625rem] font-semibold leading-snug m-0">{name}</h3>
{productDescription && (
<div
class="text-sm text-secondary leading-normal [text-wrap:pretty] line-clamp-3"
dangerouslySetInnerHTML={{
__html: sanitizeHtml(productDescription),
}}
/>
)}
<div class="mt-1 flex items-baseline gap-2">
{price && (
<span class="text-lg font-bold text-accent tabular-nums">
{price}
</span>
)}
{listPrice && listPrice !== price && (
<span class="text-sm text-muted line-through tabular-nums">
{listPrice}
</span>
)}
</div>
{safeUrl && (
<a
href={safeUrl}
{...(isExternal
? { target: "_blank", rel: "noopener noreferrer" }
: {})}
class="mt-2 inline-flex items-center justify-center py-[0.6em] px-[1.5em] rounded-full bg-accent text-inverted font-semibold text-sm tracking-[0.02em] no-underline transition-opacity hover:opacity-85"
>
{cta ?? "Comprar"}
</a>
)}
</div>
</div>
);
}

export function LoadingFallback() {
return (
<div class="not-prose my-8 border border-line rounded-brand bg-base max-w-xs mx-auto p-5 animate-pulse">
<div class="aspect-square bg-alt mb-4 rounded-brand" />
<div class="h-5 bg-alt mb-2 rounded" />
<div class="h-4 bg-alt mb-2 rounded" />
<div class="h-4 bg-alt w-2/3 mb-4 rounded" />
<div class="h-9 bg-alt w-1/2 rounded-full" />
</div>
);
}
143 changes: 143 additions & 0 deletions blog/sections/blocks/ProductHighlight.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import Image from "../../../website/components/Image.tsx";
import { getProductDisplay } from "../../utils/productData.ts";
import { coerceProduct } from "../../utils/coerceProductInput.ts";
import { sanitizeHtml } from "../../utils/sanitizeHtml.ts";
import { AppContext } from "../../mod.ts";
import type { Product } from "../../../commerce/types.ts";

/**
* @title Product
* @description Search and select a storefront product dynamically.
* @format dynamic-options
* @options blog/loaders/options/productsByTerm.ts
*/
type ProductReference = string;

export interface Props {
/**
* @title Product
* @description Product reference as `platform:kind:id` (e.g. `vtex:product:123`). Loader-ref shapes work at runtime but strings are preferred.
* @format dynamic-options
* @options blog/loaders/options/productsByTerm.ts
*/
product?: ProductReference;
/** Badge text (e.g. "Destaque", "Limited") */
badge?: string;
/** Full description (HTML) */
description?: string;
/** Call-to-action button text */
cta?: string;
}

type RuntimeProps = Omit<Props, "product"> & {
product: Product | null;
};

export async function loader(props: Props, req: Request, ctx: AppContext) {
const product = await coerceProduct(props.product, req, ctx);
return {
badge: props.badge,
description: props.description,
cta: props.cta,
product,
} as RuntimeProps;
}

/**
* @title Product Highlight
* @description Full-width featured product block with image and details side by side.
*/
export default function ProductHighlight(
{ product, badge, description, cta }: RuntimeProps,
) {
if (!product) return null;

const {
name,
imageUrl,
width,
height,
price,
listPrice,
safeUrl,
isExternal,
} = getProductDisplay(product);
if (!name) return null;

const productDescription = description ?? product.description;

return (
<div class="not-prose my-10 border border-line rounded-brand overflow-hidden bg-base">
<div class="flex flex-col sm:flex-row">
<div class="relative sm:w-2/5 overflow-hidden bg-alt aspect-video sm:aspect-auto sm:min-h-[280px]">
<Image
src={imageUrl}
alt={name}
width={width}
height={height}
fit="cover"
loading="lazy"
fetchPriority="low"
class="w-full h-full object-cover block"
/>
{badge && (
<span class="absolute top-4 left-4 text-xs font-semibold px-2.5 py-1 rounded-full bg-accent text-inverted leading-none">
{badge}
</span>
)}
</div>
<div class="p-7 flex flex-col gap-3 justify-center sm:w-3/5">
<h3 class="text-xl font-bold leading-snug m-0">{name}</h3>
{productDescription && (
<div
class="text-[0.9375rem] text-secondary leading-relaxed [text-wrap:pretty]"
dangerouslySetInnerHTML={{
__html: sanitizeHtml(productDescription),
}}
/>
)}
<div class="flex items-baseline gap-2.5 mt-1">
{price && (
<span class="text-2xl font-bold text-accent tabular-nums">
{price}
</span>
)}
{listPrice && listPrice !== price && (
<span class="text-base text-muted line-through tabular-nums">
{listPrice}
</span>
)}
</div>
{safeUrl && (
<a
href={safeUrl}
{...(isExternal
? { target: "_blank", rel: "noopener noreferrer" }
: {})}
class="mt-2 self-start inline-flex items-center justify-center py-[0.7em] px-[2em] rounded-full bg-accent text-inverted font-semibold text-sm tracking-[0.02em] no-underline transition-opacity hover:opacity-85"
>
{cta ?? "Comprar"}
</a>
)}
</div>
</div>
</div>
);
}

export function LoadingFallback() {
return (
<div class="not-prose my-10 border border-line rounded-brand bg-base animate-pulse">
<div class="flex flex-col sm:flex-row">
<div class="sm:w-2/5 aspect-video sm:aspect-auto bg-alt" />
<div class="p-7 sm:w-3/5 flex flex-col gap-3">
<div class="h-7 bg-alt rounded w-3/4" />
<div class="h-4 bg-alt rounded" />
<div class="h-4 bg-alt rounded w-5/6" />
<div class="h-8 bg-alt rounded w-1/3 mt-2" />
<div class="h-10 bg-alt rounded-full w-40 mt-2" />
</div>
</div>
</div>
);
}
Loading