> ## Documentation Index
> Fetch the complete documentation index at: https://docs.replo.app/llms.txt
> Use this file to discover all available pages before exploring further.

# How to Build a Gift Guide

> Gift guides are essential for the holiday season. They help shoppers find the perfect gift by organizing your products into curated collections—by price point, style, popularity, recipient, or occasion.

export const Device = ({prompt, desktopImageSource, mobileImageSource, imageAlt = "Template preview", buttonCta = "Build in Replo"}) => {
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState(null);
  const [isMobile, setIsMobile] = useState(false);
  const [isDragging, setIsDragging] = useState(false);
  const [dragStart, setDragStart] = useState({
    x: 0,
    y: 0
  });
  const [scrollStart, setScrollStart] = useState({
    x: 0,
    y: 0
  });
  const [velocity, setVelocity] = useState({
    x: 0,
    y: 0
  });
  const [lastPos, setLastPos] = useState({
    x: 0,
    y: 0
  });
  const [lastTime, setLastTime] = useState(0);
  const [scrollContainer, setScrollContainer] = useState(null);
  const showToggle = desktopImageSource && mobileImageSource;
  const currentImage = showToggle && isMobile ? mobileImageSource : desktopImageSource;
  const handleMouseDown = (e, container) => {
    setIsDragging(true);
    setDragStart({
      x: e.clientX,
      y: e.clientY
    });
    setScrollStart({
      x: container.scrollLeft,
      y: container.scrollTop
    });
    setLastPos({
      x: e.clientX,
      y: e.clientY
    });
    setLastTime(Date.now());
    setVelocity({
      x: 0,
      y: 0
    });
    setScrollContainer(container);
    e.preventDefault();
  };
  const handleMouseMove = (e, container) => {
    if (!isDragging) return;
    const now = Date.now();
    const dt = now - lastTime;
    if (dt > 0) {
      const dx = e.clientX - dragStart.x;
      const dy = e.clientY - dragStart.y;
      const vx = (e.clientX - lastPos.x) / dt;
      const vy = (e.clientY - lastPos.y) / dt;
      container.scrollLeft = scrollStart.x - dx;
      container.scrollTop = scrollStart.y - dy;
      setVelocity({
        x: vx,
        y: vy
      });
      setLastPos({
        x: e.clientX,
        y: e.clientY
      });
      setLastTime(now);
    }
    e.preventDefault();
  };
  const handleMouseUp = () => {
    setIsDragging(false);
    if (scrollContainer && (Math.abs(velocity.x) > 0.1 || Math.abs(velocity.y) > 0.1)) {
      startMomentumScroll();
    }
  };
  const startMomentumScroll = () => {
    let currentVelocity = {
      ...velocity
    };
    const friction = 0.95;
    const minVelocity = 0.1;
    const animate = () => {
      if (!scrollContainer) return;
      scrollContainer.scrollLeft -= currentVelocity.x * 16;
      scrollContainer.scrollTop -= currentVelocity.y * 16;
      currentVelocity.x *= friction;
      currentVelocity.y *= friction;
      if (Math.abs(currentVelocity.x) > minVelocity || Math.abs(currentVelocity.y) > minVelocity) {
        requestAnimationFrame(animate);
      }
    };
    requestAnimationFrame(animate);
  };
  useEffect(() => {
    const handleGlobalMouseUp = () => {
      setIsDragging(false);
    };
    window.addEventListener("mouseup", handleGlobalMouseUp);
    return () => {
      window.removeEventListener("mouseup", handleGlobalMouseUp);
    };
  }, []);
  const LoaderIcon = <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{
    animation: "spin 1s linear infinite"
  }}>
      <path d="M21 12a9 9 0 1 1-6.219-8.56" />
    </svg>;
  const ChevronIcon = <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
      <path d="m9 18 6-6-6-6" />
    </svg>;
  async function postJSON(url, body, headers = {}, timeoutMs = 120000) {
    const ctrl = new AbortController();
    const t = setTimeout(() => ctrl.abort(), timeoutMs);
    try {
      const res = await fetch(url, {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          ...headers
        },
        body: JSON.stringify(body ?? ({})),
        signal: ctrl.signal
      });
      const text = await res.text();
      if (!res.ok) throw new Error(`API Error: ${res.status}`);
      return text ? JSON.parse(text) : {};
    } finally {
      clearTimeout(t);
    }
  }
  async function handleTryPrompt(event) {
    event.preventDefault();
    event.stopPropagation();
    setIsLoading(true);
    setError(null);
    try {
      const body = {
        prompt,
        file: null
      };
      const {seed} = await postJSON(`${PUBLISHER_API_BASE_URL}/api/v1/marketing/issue-marketing-site-jwt`, body);
      if (!seed) throw new Error("No seed returned from API");
      const url = new URL(APP_SIGNUP_URL);
      url.hash = `seed=${encodeURIComponent(seed)}`;
      window.open(url.toString(), "_blank");
      setIsLoading(false);
    } catch (err) {
      console.error("Failed to generate prompt:", err);
      setError(err.message || "Something went wrong. Please try again.");
      setIsLoading(false);
    }
  }
  return <div className="device-wrapper" style={{
    width: "100%",
    display: "flex",
    flexDirection: "column",
    alignItems: "center",
    position: "relative",
    paddingTop: showToggle ? "16px" : "0"
  }}>
      {}
      {showToggle && <div style={{
    display: "flex",
    gap: "0.5rem",
    alignItems: "center",
    padding: "0.25rem",
    backgroundColor: "#f3f4f6",
    borderRadius: "9999px",
    position: "absolute",
    top: "0",
    left: "50%",
    transform: "translateX(-50%)",
    zIndex: 20
  }}>
          <button onClick={() => setIsMobile(false)} style={{
    padding: "0.375rem 0.75rem",
    fontSize: "0.8125rem",
    fontWeight: "500",
    fontFamily: "Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif",
    border: "none",
    borderRadius: "9999px",
    cursor: "pointer",
    backgroundColor: !isMobile ? "white" : "transparent",
    color: !isMobile ? "#111827" : "#6b7280",
    boxShadow: !isMobile ? "0 1px 3px 0 rgba(0, 0, 0, 0.1)" : "none",
    transition: "all 0.2s ease"
  }}>
            Desktop
          </button>
          <button onClick={() => setIsMobile(true)} style={{
    padding: "0.375rem 0.75rem",
    fontSize: "0.8125rem",
    fontWeight: "500",
    fontFamily: "Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif",
    border: "none",
    borderRadius: "9999px",
    cursor: "pointer",
    backgroundColor: isMobile ? "white" : "transparent",
    color: isMobile ? "#111827" : "#6b7280",
    boxShadow: isMobile ? "0 1px 3px 0 rgba(0, 0, 0, 0.1)" : "none",
    transition: "all 0.2s ease"
  }}>
            Mobile
          </button>
        </div>}

      {}
      <div style={{
    position: "relative",
    display: "flex",
    justifyContent: "center",
    alignItems: "center",
    width: "100%"
  }} onMouseUp={handleMouseUp} onMouseLeave={handleMouseUp}>
        {isMobile && mobileImageSource ? <div className="device device-iphone-14-pro device-mobile-responsive" style={{
    transform: "scale(0.7)",
    transformOrigin: "center center"
  }}>
            <div className="device-frame">
              <div className="device-screen" style={{
    overflow: "auto",
    overflowX: "hidden",
    cursor: isDragging ? "grabbing" : "grab",
    userSelect: "none"
  }} onMouseDown={e => {
    const scrollContainer = e.currentTarget;
    handleMouseDown(e, scrollContainer);
  }} onMouseMove={e => {
    const scrollContainer = e.currentTarget;
    handleMouseMove(e, scrollContainer);
  }}>
                <img src={mobileImageSource} alt={imageAlt} loading="lazy" style={{
    width: "100%",
    height: "auto",
    display: "block",
    pointerEvents: "none"
  }} draggable="false" />
              </div>
            </div>
            <div className="device-stripe"></div>
            <div className="device-header"></div>
            <div className="device-sensors"></div>
            <div className="device-btns"></div>
            <div className="device-power"></div>
            <div className="device-home"></div>
          </div> : desktopImageSource ? <div className="device device-macbook-pro device-desktop-responsive" style={{
    transform: "scale(0.85)",
    transformOrigin: "center center"
  }}>
            <div className="device-frame">
              <div className="device-screen" style={{
    overflow: "auto",
    overflowX: "hidden",
    cursor: isDragging ? "grabbing" : "grab",
    userSelect: "none"
  }} onMouseDown={e => {
    const scrollContainer = e.currentTarget;
    handleMouseDown(e, scrollContainer);
  }} onMouseMove={e => {
    const scrollContainer = e.currentTarget;
    handleMouseMove(e, scrollContainer);
  }}>
                <img src={desktopImageSource} alt={imageAlt} loading="lazy" style={{
    width: "100%",
    height: "auto",
    display: "block",
    pointerEvents: "none"
  }} draggable="false" />
              </div>
            </div>
            <div className="device-stripe"></div>
            <div className="device-header"></div>
            <div className="device-sensors"></div>
            <div className="device-btns"></div>
            <div className="device-power"></div>
            <div className="device-home"></div>
          </div> : null}

        {}
        {currentImage && <div style={{
    position: "absolute",
    top: "50%",
    left: "50%",
    transform: "translate(-50%, -50%)",
    zIndex: 10
  }}>
            <button type="button" onClick={handleTryPrompt} disabled={isLoading} style={{
    backgroundColor: "rgb(39, 74, 226)",
    color: "white",
    padding: "0.75rem 1.5rem",
    fontSize: "1rem",
    fontWeight: "600",
    fontFamily: "Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif",
    border: "none",
    borderRadius: "9999px",
    cursor: isLoading ? "not-allowed" : "pointer",
    textAlign: "center",
    lineHeight: "1.5rem",
    whiteSpace: "nowrap",
    display: "inline-flex",
    alignItems: "center",
    justifyContent: "center",
    WebkitFontSmoothing: "antialiased",
    boxSizing: "border-box",
    opacity: isLoading ? 0.7 : 1,
    transition: "background-color 0.2s ease"
  }} onMouseEnter={e => {
    if (!isLoading) {
      e.currentTarget.style.backgroundColor = "rgb(59, 94, 246)";
    }
  }} onMouseLeave={e => {
    if (!isLoading) {
      e.currentTarget.style.backgroundColor = "rgb(39, 74, 226)";
    }
  }}>
              <span style={{
    visibility: isLoading ? "hidden" : "visible",
    display: "inline-flex",
    alignItems: "center",
    gap: "0.5rem"
  }}>
                {buttonCta}
                {ChevronIcon}
              </span>
              {isLoading && <span style={{
    position: "absolute",
    display: "inline-flex",
    alignItems: "center",
    justifyContent: "center"
  }}>
                  {LoaderIcon}
                </span>}
            </button>
          </div>}
      </div>

      {}
      <style>
        {`
          @keyframes spin {
            from {
              transform: rotate(0deg);
            }
            to {
              transform: rotate(360deg);
            }
          }
          
          .device-screen {
            scrollbar-width: none; /* Firefox */
            -ms-overflow-style: none; /* IE and Edge */
          }
          
          .device-screen::-webkit-scrollbar {
            display: none; /* Chrome, Safari, Opera */
          }

          /* Desktop - compensate for scaling with negative margins */
          .device-mobile-responsive {
            margin-top: -80px;
            margin-bottom: -80px;
          }

          .device-desktop-responsive {
            margin-top: 16px;
            margin-bottom: 16px;
          }

          /* Mobile viewports - simplified with fixed height and clipping */
          @media (max-width: 768px) {
            .device-wrapper {
              overflow: hidden;
            }

            .device-mobile-responsive {
              transform: scale(0.5) !important;
              margin-top: -160px;
              margin-bottom: -160px;
            }

            .device-desktop-responsive {
              transform: scale(0.5) !important;
              margin-top: -60px;
              margin-bottom: -60px;
            }
          }
        `}
      </style>

      {}
      {error && <div style={{
    marginTop: "12px",
    padding: "12px 16px",
    backgroundColor: "#fee",
    border: "1px solid #fcc",
    borderRadius: "6px",
    color: "#c33",
    fontSize: "14px",
    textAlign: "center"
  }} role="alert">
          {error}
        </div>}
    </div>;
};

<Device
  prompt={`Recreate this gift guide for me: https://mejuri.com/guided-shop/gift-guide. Adapt it to my products and brand styles.

Structure:
- Hero section with main heading and CTA
- Product categories organized by price: Under $50, Under $150, Under $300, Splurge Worthy
- Product categories organized by type: Best Sellers, New Arrivals, Gift Sets
- Product showcases with images, product names, prices, and quick add buttons
- Multiple CTAs throughout to drive conversions

Use a clean, editorial layout with generous white space. Products should have clear images and pricing upfront.`}
  mobileImageSource="/images/use-cases/gift-guide/mobile.png"
  desktopImageSource="/images/use-cases/gift-guide/desktop.png"
  buttonCta="Build in Replo"
  imageAlt="Gift Guide Example"
/>

## Step 1: Start Your Gift Guide with a Prompt

### Starter prompt

The more specific you are about your product categories and price points, the better the output.

```
Recreate this gift guide for me: https://mejuri.com/guided-shop/gift-guide. Adapt it to my products and brand styles.

Structure:
- Hero section with main heading and CTA
- Product categories organized by price: Under $50, Under $150, Under $300, Splurge Worthy
- Product categories organized by type: Best Sellers, New Arrivals, Gift Sets
- Product showcases with images, product names, prices, and quick add buttons
- Multiple CTAs throughout to drive conversions

Use a clean, editorial layout with generous white space. Products should have clear images and pricing upfront.
```

## Step 2: Customize Your Copy and Images

Replace the AI-generated content with your actual products and categories. Gift guides perform better when they reflect real inventory and real price points.

<AccordionGroup>
  <Accordion title="Update product categories">
    Replace the AI-generated categories with your actual product collections.

    ```
    Replace the product categories with:

    Category 1: [Gifts Under $X]
    Show products: [Product 1], [Product 2], [Product 3]
    With images: [URL 1], [URL 2], [URL 3]

    Category 2: [Best Sellers]
    Show products: [Product 1], [Product 2], [Product 3]
    With images: [URL 1], [URL 2], [URL 3]

    Category 3: [Gift Sets]
    Show products: [Product 1], [Product 2], [Product 3]
    With images: [URL 1], [URL 2], [URL 3]
    ```
  </Accordion>

  <Accordion title="Add recipient-based categories">
    Gift guides work better when you organize by who the gift is for, not just what it is.

    ```
    Add these recipient categories:

    For Him — Show [Product 1], [Product 2], [Product 3]
    For Her — Show [Product 1], [Product 2], [Product 3]
    For Them — Show [Product 1], [Product 2], [Product 3]

    Each section should have 4-6 products with clear images and pricing.
    ```
  </Accordion>

  <Accordion title="Update price tiers">
    Price tiers help customers self-select based on their budget. This increases conversion because you're not forcing them to filter through irrelevant options.

    ```
    Update the price categories:

    Gifts Under $[X] — Show [number] products in this range
    Gifts Under $[Y] — Show [number] products in this range
    Splurge Worthy ($[Z]+) — Show [number] products in this range

    Make sure actual product prices match the category they're in.
    ```
  </Accordion>
</AccordionGroup>

## Step 3: Personalize the design

The output should use your brand fonts and colors automatically. But you can refine the visual hierarchy to make high-margin products stand out.

<Tabs>
  <Tab title="From a URL">
    Reference any URL to pull design inspiration, whether it's your own site or a competitor's gift guide.

    **How to:**

    ```
    Can you replicate the layout from [input URL] and apply it to my gift guide?

    I like how it uses [large product images, price callouts, editorial sections].
    ```

    This is helpful for quickly adapting proven designs that are already converting.
  </Tab>

  <Tab title="From a Screenshot">
    Drop an image to show the exact visual style you want.

    **How to:**

    ```
    Can you apply the layout from this image to my gift guide?

    I want the same [grid structure, product card style, white space].
    ```

    This works well when you have a Figma design or reference from another brand you want to replicate.
  </Tab>

  <Tab title="From a Prompt">
    Use natural language to describe the visual feel you want. Be specific about design elements.

    **Example:**

    ```
    I want my gift guide to [feel more premium with larger images and cleaner typography].

    Increase product image sizes by 30% and add more vertical spacing between sections.
    ```

    <Tip>
      The more specific you are about design changes, the better the output. Mention exact percentages, colors, spacing, or layout details.
    </Tip>
  </Tab>
</Tabs>

## Step 4: Set Up Your Links

Use [edit mode](https://docs.replo.app/features/edit-mode) to select product cards and CTAs. Once selected:

```
This product card should link to [Product URL].
```

Or to update all product links at once:

```
Update all product cards to link to their respective product pages:
[Product 1] → [URL 1]
[Product 2] → [URL 2]
[Product 3] → [URL 3]
```

For category CTAs:

```
This "Shop All" button should link to [Collection URL].
```

## Step 5: Publish

Once your gift guide is ready, click **Publish** in the top-right corner:

1. **Publish to your Replo domain** — every workspace gets a default domain (e.g. `yourshop.reploshops.com`). Use this for testing or sharing preview links.
2. **Add a custom domain** — connect your own domain to publish the gift guide live under your brand's URL.

[*Read more about Publishing and Custom Domains*](https://docs.replo.app/features/publish)*.*

Click **Publish** again to make your gift guide live.

## Gift Guide Performance Tips

Gift guides drive the most revenue when they're promoted correctly:

* Run paid traffic directly to the gift guide during peak seasons (Q4, Valentine's Day, Mother's Day)
* Use the gift guide URL in email campaigns instead of linking to your homepage
* Test price tier labels — "Gifts Under \$100" often converts better than "Affordable Gifts"
* Add urgency with inventory callouts ("Only 3 left") or shipping deadlines ("Order by Dec 15 for holiday delivery")
* Track AOV by traffic source — gift guide traffic from paid social typically has lower AOV than email traffic
