# Phase 12: Tabs Container (Universal Implementation)

This phase details how to create a highly flexible "Tabs Container" component for the Puck CMS that solves the Next.js React Server Component (RSC) boundary issue. It allows editors to create custom tabs and drop any component inside them.

**Universal Architecture Note:** 
Because we use custom rendering loops instead of Puck's default `<Puck.Render>`, this implementation includes the required recursive rendering logic to properly display dropped zone contents on the live site.

---

## 1. The Editor Client Component

This component runs only when the editor is active (`editMode` is true). It stacks all the tabs vertically so the editor can see all zones and drop components into them simultaneously.

**Create `components/editor/TabsContainer.tsx`:**
```tsx
"use client";

import React, { useState } from 'react';
import { DropZone } from '@puckeditor/core';

export interface TabItem {
  id: string;
  label: string;
}

export interface TabsContainerProps {
  title?: string;
  tabs: TabItem[];
  isEditing?: boolean;
  renderDropZone?: (props: { zone: string }) => React.ReactNode;
}

export function TabsContainer({ title, tabs = [], isEditing = false, renderDropZone }: TabsContainerProps) {
  const [activeTabId, setActiveTabId] = useState<string>(tabs[0]?.id || '');

  React.useEffect(() => {
    if (tabs.length > 0 && !tabs.find(t => t.id === activeTabId)) {
      setActiveTabId(tabs[0].id);
    }
  }, [tabs, activeTabId]);

  return (
    <section className="py-12 bg-transparent">
      <div className="w-full max-w-[1280px] mx-auto px-4 sm:px-6">

        {title && (
          <h2 className="text-2xl md:text-3xl font-bold text-brand-primary mb-8 text-center">
            {title}
          </h2>
        )}

        {tabs.length === 0 ? (
          <div className="p-8 border-2 border-dashed border-gray-300 text-gray-500 rounded-xl text-center bg-gray-50 font-medium">
            Add Tabs from the right panel
          </div>
        ) : isEditing ? (
          /* ── EDITOR MODE: all zones stacked so every tab is droppable ── */
          <div className="flex flex-col gap-6">
            <div className="flex flex-wrap justify-center gap-4">
              {tabs.map((tab, i) => (
                <button
                  key={`editor-btn-${i}`}
                  className="px-8 py-3.5 rounded-full font-bold text-sm md:text-base bg-white text-gray-500 border border-gray-200 cursor-default opacity-70"
                  tabIndex={-1}
                >
                  {tab.label || `Tab ${i + 1}`}
                </button>
              ))}
            </div>

            {tabs.map((tab, i) => (
              <div key={`editor-zone-${i}`}>
                <div style={{
                  display: 'flex', alignItems: 'center', gap: '8px',
                  marginBottom: '6px', padding: '4px 10px',
                  background: '#1e3a5f', borderRadius: '6px',
                  fontSize: '12px', fontWeight: 700, color: '#60a5fa',
                  letterSpacing: '0.04em', width: 'fit-content',
                }}>
                  <span style={{ fontSize: '14px' }}>📂</span>
                  Tab zone: <em style={{ color: '#93c5fd' }}>{tab.label || `Tab ${i + 1}`}</em>
                  <span style={{ color: '#475569', fontWeight: 400 }}>(id: {tab.id || '⚠ set an ID'})</span>
                </div>
                <div style={{
                  border: '2px dashed #cbd5e1',
                  borderRadius: '10px',
                  background: '#f8fafc',
                }}>
                  {/* CRITICAL: The flex & min-h styles ensure the DropZone isn't 0px tall when empty */}
                  <div className="min-h-[80px] flex flex-col [&>div]:flex-1 [&>div]:min-h-[80px]">
                    <DropZone zone={`tab-${i}`} />
                  </div>
                </div>
              </div>
            ))}
          </div>
        ) : (
          /* ── LIVE SITE FALLBACK (Should rarely render if RSC override works) ── */
          <>
            <div className="flex flex-wrap justify-center gap-4 mb-10">
              {tabs.map((tab, i) => {
                const isActive = activeTabId === tab.id;
                return (
                  <button
                    key={`live-btn-${i}`}
                    onClick={() => setActiveTabId(tab.id)}
                    className={`px-8 py-3.5 rounded-full font-bold text-sm md:text-base transition-all duration-300 shadow-sm ${
                      isActive
                        ? 'bg-brand-primary text-white shadow-lg scale-105 border border-brand-primary'
                        : 'bg-white text-gray-600 border border-gray-200 hover:bg-gray-50'
                    }`}
                  >
                    {tab.label}
                  </button>
                );
              })}
            </div>
            <div className="w-full relative">
              {tabs.map((tab, i) => (
                <div
                  key={`zone-wrapper-${i}`}
                  style={{ display: activeTabId === tab.id ? 'block' : 'none' }}
                  className="w-full"
                >
                  <DropZone zone={`tab-${i}`} />
                </div>
              ))}
            </div>
          </>
        )}

      </div>
    </section>
  );
}
```

---

## 2. Live Site Wrapper (Tab Switching)

This is a `"use client"` component that handles the `useState` necessary for clicking tabs. It receives fully-rendered React Elements from the server component as its `children`.

**Create `components/editor/TabsClientWrapper.tsx`:**
```tsx
"use client";

import React, { useState } from 'react';

interface TabItem {
  id: string;
  label: string;
}

interface Props {
  tabs: TabItem[];
  children: React.ReactNode;
}

export function TabsClientWrapper({ tabs, children }: Props) {
  const [activeIndex, setActiveIndex] = useState(0);
  const childrenArray = React.Children.toArray(children);

  return (
    <>
      <div className="flex flex-wrap justify-center gap-4 mb-10">
        {tabs.map((tab, i) => {
          const isActive = i === activeIndex;
          return (
            <button
              key={`tab-btn-${i}`}
              onClick={() => setActiveIndex(i)}
              className={`px-8 py-3.5 rounded-full font-bold text-sm md:text-base transition-all duration-300 shadow-sm ${
                isActive
                  ? 'bg-brand-primary text-white shadow-lg scale-105 border border-brand-primary'
                  : 'bg-white text-gray-600 border border-gray-200 hover:bg-gray-50'
              }`}
            >
              {tab.label || `Tab ${i + 1}`}
            </button>
          );
        })}
      </div>

      <div className="w-full relative">
        {childrenArray.map((child, i) => (
          <div
            key={`zone-panel-${i}`}
            style={{ display: i === activeIndex ? 'block' : 'none' }}
            className="w-full"
          >
            {child}
          </div>
        ))}
      </div>
    </>
  );
}
```

---

## 3. Server Component Renderer

This component strictly runs on the server (RSC). It executes the recursive rendering for zones so that no functions are accidentally passed over the RSC boundary to the client wrapper.

**Create `components/editor/TabsContainerRSC.tsx`:**
```tsx
import React from 'react';
import { TabsClientWrapper } from './TabsClientWrapper';

interface TabItem {
  id: string;
  label: string;
}

interface Props {
  title?: string;
  tabs: TabItem[];
  renderZone?: (props: { zone: string }) => React.ReactNode;
}

export function TabsContainerRSC({ title, tabs = [], renderZone }: Props) {
  const zoneElements = tabs.map((tab, i) => (
    <div key={tab.id || `zone-${i}`} className="w-full">
      {renderZone ? renderZone({ zone: `tab-${i}` }) : null}
    </div>
  ));

  return (
    <section className="py-12 bg-transparent">
      <div className="w-full max-w-[1280px] mx-auto px-4 sm:px-6">
        {title && (
          <h2 className="text-2xl md:text-3xl font-bold text-brand-primary mb-8 text-center">
            {title}
          </h2>
        )}

        {tabs.length === 0 ? (
          <div className="p-8 border-2 border-dashed border-gray-300 text-gray-500 rounded-xl text-center bg-gray-50 font-medium">
            No tabs configured.
          </div>
        ) : (
          <TabsClientWrapper tabs={tabs}>
            {zoneElements}
          </TabsClientWrapper>
        )}
      </div>
    </section>
  );
}
```

---

## 4. CMS Configuration (`lib/puck/config.tsx`)

Register the component inside your global `puckConfig`. 

> [!CAUTION]
> **NEVER use `!!props.editMode` to detect the editor.** Use `window.location.pathname.startsWith('/page_editor_admin')` ONLY. Also, you MUST extract and pass `renderDropZone` before calling `stripPuck()` — this is how the live site renders zone content.

```tsx
import { TabsContainer } from "@/components/editor/TabsContainer";

export const puckConfig: Config<any, any> = {
  // ...
  components: {
    // ...
    SiteTabsContainer: {
      label: "Tabs Container",
      fields: {
        title: { type: "text", label: "Optional Section Title" },
        tabs: {
          type: "array",
          label: "Tabs",
          getItemSummary: (item: any) => item.label || "New Tab",
          arrayFields: {
            id: { type: "text", label: "Unique Tab ID (e.g. tab-1)" },
            label: { type: "text", label: "Tab Label (e.g. Recommended)" }
          }
        }
      },
      defaultProps: {
        title: "Filter Rankings",
        tabs: [
          { id: "tab-1", label: "Tab 1" },
          { id: "tab-2", label: "Tab 2" }
        ]
      },
      render: (props: any) => {
        const inEditor =
          typeof window !== 'undefined' &&
          window.location.pathname.startsWith('/page_editor_admin');
        const { renderDropZone, ...rest } = props;
        return <TabsContainer {...stripPuck(rest)} isEditing={inEditor} renderDropZone={renderDropZone} />;
      },
    },
  }
}
```

---

## 5. Overriding Live Site Rendering (`serverConfig` pattern)

> [!CAUTION]
> **This step is CRITICAL.** Without it, the tab content will appear in the editor but render as EMPTY on the live site. `DropZone` only resolves zone content inside the Puck editor context — on the live site, you must manually read zone data from `puckData.zones` and render items.

Add the `SiteTabsContainer` override inside `serverConfig.components` in **both** `app/page.tsx` (Homepage) and `app/[slug]/page.tsx` (Custom Pages).

You will also need to import `TabsClientWrapper`:
```tsx
import { TabsClientWrapper } from "@/components/editor/TabsClientWrapper";
```

Then add this override inside `serverConfig.components`:
```tsx
SiteTabsContainer: {
  ...puckConfig.components?.SiteTabsContainer,
  render: (props: any) => {
    const componentId = props.id;
    const p = stripPuck(props);
    const tabs = p.tabs || [];
    return (
      <section className="py-12 bg-transparent">
        <div className="w-full max-w-[1280px] mx-auto px-4 sm:px-6">
          {p.title && (
            <h2 className="text-2xl md:text-3xl font-bold text-brand-primary mb-8 text-center">
              {p.title}
            </h2>
          )}
          {tabs.length === 0 ? (
            <div className="p-8 border-2 border-dashed border-gray-300 text-gray-500 rounded-xl text-center bg-gray-50 font-medium">
              No tabs configured.
            </div>
          ) : (
            <TabsClientWrapper tabs={tabs}>
              {tabs.map((tab: any, i: number) => {
                const zoneKey = `${componentId}:tab-${i}`;
                const zoneItems = enrichedPuckData?.zones?.[zoneKey] || [];
                return (
                  <div key={tab.id || `zone-${i}`} className="w-full">
                    {zoneItems.map((item: any, idx: number) => {
                      const itemId = item.props?.id || `zone-item-${idx}`;
                      const comp = serverConfig.components[item.type];
                      if (comp?.render) {
                        return <React.Fragment key={itemId}>{comp.render({ ...comp.defaultProps, ...item.props })}</React.Fragment>;
                      }
                      return null;
                    })}
                  </div>
                );
              })}
            </TabsClientWrapper>
          )}
        </div>
      </section>
    );
  },
},
```

> [!NOTE]
> **How this works:** The `enrichedPuckData.zones` object contains all zone data with keys like `SiteTabsContainer-abc123:tab-0`. The override reads these zone items and renders each one by calling `serverConfig.components[item.type].render()`. The rendered elements are passed as children to `TabsClientWrapper`, which handles client-side tab switching via `useState`.
>
> **Why `DropZone` doesn't work here:** `DropZone` from `@puckeditor/core` requires the Puck editor context (or `<Render>` context) to resolve zone content. On the live site, the server-rendered `serverConfig` override bypasses this context, so zone content must be resolved manually from `puckData.zones`.


---

## ? Phase 12 Completion Verification

Run this checklist to confirm Phase 12 is fully implemented:

- [ ] `npx tsc --noEmit` passes with 0 errors
- [ ] `TabsContainer.tsx` created in `components/editor/` (editor + live DropZone component)
- [ ] `TabsClientWrapper.tsx` created in `components/editor/` (client-side tab switching)
- [ ] `TabsContainerRSC.tsx` created in `components/editor/` (RSC server component)
- [ ] "Tabs Container" appears in the "Layout Utilities" category in Puck sidebar
- [ ] Drag onto canvas ? editor shows stacked zones with labels (?? Tab zone: Tab 1, Tab 2, etc.)
- [ ] Drop components (e.g. RankedTopList) into each tab zone in the editor
- [ ] Editor detection uses `window.location.pathname.startsWith('/page_editor_admin')` � NOT `!!props.editMode`
- [ ] `renderDropZone` is extracted before `stripPuck()` and passed to `TabsContainer`
- [ ] `SiteTabsContainer` server override added to `serverConfig.components` in BOTH `app/page.tsx` AND `app/[slug]/page.tsx`
- [ ] Override manually reads `enrichedPuckData.zones[zoneKey]` and renders items via `serverConfig.components[item.type].render()`
- [ ] Publish ? live site ? tab buttons appear ? clicking switches content panels
- [ ] Tab content is NOT empty on the live site (the zone rendering override is working)

> **Phase 12 is complete when ALL boxes above are checked.**
