Granos del Sur — Shopify Hydrogen

Headless storefront implementation · Generado por CULTIVA IA / skill shopify-hydrogen
Hydrogen v2024.10 React Router v7 TypeScript estricto Shopify Oxygen
📦
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
Oxygen Worker
Edge SSR
📌
React Router
Loader / Action
🛒
Storefront API
GraphQL 2024-10
🗄
Edge Cache
CacheLong/Short

1. Product Detail Page (PDP)

routes/products.$handle.tsx
app/routes/products.$handle.tsx 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
app/routes/collections.$handle.tsx 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
app/root.tsx (fragmento GTM) 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
app/components/SellingPlanSelector.tsx 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