📦
Componentes
4 rutas
PDP, Collection, Cart, Account
⚡
Cache strategy
CacheLong + Short
Productos: 24h / Carrito: no-store
📈
Analytics
GTM + Shopify
GTM-GRANOSSUR via Partytown
🔄
Suscripciones
Selling Plans
15% dto. suscripcion mensual
Arquitectura del sistema
SSR + Oxygen
🌐
Browser
React hydration
➔
➔
📌
React Router
Loader / Action
➔
🛒
Storefront API
GraphQL 2024-10
➔
🗄
Edge Cache
CacheLong/Short
1. Product Detail Page (PDP)
routes/products.$handle.tsx
// Granos del Sur — Product Detail Page
import { useLoaderData, useNavigation } from 'react-router';
import { LoaderFunctionArgs } from 'react-router';
import {
Money, Image, getSelectedProductOptions,
Analytics, useAnalytics, CacheLong, CacheNone
} from '@shopify/hydrogen';
import { AddToCartButton } from '~/components/AddToCartButton';
import { SellingPlanSelector } from '~/components/SellingPlanSelector';
// --- GraphQL query (Storefront API) ---
const PRODUCT_QUERY = `#graphql
query ProductDetails($handle: String!, $selectedOptions: [SelectedOptionInput!]!) {
product(handle: $handle) {
id
title
description
vendor
selectedVariant: variantBySelectedOptions(selectedOptions: $selectedOptions) {
id
sku
availableForSale
price { amount currencyCode }
compareAtPrice { amount currencyCode }
selectedOptions { name value }
image { url altText width height }
}
variants(first: 20) {
nodes {
id
availableForSale
selectedOptions { name value }
price { amount currencyCode }
}
}
images(first: 6) {
nodes { url altText width height }
}
sellingPlanGroups(first: 5) {
nodes {
name
sellingPlans(first: 5) {
nodes {
id
name
priceAdjustments {
adjustmentValue {
... on SellingPlanPercentagePriceAdjustment { adjustmentPercentage }
}
}
}
}
}
}
}
}
`;
// --- Loader (Server-side, corre en Oxygen Worker) ---
export async function loader({ request, params, context }: LoaderFunctionArgs) {
const { storefront } = context;
const selectedOptions = getSelectedProductOptions(request);
const { product } = await storefront.query(PRODUCT_QUERY, {
variables: {
handle: params.handle!,
selectedOptions,
},
cache: storefront.CacheLong(), // Cached 24h en Oxygen Edge
});
if (!product) throw new Response('Producto no encontrado', { status: 404 });
return { product };
}
// --- Componente principal ---
export default function ProductPage() {
const { product } = useLoaderData<typeof loader>();
const { selectedVariant, sellingPlanGroups } = product;
return (
<Analytics.ProductView
data={{ products: [{ ...product, quantity: 1 }] }}
>
<div className="product-page">
{/* Galeria de imagenes */}
<section className="product-gallery">
{product.images.nodes.map((image, i) => (
<Image
key={i}
data={image}
sizes="(min-width: 768px) 50vw, 100vw"
loading={i === 0 ? "eager" : "lazy"}
/>
))}
</section>
{/* Info + CTA */}
<section className="product-info">
<h1>{product.title}</h1>
<p>{product.description}</p>
{/* Precio con Money component */}
<div className="pricing">
{selectedVariant?.compareAtPrice && (
<Money withoutTrailingZeros
data={selectedVariant.compareAtPrice}
as="del"
/>
)}
<Money withoutTrailingZeros
data={selectedVariant!.price}
className="price-current"
/>
</div>
{/* Selector de gramaje (250g / 500g / 1kg) */}
<VariantSelector
product={product}
options={product.options}
/>
{/* Suscripcion mensual con 15% dto */}
{sellingPlanGroups.nodes.length > 0 && (
<SellingPlanSelector
sellingPlanGroups={sellingPlanGroups.nodes}
/>
)}
<AddToCartButton
lines={[{ merchandiseId: selectedVariant!.id, quantity: 1 }]}
disabled={!selectedVariant?.availableForSale}
/>
</section>
</div>
</Analytics.ProductView>
);
}
2. Infinite Scroll — Coleccion “Cafes de Origen”
routes/collections.$handle.tsx
import { useLoaderData, useFetcher } from 'react-router';
import { Pagination, getPaginationVariables, Image, Money } from '@shopify/hydrogen';
import { useEffect, useRef } from 'react';
const COLLECTION_QUERY = `#graphql
query Collection(
$handle: String!
$first: Int
$last: Int
$startCursor: String
$endCursor: String
) {
collection(handle: $handle) {
id title description
image { url altText width height }
products(first: $first, last: $last,
before: $startCursor, after: $endCursor) {
nodes {
id title handle
featuredImage { url altText width height }
priceRange {
minVariantPrice { amount currencyCode }
}
variants(first: 1) {
nodes { id availableForSale }
}
}
pageInfo {
hasPreviousPage hasNextPage
startCursor endCursor
}
}
}
}
`;
export async function loader({ request, params, context }: LoaderFunctionArgs) {
const paginationVars = getPaginationVariables(request, { pageBy: 12 });
const { collection } = await context.storefront.query(COLLECTION_QUERY, {
variables: { handle: params.handle!, ...paginationVars },
cache: context.storefront.CacheShort(), // 1 min cache
});
return { collection };
}
export default function CollectionPage() {
const { collection } = useLoaderData<typeof loader>();
const loadMoreRef = useRef<HTMLDivElement>(null);
return (
<div>
<h1>{collection.title}</h1>
{/* Hydrogen Pagination con Infinite Scroll */}
<Pagination connection={collection.products}>
{({ nodes, isLoading, PreviousLink, NextLink }) => (
<>
<div className="product-grid">
{nodes.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
{/* Sentinel para infinite scroll */}
<div ref={loadMoreRef} style={{ height: '1px' }} />
<NextLink>
{isLoading ? 'Cargando...' : 'Cargar mas cafes'}
</NextLink>
</>
)}
</Pagination>
</div>
);
}
// Hook: IntersectionObserver para auto-cargar al hacer scroll
function useInfiniteScroll(ref: React.RefObject<HTMLDivElement>, callback: () => void) {
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => { if (entry.isIntersecting) callback(); },
{ threshold: 0.1 }
);
if (ref.current) observer.observe(ref.current);
return () => observer.disconnect();
}, [ref, callback]);
}
3. Google Tag Manager con Partytown
root.tsx — entry.server.tsx
import { useNonce, Analytics, getShopAnalytics } from '@shopify/hydrogen';
import { useEffect } from 'react';
const GTM_ID = 'GTM-GRANOSSUR';
export function Layout({ children }: { children: React.ReactNode }) {
const nonce = useNonce();
return (
<html>
<head>
{/* GTM via Partytown (off-main-thread) */}
<script
nonce={nonce}
dangerouslySetInnerHTML={{
__html: `
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '${GTM_ID}');
`
}}
/>
</head>
<body>
{/* Hydrogen Analytics: dispara eventos GA4 automaticamente */}
<Analytics.Provider
cart={cart}
shop={getShopAnalytics({ storefront, publicStoreDomain })}
consent={{ checkoutDomain: 'checkout.granossur.com' }}
>
{children}
{/* Custom GTM event handler */}
<GTMEventBridge gtmId={GTM_ID} />
</Analytics.Provider>
</body>
</html>
);
}
// Puente entre Analytics.Provider de Hydrogen y dataLayer de GTM
function GTMEventBridge({ gtmId }: { gtmId: string }) {
const { subscribe } = useAnalytics();
useEffect(() => {
// Suscribirse a eventos de Hydrogen Analytics
return subscribe('product_viewed', ({ products }) => {
window.dataLayer?.push({
event: 'view_item',
ecommerce: {
items: products.map((p) => ({
item_id: p.id,
item_name: p.title,
item_brand: 'Granos del Sur',
price: p.price?.amount,
currency: p.price?.currencyCode,
})),
},
});
});
}, [subscribe]);
useEffect(() => {
return subscribe('product_added_to_cart', ({ cart, currentLine }) => {
window.dataLayer?.push({
event: 'add_to_cart',
ecommerce: {
currency: currentLine?.cost?.totalAmount?.currencyCode,
value: currentLine?.cost?.totalAmount?.amount,
items: [{
item_id: currentLine?.merchandise?.product?.id,
item_name: currentLine?.merchandise?.product?.title,
quantity: currentLine?.quantity,
}],
},
});
});
}, [subscribe]);
return null;
}
4. Componente SellingPlanSelector (Suscripciones)
components/SellingPlanSelector.tsx
import { useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router';
type SellingPlanGroup = {
name: string;
sellingPlans: {
nodes: {
id: string;
name: string;
priceAdjustments: Array<{
adjustmentValue: {
adjustmentPercentage?: number;
};
}>;
}[];
};
};
export function SellingPlanSelector({
sellingPlanGroups,
}: {
sellingPlanGroups: SellingPlanGroup[];
}) {
const [searchParams, setSearchParams] = useSearchParams();
const [purchaseType, setPurchaseType] = useState<'one-time' | 'subscribe'>('one-time');
const handleSellingPlan = (sellingPlanId: string | null) => {
const params = new URLSearchParams(searchParams);
if (sellingPlanId) {
params.set('selling_plan', sellingPlanId);
} else {
params.delete('selling_plan');
}
setSearchParams(params, { preventScrollReset: true });
};
return (
<div className="selling-plan-selector">
<p className="selector-label">Tipo de compra</p>
{/* Compra unica */}
<label className={`option ${purchaseType === 'one-time' ? 'selected' : ''}`>
<input
type="radio"
name="purchase-type"
value="one-time"
checked={purchaseType === 'one-time'}
onChange={() => {
setPurchaseType('one-time');
handleSellingPlan(null);
}}
/>
Compra unica
</label>
{/* Suscripcion mensual (15% descuento) */}
{sellingPlanGroups.map((group) =>
group.sellingPlans.nodes.map((plan) => (
<label
key={plan.id}
className={`option subscribe ${purchaseType === 'subscribe' ? 'selected' : ''}`}
>
<input
type="radio"
name="purchase-type"
value={plan.id}
checked={purchaseType === 'subscribe'}
onChange={() => {
setPurchaseType('subscribe');
handleSellingPlan(plan.id);
}}
/>
{plan.name}
{/* Muestra el descuento: 15% */}
{plan.priceAdjustments[0]?.adjustmentValue.adjustmentPercentage && (
<span className="discount-badge">
-{plan.priceAdjustments[0].adjustmentValue.adjustmentPercentage}%
</span>
)}
</label>
))
)}
</div>
);
}
Estrategia de Cache en Oxygen
| Ruta / recurso |
Cache strategy |
TTL |
Justificacion |
| Pagina de producto (PDP) |
CacheLong() |
24h / SWR 1h |
Productos cambian poco; SEO critico |
| Coleccion de cafes |
CacheShort() |
1min / SWR 30s |
Nuevo stock frecuente, agotados |
| Carrito (cart API) |
CacheNone() |
Sin cache |
Estado unico por usuario |
| Selling plans / precios |
CacheShort() |
1min |
Promociones cambian con frecuencia |
| Footer / menus / metaobjects |
CacheLong() |
24h |
Contenido estatico del sitio |
| Customer Account API |
CacheNone() |
Sin cache |
Datos privados del cliente |
Resumen de implementacion
📦 Product Detail Page
- Variantes por URL params (SelectedOptions)
- Componente Money con moneda local
- Image component con lazy loading
- Analytics.ProductView integrado
- Selling Plans para suscripcion mensual
🔄 Infinite Scroll
- Hydrogen Pagination (cursor-based)
- IntersectionObserver para scroll-to-load
- 12 productos por pagina
- CacheShort para stock actualizado
- URL con cursores para compartir estado
📈 Google Tag Manager
- GTM-GRANOSSUR via script nonce CSP
- Analytics.Provider de Hydrogen
- Eventos view_item y add_to_cart
- dataLayer con estructura GA4 ecommerce
- subscribe() hook para eventos custom
🔄 Suscripciones
- Selling Plans API GraphQL
- 15% descuento mensual visible en UI
- URL param selling_plan para SSR
- Toggle compra unica vs suscripcion
- Compatible con Recharge y ReSubscribe