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

# Build a Gift Guide

> Build a curated gift guide that helps holiday shoppers pick fast.

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 API_BASE = "https://api.replo.app";
  const APP_SIGNUP_URL = "https://dashboard.replo.app/auth/signup";
  const showToggle = desktopImageSource && mobileImageSource;
  const currentImage = showToggle && isMobile ? mobileImageSource : desktopImageSource;
  const handleMouseDown = (event, container) => {
    setIsDragging(true);
    setDragStart({
      x: event.clientX,
      y: event.clientY
    });
    setScrollStart({
      x: container.scrollLeft,
      y: container.scrollTop
    });
    setLastPos({
      x: event.clientX,
      y: event.clientY
    });
    setLastTime(Date.now());
    setVelocity({
      x: 0,
      y: 0
    });
    setScrollContainer(container);
    event.preventDefault();
  };
  const handleMouseMove = (event, container) => {
    if (!isDragging) return;
    const now = Date.now();
    const elapsedMs = now - lastTime;
    if (elapsedMs > 0) {
      const deltaX = event.clientX - dragStart.x;
      const deltaY = event.clientY - dragStart.y;
      const velocityX = (event.clientX - lastPos.x) / elapsedMs;
      const velocityY = (event.clientY - lastPos.y) / elapsedMs;
      container.scrollLeft = scrollStart.x - deltaX;
      container.scrollTop = scrollStart.y - deltaY;
      setVelocity({
        x: velocityX,
        y: velocityY
      });
      setLastPos({
        x: event.clientX,
        y: event.clientY
      });
      setLastTime(now);
    }
    event.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 controller = new AbortController();
    const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
    try {
      const response = await fetch(url, {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          ...headers
        },
        body: JSON.stringify(body ?? ({})),
        signal: controller.signal
      });
      const responseText = await response.text();
      if (!response.ok) throw new Error(`API Error: ${response.status}`);
      return responseText ? JSON.parse(responseText) : {};
    } finally {
      clearTimeout(timeoutId);
    }
  }
  async function handleTryPrompt(event) {
    event.preventDefault();
    event.stopPropagation();
    setIsLoading(true);
    setError(null);
    try {
      const body = {
        prompt,
        file: null
      };
      const {seed} = await postJSON(`${API_BASE}/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 (caughtError) {
      console.error("Failed to generate prompt:", caughtError);
      const message = caughtError instanceof Error ? caughtError.message : null;
      setError(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={event => {
    handleMouseDown(event, event.currentTarget);
  }} onMouseMove={event => {
    handleMouseMove(event, event.currentTarget);
  }}>
                <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={event => {
    handleMouseDown(event, event.currentTarget);
  }} onMouseMove={event => {
    handleMouseMove(event, event.currentTarget);
  }}>
                <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={event => {
    if (!isLoading) {
      event.currentTarget.style.backgroundColor = "rgb(59, 94, 246)";
    }
  }} onMouseLeave={event => {
    if (!isLoading) {
      event.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 [select mode](/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.replosites.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*](/publishing)*.*

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
