# Quick Links Editor Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Allow dealers (Growth+) to add up to 10 custom links displayed in a two-column layout below their dealer info card.

**Architecture:** New `DealerLink` table with flat list storage, dnd-kit sortable editor with live preview, CSS column-count for auto-balanced two-column display.

**Tech Stack:** Prisma, Next.js 14 App Router, dnd-kit/sortable, Tailwind CSS, Zod validation

---

## Task 1: Add DealerLink Model to Prisma Schema

**Files:**

- Modify: `prisma/schema.prisma`

**Step 1: Add DealerLink model**

Add after the `NavigationItem` model (around line 223):

```prisma
// Quick Links - up to 10 custom links displayed on dealer page (Growth+ tiers)
model DealerLink {
  id           String   @id @default(cuid())
  dealerId     String

  label        String   @db.VarChar(50)
  linkType     String   @db.VarChar(20)  // "page" | "external"
  pageId       String?
  externalUrl  String?  @db.VarChar(500)
  openInNewTab Boolean  @default(true)

  sortOrder    Int      @default(0)

  createdAt    DateTime @default(now())
  updatedAt    DateTime @updatedAt

  // Relations
  dealer       Dealer   @relation(fields: [dealerId], references: [id], onDelete: Cascade)
  page         Page?    @relation(fields: [pageId], references: [id], onDelete: SetNull)

  @@index([dealerId, sortOrder])
}
```

**Step 2: Add relations to Dealer and Page models**

In the `Dealer` model, add after `navigationItems NavigationItem[]` (around line 117):

```prisma
  dealerLinks     DealerLink[]
```

In the `Page` model, add after `navigationItems NavigationItem[]` (around line 179):

```prisma
  dealerLinks     DealerLink[]
```

**Step 3: Generate Prisma client and sync database**

Run: `npm run sync-db`

Expected: Schema synced successfully, `DealerLink` table created.

**Step 4: Commit**

```bash
git add prisma/schema.prisma
git commit -m "feat(db): add DealerLink model for quick links feature"
```

---

## Task 2: Add Tier Access Function

**Files:**

- Modify: `lib/cms/types.ts`

**Step 1: Add LINK_EDITOR_TIERS constant**

After line 27 (after LEAD_FORM_TIERS), add:

```typescript
// Link editor access (Growth+)
export const LINK_EDITOR_TIERS = ['growth', 'enhanced', 'professional'] as const;
```

**Step 2: Add LinkEditorTier type**

After line 32 (after LeadFormTier), add:

```typescript
export type LinkEditorTier = (typeof LINK_EDITOR_TIERS)[number];
```

**Step 3: Add hasLinkEditorAccess function**

After the `hasLeadFormAccess` function (around line 297), add:

```typescript
/**
 * Checks if a dealer has link editor access based on their tier
 */
export function hasLinkEditorAccess(subscriptionTier: string): boolean {
  return (LINK_EDITOR_TIERS as readonly string[]).includes(subscriptionTier);
}
```

**Step 4: Commit**

```bash
git add lib/cms/types.ts
git commit -m "feat(cms): add link editor tier access function (Growth+)"
```

---

## Task 3: Create Link Validation Module

**Files:**

- Create: `lib/cms/link-validation.ts`

**Step 1: Create the validation file**

```typescript
/**
 * Link Validation
 *
 * Validates dealer link structure and constraints.
 * Simpler than navigation validation - flat list, no hierarchy.
 */

import { z } from 'zod';
import xss from 'xss';

/**
 * Maximum limits for dealer links
 */
export const MAX_DEALER_LINKS = 10;
export const MAX_LABEL_LENGTH = 50;
export const MAX_URL_LENGTH = 500;

/**
 * Link types
 */
export type LinkType = 'page' | 'external';

/**
 * Dealer link data for validation
 */
export interface DealerLinkData {
  id: string;
  label: string;
  linkType: LinkType;
  pageId: string | null;
  externalUrl: string | null;
  openInNewTab: boolean;
  sortOrder: number;
}

/**
 * Validation result
 */
export interface LinkValidationResult {
  valid: boolean;
  error?: string;
}

/**
 * XSS-safe string sanitizer for labels
 */
const sanitizeLabel = (label: string): string => {
  return xss(label.trim(), {
    whiteList: {},
    stripIgnoreTag: true,
    stripIgnoreTagBody: ['script'],
  });
};

/**
 * Zod schema for a single dealer link (for API validation)
 */
export const DealerLinkSchema = z.object({
  id: z.string().min(1),
  label: z
    .string()
    .min(1, 'Label is required')
    .max(MAX_LABEL_LENGTH, `Label must be ${MAX_LABEL_LENGTH} characters or less`)
    .transform(sanitizeLabel),
  linkType: z.enum(['page', 'external']),
  pageId: z.string().nullable(),
  externalUrl: z
    .string()
    .max(MAX_URL_LENGTH)
    .nullable()
    .refine(
      (url) => {
        if (!url) return true;
        try {
          new URL(url);
          return true;
        } catch {
          // Allow relative URLs starting with / or #
          return url.startsWith('/') || url.startsWith('#');
        }
      },
      { message: 'Invalid URL format' }
    ),
  openInNewTab: z.boolean().default(true),
  sortOrder: z.number().int().min(0),
});

/**
 * Zod schema for bulk link update (array of links)
 */
export const BulkLinksUpdateSchema = z
  .array(DealerLinkSchema)
  .max(MAX_DEALER_LINKS, `Maximum ${MAX_DEALER_LINKS} links allowed`);

/**
 * Validates link structure
 */
export function validateLinks(links: DealerLinkData[]): LinkValidationResult {
  // Check max links
  if (links.length > MAX_DEALER_LINKS) {
    return {
      valid: false,
      error: `Maximum ${MAX_DEALER_LINKS} links allowed`,
    };
  }

  // Validate each link
  for (const link of links) {
    // Check label
    if (!link.label || link.label.trim() === '') {
      return {
        valid: false,
        error: `Link "${link.id}" has an empty label`,
      };
    }
    if (link.label.length > MAX_LABEL_LENGTH) {
      return {
        valid: false,
        error: `Link "${link.id}" label exceeds ${MAX_LABEL_LENGTH} characters`,
      };
    }

    // Check link type constraints
    if (link.linkType === 'page' && !link.pageId) {
      return {
        valid: false,
        error: `Link "${link.label}" is set to page type but has no page selected`,
      };
    }
    if (link.linkType === 'external' && !link.externalUrl) {
      return {
        valid: false,
        error: `Link "${link.label}" is set to external type but has no URL`,
      };
    }

    // Check for both pageId and externalUrl (should be mutually exclusive)
    if (link.pageId && link.externalUrl) {
      return {
        valid: false,
        error: `Link "${link.label}" cannot have both a page and external URL`,
      };
    }
  }

  return { valid: true };
}

/**
 * Check for duplicate URLs (warning, not error)
 */
export function checkDuplicateUrls(links: DealerLinkData[]): string[] {
  const urls = links.filter((l) => l.externalUrl).map((l) => l.externalUrl!.toLowerCase());

  const seen = new Set<string>();
  const duplicates: string[] = [];

  for (const url of urls) {
    if (seen.has(url)) {
      duplicates.push(url);
    }
    seen.add(url);
  }

  return duplicates;
}
```

**Step 2: Commit**

```bash
git add lib/cms/link-validation.ts
git commit -m "feat(cms): add link validation module"
```

---

## Task 4: Create Links API Route

**Files:**

- Create: `app/api/cms/links/route.ts`

**Step 1: Create the API route**

```typescript
/**
 * CMS Links API - List and Bulk Update Dealer Links
 *
 * GET /api/cms/links - List all links for authenticated dealer
 * PUT /api/cms/links - Bulk update links (replace entire list)
 *
 * Growth tier and above only.
 */

import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { Prisma } from '@prisma/client';
import { hasLinkEditorAccess } from '@/lib/cms/types';
import { BulkLinksUpdateSchema, validateLinks, DealerLinkData } from '@/lib/cms/link-validation';
import { ZodError } from 'zod';
import { triggerISRRevalidation } from '@/lib/isr-revalidation';

/**
 * Helper to get authenticated dealer with link editor access
 */
async function getAuthenticatedDealer(userId: string) {
  const dealer = await prisma.dealer.findUnique({
    where: { userId },
    select: {
      id: true,
      subscriptionTier: true,
      subdomain: true,
    },
  });

  if (!dealer) {
    return null;
  }

  if (!hasLinkEditorAccess(dealer.subscriptionTier)) {
    return { ...dealer, accessDenied: true };
  }

  return dealer;
}

/**
 * GET /api/cms/links
 * List all links for the authenticated dealer
 */
export async function GET() {
  try {
    const session = await getServerSession(authOptions);

    if (!session?.user?.id) {
      return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
    }

    const dealer = await getAuthenticatedDealer(session.user.id);

    if (!dealer) {
      return NextResponse.json({ error: 'Dealer not found' }, { status: 404 });
    }

    if ('accessDenied' in dealer && dealer.accessDenied) {
      return NextResponse.json(
        { error: 'Growth tier or above required for link editor' },
        { status: 403 }
      );
    }

    // Get all links ordered by sortOrder
    const links = await prisma.dealerLink.findMany({
      where: { dealerId: dealer.id },
      include: {
        page: {
          select: { slug: true, title: true, type: true },
        },
      },
      orderBy: { sortOrder: 'asc' },
    });

    return NextResponse.json({
      links: links.map((link) => ({
        id: link.id,
        label: link.label,
        linkType: link.linkType,
        pageId: link.pageId,
        pageSlug: link.page?.slug ?? null,
        pageTitle: link.page?.title ?? null,
        externalUrl: link.externalUrl,
        openInNewTab: link.openInNewTab,
        sortOrder: link.sortOrder,
      })),
      dealer: { id: dealer.id, tier: dealer.subscriptionTier },
    });
  } catch (error) {
    console.error('Error listing links:', error);
    return NextResponse.json({ error: 'Failed to list links' }, { status: 500 });
  }
}

/**
 * PUT /api/cms/links
 * Bulk update links (replace entire list)
 */
export async function PUT(request: NextRequest) {
  try {
    const session = await getServerSession(authOptions);

    if (!session?.user?.id) {
      return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
    }

    const dealer = await getAuthenticatedDealer(session.user.id);

    if (!dealer) {
      return NextResponse.json({ error: 'Dealer not found' }, { status: 404 });
    }

    if ('accessDenied' in dealer && dealer.accessDenied) {
      return NextResponse.json(
        { error: 'Growth tier or above required for link editor' },
        { status: 403 }
      );
    }

    // Parse request body
    let body: { links?: DealerLinkData[]; publish?: boolean };
    try {
      body = await request.json();
    } catch {
      return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
    }

    if (!body.links) {
      return NextResponse.json({ error: 'Missing links in request body' }, { status: 400 });
    }

    // Validate with Zod (includes XSS sanitization)
    let sanitizedLinks: DealerLinkData[];
    try {
      sanitizedLinks = BulkLinksUpdateSchema.parse(body.links);
    } catch (error) {
      if (error instanceof ZodError) {
        const errorMessages = error.issues.map(
          (issue) => `${issue.path.join('.')}: ${issue.message}`
        );
        return NextResponse.json(
          { error: `Validation failed: ${errorMessages.join(', ')}` },
          { status: 400 }
        );
      }
      throw error;
    }

    // Additional structure validation
    const validation = validateLinks(sanitizedLinks);
    if (!validation.valid) {
      return NextResponse.json({ error: validation.error }, { status: 400 });
    }

    // Validate page ownership for page-type links
    const pageIds = sanitizedLinks
      .filter((l) => l.linkType === 'page' && l.pageId)
      .map((l) => l.pageId!);

    if (pageIds.length > 0) {
      const pages = await prisma.page.findMany({
        where: { id: { in: pageIds }, dealerId: dealer.id },
        select: { id: true },
      });
      const validPageIds = new Set(pages.map((p) => p.id));

      for (const link of sanitizedLinks) {
        if (link.linkType === 'page' && link.pageId && !validPageIds.has(link.pageId)) {
          return NextResponse.json(
            { error: `Page not found or not owned by dealer: ${link.pageId}` },
            { status: 400 }
          );
        }
      }
    }

    const newIds = new Set(sanitizedLinks.map((l) => l.id));

    // Perform updates in a transaction
    await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
      // Get existing links
      const existingLinks = await tx.dealerLink.findMany({
        where: { dealerId: dealer.id },
        select: { id: true },
      });

      const existingIds = new Set(existingLinks.map((l) => l.id));

      // Delete links that were removed
      const toDelete = existingLinks.filter((l) => !newIds.has(l.id)).map((l) => l.id);

      if (toDelete.length > 0) {
        await tx.dealerLink.deleteMany({
          where: {
            id: { in: toDelete },
            dealerId: dealer.id,
          },
        });
      }

      // Upsert all links
      for (const link of sanitizedLinks) {
        const isExisting = existingIds.has(link.id);

        if (isExisting) {
          await tx.dealerLink.update({
            where: { id: link.id },
            data: {
              label: link.label,
              linkType: link.linkType,
              pageId: link.linkType === 'page' ? link.pageId : null,
              externalUrl: link.linkType === 'external' ? link.externalUrl : null,
              openInNewTab: link.openInNewTab,
              sortOrder: link.sortOrder,
            },
          });
        } else {
          await tx.dealerLink.create({
            data: {
              id: link.id,
              dealerId: dealer.id,
              label: link.label,
              linkType: link.linkType,
              pageId: link.linkType === 'page' ? link.pageId : null,
              externalUrl: link.linkType === 'external' ? link.externalUrl : null,
              openInNewTab: link.openInNewTab,
              sortOrder: link.sortOrder,
            },
          });
        }
      }
    });

    // Trigger ISR revalidation if publishing
    if (body.publish && dealer.subdomain) {
      await triggerISRRevalidation(dealer.subdomain);
    }

    return NextResponse.json({
      message: 'Links updated successfully',
      published: body.publish ?? false,
    });
  } catch (error) {
    console.error('Error updating links:', error);
    return NextResponse.json({ error: 'Failed to update links' }, { status: 500 });
  }
}
```

**Step 2: Commit**

```bash
git add app/api/cms/links/route.ts
git commit -m "feat(api): add links API route (GET/PUT)"
```

---

## Task 5: Add QuickLink Type to Dealer Types

**Files:**

- Modify: `types/dealer.ts`

**Step 1: Add QuickLink interface**

After the `NavigationLink` interface (around line 15), add:

```typescript
export interface QuickLink {
  label: string;
  href: string;
  openInNewTab: boolean;
}
```

**Step 2: Add quickLinks to DealerInfo interface**

In the `DealerInfo` interface, after `heroSlider` (around line 31), add:

```typescript
  quickLinks?: QuickLink[]; // Quick links displayed below dealer info
```

**Step 3: Commit**

```bash
git add types/dealer.ts
git commit -m "feat(types): add QuickLink type to dealer types"
```

---

## Task 6: Create QuickLinks Display Component

**Files:**

- Create: `components/QuickLinks.tsx`

**Step 1: Create the component**

```typescript
/**
 * QuickLinks Component
 *
 * Displays dealer's custom quick links in a two-column auto-balanced layout.
 * Uses CSS column-count for automatic balancing.
 */

import { QuickLink } from '@/types/dealer';

interface QuickLinksProps {
  links: QuickLink[];
}

export function QuickLinks({ links }: QuickLinksProps) {
  if (!links || links.length === 0) {
    return null;
  }

  return (
    <section className="quick-links-section">
      <h2 className="quick-links-heading">Quick Links</h2>
      <ul className="quick-links-grid">
        {links.map((link, index) => (
          <li key={index} className="quick-links-item">
            <a
              href={link.href}
              target={link.openInNewTab ? '_blank' : undefined}
              rel={link.openInNewTab ? 'noopener noreferrer' : undefined}
              className="quick-links-link"
            >
              {link.label}
            </a>
          </li>
        ))}
      </ul>
    </section>
  );
}
```

**Step 2: Add styles to dealer-template.css**

In `app/dealer-template.css`, add at the end:

```css
/* Quick Links Section */
.quick-links-section {
  margin-top: 2rem;
  padding: 1.5rem;
  background: rgba(255, 255, 255, 0.05);
  border-radius: 0.5rem;
}

.quick-links-heading {
  font-size: 1.25rem;
  font-weight: 600;
  color: white;
  margin-bottom: 1rem;
  text-align: center;
}

.quick-links-grid {
  column-count: 2;
  column-fill: balance;
  column-gap: 2rem;
  list-style: none;
  padding: 0;
  margin: 0;
}

.quick-links-item {
  break-inside: avoid;
  padding: 0.5rem 0;
}

.quick-links-link {
  color: #22d3ee;
  text-decoration: none;
  display: flex;
  align-items: center;
  gap: 0.5rem;
  transition: color 0.2s;
}

.quick-links-link::before {
  content: '•';
  color: #22d3ee;
}

.quick-links-link:hover {
  color: #67e8f9;
  text-decoration: underline;
}

/* Mobile: single column */
@media (max-width: 640px) {
  .quick-links-grid {
    column-count: 1;
  }
}
```

**Step 3: Commit**

```bash
git add components/QuickLinks.tsx app/dealer-template.css
git commit -m "feat(ui): add QuickLinks display component with CSS columns"
```

---

## Task 7: Integrate QuickLinks into Dealer Page

**Files:**

- Modify: `app/dealers/[subdomain]/page.tsx`
- Modify: `components/DealerTemplate.tsx`

**Step 1: Fetch quick links in dealer page**

In `app/dealers/[subdomain]/page.tsx`, after fetching navigation items (around line 153), add:

```typescript
// Fetch quick links for this dealer
const quickLinks = await prisma.dealerLink.findMany({
  where: { dealerId: dealer.id },
  orderBy: { sortOrder: 'asc' },
  include: {
    page: { select: { slug: true, type: true } },
  },
});
```

**Step 2: Transform quick links**

After the navigation transformation (around line 167), add:

```typescript
// Transform quick links
const transformedQuickLinks = quickLinks.map((link) => ({
  label: link.label,
  href: link.externalUrl || (link.page ? getPagePath(link.page) : '#'),
  openInNewTab: link.openInNewTab,
}));
```

**Step 3: Update transformToTemplateProps call**

Update the function call (around line 170) to pass quickLinks:

```typescript
const dealerInfo = transformToTemplateProps(dealer, navigation, transformedQuickLinks);
```

**Step 4: Update transformToTemplateProps function signature**

Update the function (around line 73) to accept quickLinks:

```typescript
function transformToTemplateProps(
  dealer: {
    subdomain: string | null;
    businessName: string | null;
    phone: string;
    address: string;
    city: string;
    state: string;
    zip: string;
    country: string;
    heroSlider: any;
    user: { email: string } | null;
  },
  navigation: NavigationLink[],
  quickLinks: QuickLink[]
): DealerInfo {
```

And add quickLinks to the return object (around line 101):

```typescript
    quickLinks,
```

**Step 5: Update DealerTemplate to render QuickLinks**

In `components/DealerTemplate.tsx`, import QuickLinks and add it after the dealer info card section.

First, add the import at the top:

```typescript
import { QuickLinks } from './QuickLinks';
```

Then find the section after the dealer contact info (the "Not Sure Which Product..." section) and add after it:

```typescript
{dealer.quickLinks && dealer.quickLinks.length > 0 && (
  <QuickLinks links={dealer.quickLinks} />
)}
```

**Step 6: Update types import**

In `app/dealers/[subdomain]/page.tsx`, update the import to include QuickLink:

```typescript
import { DealerInfo, NavigationLink, QuickLink } from '@/types/dealer';
```

**Step 7: Commit**

```bash
git add app/dealers/[subdomain]/page.tsx components/DealerTemplate.tsx
git commit -m "feat(dealer-page): integrate quick links into dealer page"
```

---

## Task 8: Create Link Editor Page

**Files:**

- Create: `app/dashboard/cms/links/page.tsx`

**Step 1: Create the editor page**

```typescript
/**
 * Link Editor Page
 *
 * Allows dealers (Growth+) to manage quick links displayed on their dealer page.
 * Features drag-and-drop reordering with live preview.
 */

'use client';

import { useState, useEffect, useCallback } from 'react';
import { useSession } from 'next-auth/react';
import { useRouter } from 'next/navigation';
import dynamic from 'next/dynamic';

// Dynamically import the editor component to avoid SSR issues with dnd-kit
const LinkEditorContent = dynamic(() => import('@/components/cms/LinkEditorContent'), {
  ssr: false,
  loading: () => (
    <div className="flex items-center justify-center min-h-[400px]">
      <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-cyan-500"></div>
    </div>
  ),
});

export default function LinkEditorPage() {
  const { data: session, status } = useSession();
  const router = useRouter();
  const [accessDenied, setAccessDenied] = useState(false);

  useEffect(() => {
    if (status === 'unauthenticated') {
      router.push('/api/auth/signin');
    }
  }, [status, router]);

  if (status === 'loading') {
    return (
      <div className="flex items-center justify-center min-h-screen">
        <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-cyan-500"></div>
      </div>
    );
  }

  if (accessDenied) {
    return (
      <div className="min-h-screen bg-gray-900 text-white p-8">
        <div className="max-w-2xl mx-auto text-center">
          <h1 className="text-2xl font-bold mb-4">Upgrade Required</h1>
          <p className="text-gray-400 mb-6">
            The Link Editor is available for Growth tier and above.
          </p>
          <button
            onClick={() => router.push('/dashboard')}
            className="px-4 py-2 bg-cyan-600 hover:bg-cyan-700 rounded-lg"
          >
            Back to Dashboard
          </button>
        </div>
      </div>
    );
  }

  return (
    <div className="min-h-screen bg-gray-900 text-white">
      <LinkEditorContent onAccessDenied={() => setAccessDenied(true)} />
    </div>
  );
}
```

**Step 2: Commit**

```bash
git add app/dashboard/cms/links/page.tsx
git commit -m "feat(dashboard): add link editor page shell"
```

---

## Task 9: Create LinkEditorContent Component

**Files:**

- Create: `components/cms/LinkEditorContent.tsx`

**Step 1: Create the editor content component**

```typescript
/**
 * LinkEditorContent Component
 *
 * Main content area for the link editor. Handles data fetching,
 * drag-and-drop, and save operations.
 */

'use client';

import { useState, useEffect, useCallback, useMemo } from 'react';
import {
  DndContext,
  closestCenter,
  KeyboardSensor,
  PointerSensor,
  useSensor,
  useSensors,
  DragEndEvent,
} from '@dnd-kit/core';
import {
  arrayMove,
  SortableContext,
  sortableKeyboardCoordinates,
  verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { SortableLinkItem } from './SortableLinkItem';
import { LinkEditModal } from './LinkEditModal';
import { LinkPreview } from './LinkPreview';

export interface LinkItem {
  id: string;
  label: string;
  linkType: 'page' | 'external';
  pageId: string | null;
  pageSlug: string | null;
  pageTitle: string | null;
  externalUrl: string | null;
  openInNewTab: boolean;
  sortOrder: number;
}

interface PageOption {
  id: string;
  slug: string;
  title: string;
}

interface LinkEditorContentProps {
  onAccessDenied: () => void;
}

export default function LinkEditorContent({ onAccessDenied }: LinkEditorContentProps) {
  const [links, setLinks] = useState<LinkItem[]>([]);
  const [pages, setPages] = useState<PageOption[]>([]);
  const [loading, setLoading] = useState(true);
  const [saving, setSaving] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [editingLink, setEditingLink] = useState<LinkItem | null>(null);
  const [lastSaved, setLastSaved] = useState<Date | null>(null);

  const sensors = useSensors(
    useSensor(PointerSensor, {
      activationConstraint: { distance: 5 },
    }),
    useSensor(KeyboardSensor, {
      coordinateGetter: sortableKeyboardCoordinates,
    })
  );

  // Fetch links and pages on mount
  useEffect(() => {
    async function fetchData() {
      try {
        const [linksRes, pagesRes] = await Promise.all([
          fetch('/api/cms/links'),
          fetch('/api/cms/pages?type=page'),
        ]);

        if (linksRes.status === 403) {
          onAccessDenied();
          return;
        }

        if (!linksRes.ok) {
          throw new Error('Failed to fetch links');
        }

        const linksData = await linksRes.json();
        setLinks(linksData.links || []);

        if (pagesRes.ok) {
          const pagesData = await pagesRes.json();
          setPages(pagesData.pages || []);
        }
      } catch (err) {
        setError(err instanceof Error ? err.message : 'Failed to load data');
      } finally {
        setLoading(false);
      }
    }

    fetchData();
  }, [onAccessDenied]);

  // Handle drag end
  const handleDragEnd = useCallback((event: DragEndEvent) => {
    const { active, over } = event;

    if (over && active.id !== over.id) {
      setLinks((items) => {
        const oldIndex = items.findIndex((i) => i.id === active.id);
        const newIndex = items.findIndex((i) => i.id === over.id);
        const newItems = arrayMove(items, oldIndex, newIndex);
        // Update sortOrder
        return newItems.map((item, index) => ({ ...item, sortOrder: index }));
      });
    }
  }, []);

  // Move link up/down (accessibility)
  const moveLink = useCallback((id: string, direction: 'up' | 'down') => {
    setLinks((items) => {
      const index = items.findIndex((i) => i.id === id);
      if (index === -1) return items;

      const newIndex = direction === 'up' ? index - 1 : index + 1;
      if (newIndex < 0 || newIndex >= items.length) return items;

      const newItems = arrayMove(items, index, newIndex);
      return newItems.map((item, idx) => ({ ...item, sortOrder: idx }));
    });
  }, []);

  // Add new link
  const handleAddLink = useCallback(() => {
    if (links.length >= 10) {
      setError('Maximum 10 links allowed');
      return;
    }

    const newLink: LinkItem = {
      id: `link-${crypto.randomUUID()}`,
      label: 'New Link',
      linkType: 'external',
      pageId: null,
      pageSlug: null,
      pageTitle: null,
      externalUrl: '',
      openInNewTab: true,
      sortOrder: links.length,
    };

    setLinks((prev) => [...prev, newLink]);
    setEditingLink(newLink);
  }, [links.length]);

  // Delete link
  const handleDeleteLink = useCallback((id: string) => {
    setLinks((items) => {
      const newItems = items.filter((i) => i.id !== id);
      return newItems.map((item, index) => ({ ...item, sortOrder: index }));
    });
  }, []);

  // Save edited link
  const handleSaveEdit = useCallback((updatedLink: LinkItem) => {
    setLinks((items) =>
      items.map((item) => (item.id === updatedLink.id ? updatedLink : item))
    );
    setEditingLink(null);
  }, []);

  // Save to server
  const handleSave = useCallback(async (publish: boolean = false) => {
    setSaving(true);
    setError(null);

    try {
      const response = await fetch('/api/cms/links', {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ links, publish }),
      });

      if (!response.ok) {
        const data = await response.json();
        throw new Error(data.error || 'Failed to save links');
      }

      setLastSaved(new Date());
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Failed to save');
    } finally {
      setSaving(false);
    }
  }, [links]);

  // Column distribution for display
  const distribution = useMemo(() => {
    const total = links.length;
    const left = Math.ceil(total / 2);
    const right = total - left;
    return `${left} | ${right}`;
  }, [links.length]);

  if (loading) {
    return (
      <div className="flex items-center justify-center min-h-[400px]">
        <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-cyan-500"></div>
      </div>
    );
  }

  return (
    <div className="max-w-6xl mx-auto p-6">
      {/* Header */}
      <div className="flex items-center justify-between mb-6">
        <div>
          <h1 className="text-2xl font-bold">Quick Links</h1>
          <p className="text-gray-400 mt-1">
            Manage links displayed on your dealer page
          </p>
        </div>
        <div className="flex items-center gap-3">
          {lastSaved && (
            <span className="text-sm text-gray-500">
              Last saved: {lastSaved.toLocaleTimeString()}
            </span>
          )}
          <button
            onClick={() => handleSave(true)}
            disabled={saving}
            className="px-4 py-2 bg-cyan-600 hover:bg-cyan-700 disabled:bg-gray-600 rounded-lg font-medium transition-colors"
          >
            {saving ? 'Saving...' : 'Save & Publish'}
          </button>
        </div>
      </div>

      {error && (
        <div className="mb-4 p-3 bg-red-900/50 border border-red-700 rounded-lg text-red-200">
          {error}
        </div>
      )}

      {/* Main content: Editor + Preview */}
      <div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
        {/* Editor (3/5) */}
        <div className="lg:col-span-3 bg-gray-800 rounded-lg p-4">
          <DndContext
            sensors={sensors}
            collisionDetection={closestCenter}
            onDragEnd={handleDragEnd}
          >
            <SortableContext
              items={links.map((l) => l.id)}
              strategy={verticalListSortingStrategy}
            >
              <div className="space-y-2">
                {links.map((link) => (
                  <SortableLinkItem
                    key={link.id}
                    link={link}
                    onEdit={() => setEditingLink(link)}
                    onDelete={() => handleDeleteLink(link.id)}
                    onMoveUp={() => moveLink(link.id, 'up')}
                    onMoveDown={() => moveLink(link.id, 'down')}
                    isFirst={link.sortOrder === 0}
                    isLast={link.sortOrder === links.length - 1}
                  />
                ))}
              </div>
            </SortableContext>
          </DndContext>

          {/* Add button and counter */}
          <div className="flex items-center justify-between mt-4 pt-4 border-t border-gray-700">
            <button
              onClick={handleAddLink}
              disabled={links.length >= 10}
              className="flex items-center gap-2 px-3 py-2 bg-gray-700 hover:bg-gray-600 disabled:bg-gray-800 disabled:text-gray-500 rounded-lg transition-colors"
            >
              <span className="text-lg">+</span>
              <span>Add Link</span>
            </button>
            <div className="text-sm text-gray-400">
              {links.length}/10 links • Distribution: [{distribution}]
            </div>
          </div>
        </div>

        {/* Preview (2/5) */}
        <div className="lg:col-span-2">
          <LinkPreview links={links} />
        </div>
      </div>

      {/* Edit Modal */}
      {editingLink && (
        <LinkEditModal
          link={editingLink}
          pages={pages}
          onSave={handleSaveEdit}
          onCancel={() => setEditingLink(null)}
        />
      )}
    </div>
  );
}
```

**Step 2: Commit**

```bash
git add components/cms/LinkEditorContent.tsx
git commit -m "feat(cms): add LinkEditorContent component with dnd-kit"
```

---

## Task 10: Create SortableLinkItem Component

**Files:**

- Create: `components/cms/SortableLinkItem.tsx`

**Step 1: Create the sortable item component**

```typescript
/**
 * SortableLinkItem Component
 *
 * A single draggable link item in the editor list.
 * Includes drag handle, up/down buttons, edit, and delete.
 */

'use client';

import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { LinkItem } from './LinkEditorContent';

interface SortableLinkItemProps {
  link: LinkItem;
  onEdit: () => void;
  onDelete: () => void;
  onMoveUp: () => void;
  onMoveDown: () => void;
  isFirst: boolean;
  isLast: boolean;
}

export function SortableLinkItem({
  link,
  onEdit,
  onDelete,
  onMoveUp,
  onMoveDown,
  isFirst,
  isLast,
}: SortableLinkItemProps) {
  const {
    attributes,
    listeners,
    setNodeRef,
    transform,
    transition,
    isDragging,
  } = useSortable({ id: link.id });

  const style = {
    transform: CSS.Transform.toString(transform),
    transition,
    opacity: isDragging ? 0.5 : 1,
  };

  return (
    <div
      ref={setNodeRef}
      style={style}
      className={`flex items-center gap-3 p-3 bg-gray-700 rounded-lg ${
        isDragging ? 'ring-2 ring-cyan-500' : ''
      }`}
    >
      {/* Drag handle */}
      <button
        {...attributes}
        {...listeners}
        className="p-1 text-gray-400 hover:text-white cursor-grab active:cursor-grabbing"
        aria-label="Drag to reorder"
      >
        <svg
          width="16"
          height="16"
          viewBox="0 0 16 16"
          fill="currentColor"
        >
          <circle cx="4" cy="3" r="1.5" />
          <circle cx="12" cy="3" r="1.5" />
          <circle cx="4" cy="8" r="1.5" />
          <circle cx="12" cy="8" r="1.5" />
          <circle cx="4" cy="13" r="1.5" />
          <circle cx="12" cy="13" r="1.5" />
        </svg>
      </button>

      {/* Up/Down buttons for keyboard accessibility */}
      <div className="flex flex-col gap-0.5">
        <button
          onClick={onMoveUp}
          disabled={isFirst}
          className="p-0.5 text-gray-400 hover:text-white disabled:text-gray-600 disabled:cursor-not-allowed"
          aria-label="Move up"
        >
          <svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
            <path d="M6 2L2 6h3v4h2V6h3L6 2z" />
          </svg>
        </button>
        <button
          onClick={onMoveDown}
          disabled={isLast}
          className="p-0.5 text-gray-400 hover:text-white disabled:text-gray-600 disabled:cursor-not-allowed"
          aria-label="Move down"
        >
          <svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
            <path d="M6 10l4-4H7V2H5v4H2l4 4z" />
          </svg>
        </button>
      </div>

      {/* Link info */}
      <div className="flex-1 min-w-0">
        <div className="font-medium text-white truncate">{link.label}</div>
        <div className="text-sm text-gray-400 truncate">
          {link.linkType === 'page'
            ? `Page: ${link.pageTitle || link.pageSlug || 'Not set'}`
            : link.externalUrl || 'No URL set'}
        </div>
      </div>

      {/* Link type badge */}
      <span
        className={`px-2 py-0.5 text-xs rounded ${
          link.linkType === 'page'
            ? 'bg-purple-900/50 text-purple-300'
            : 'bg-blue-900/50 text-blue-300'
        }`}
      >
        {link.linkType === 'page' ? 'Page' : 'External'}
      </span>

      {/* New tab indicator */}
      {link.openInNewTab && (
        <span className="text-gray-500" title="Opens in new tab">
          <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
            <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
            <polyline points="15 3 21 3 21 9" />
            <line x1="10" y1="14" x2="21" y2="3" />
          </svg>
        </span>
      )}

      {/* Edit button */}
      <button
        onClick={onEdit}
        className="p-2 text-gray-400 hover:text-cyan-400 transition-colors"
        aria-label="Edit link"
      >
        <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
          <path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
          <path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
        </svg>
      </button>

      {/* Delete button */}
      <button
        onClick={onDelete}
        className="p-2 text-gray-400 hover:text-red-400 transition-colors"
        aria-label="Delete link"
      >
        <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
          <polyline points="3 6 5 6 21 6" />
          <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
        </svg>
      </button>
    </div>
  );
}
```

**Step 2: Commit**

```bash
git add components/cms/SortableLinkItem.tsx
git commit -m "feat(cms): add SortableLinkItem component"
```

---

## Task 11: Create LinkEditModal Component

**Files:**

- Create: `components/cms/LinkEditModal.tsx`

**Step 1: Create the edit modal component**

```typescript
/**
 * LinkEditModal Component
 *
 * Modal for editing a link's properties: label, type, URL/page, open in new tab.
 */

'use client';

import { useState, useEffect } from 'react';
import { LinkItem } from './LinkEditorContent';

interface PageOption {
  id: string;
  slug: string;
  title: string;
}

interface LinkEditModalProps {
  link: LinkItem;
  pages: PageOption[];
  onSave: (link: LinkItem) => void;
  onCancel: () => void;
}

export function LinkEditModal({ link, pages, onSave, onCancel }: LinkEditModalProps) {
  const [label, setLabel] = useState(link.label);
  const [linkType, setLinkType] = useState<'page' | 'external'>(link.linkType);
  const [pageId, setPageId] = useState(link.pageId || '');
  const [externalUrl, setExternalUrl] = useState(link.externalUrl || '');
  const [openInNewTab, setOpenInNewTab] = useState(link.openInNewTab);
  const [error, setError] = useState<string | null>(null);

  // Auto-populate label from page title when selecting a page
  useEffect(() => {
    if (linkType === 'page' && pageId) {
      const page = pages.find((p) => p.id === pageId);
      if (page && label === 'New Link') {
        setLabel(page.title);
      }
    }
  }, [pageId, linkType, pages, label]);

  const handleSave = () => {
    // Validation
    if (!label.trim()) {
      setError('Label is required');
      return;
    }
    if (label.length > 50) {
      setError('Label must be 50 characters or less');
      return;
    }
    if (linkType === 'page' && !pageId) {
      setError('Please select a page');
      return;
    }
    if (linkType === 'external' && !externalUrl.trim()) {
      setError('Please enter a URL');
      return;
    }

    const selectedPage = pages.find((p) => p.id === pageId);

    onSave({
      ...link,
      label: label.trim(),
      linkType,
      pageId: linkType === 'page' ? pageId : null,
      pageSlug: linkType === 'page' && selectedPage ? selectedPage.slug : null,
      pageTitle: linkType === 'page' && selectedPage ? selectedPage.title : null,
      externalUrl: linkType === 'external' ? externalUrl.trim() : null,
      openInNewTab,
    });
  };

  return (
    <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
      <div className="bg-gray-800 rounded-lg p-6 w-full max-w-md mx-4">
        <h2 className="text-xl font-bold mb-4">Edit Link</h2>

        {error && (
          <div className="mb-4 p-2 bg-red-900/50 border border-red-700 rounded text-red-200 text-sm">
            {error}
          </div>
        )}

        <div className="space-y-4">
          {/* Link Type */}
          <div>
            <label className="block text-sm font-medium text-gray-300 mb-2">
              Link Type
            </label>
            <div className="flex gap-4">
              <label className="flex items-center gap-2 cursor-pointer">
                <input
                  type="radio"
                  name="linkType"
                  value="external"
                  checked={linkType === 'external'}
                  onChange={() => setLinkType('external')}
                  className="text-cyan-500"
                />
                <span>External URL</span>
              </label>
              <label className="flex items-center gap-2 cursor-pointer">
                <input
                  type="radio"
                  name="linkType"
                  value="page"
                  checked={linkType === 'page'}
                  onChange={() => setLinkType('page')}
                  className="text-cyan-500"
                />
                <span>Page</span>
              </label>
            </div>
          </div>

          {/* Page Selector or URL Input */}
          {linkType === 'page' ? (
            <div>
              <label className="block text-sm font-medium text-gray-300 mb-2">
                Select Page
              </label>
              <select
                value={pageId}
                onChange={(e) => setPageId(e.target.value)}
                className="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg focus:ring-2 focus:ring-cyan-500 focus:border-transparent"
              >
                <option value="">Choose a page...</option>
                {pages.map((page) => (
                  <option key={page.id} value={page.id}>
                    {page.title} (/{page.slug})
                  </option>
                ))}
              </select>
              {pages.length === 0 && (
                <p className="mt-2 text-sm text-gray-500">
                  No pages available. Create pages first.
                </p>
              )}
            </div>
          ) : (
            <div>
              <label className="block text-sm font-medium text-gray-300 mb-2">
                URL
              </label>
              <input
                type="url"
                value={externalUrl}
                onChange={(e) => setExternalUrl(e.target.value)}
                placeholder="https://www.amsoil.com/..."
                className="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg focus:ring-2 focus:ring-cyan-500 focus:border-transparent"
              />
            </div>
          )}

          {/* Label */}
          <div>
            <label className="block text-sm font-medium text-gray-300 mb-2">
              Label
            </label>
            <input
              type="text"
              value={label}
              onChange={(e) => setLabel(e.target.value)}
              maxLength={50}
              className="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg focus:ring-2 focus:ring-cyan-500 focus:border-transparent"
            />
            <p className="mt-1 text-sm text-gray-500">{label.length}/50</p>
          </div>

          {/* Open in New Tab */}
          <div>
            <label className="flex items-center gap-2 cursor-pointer">
              <input
                type="checkbox"
                checked={openInNewTab}
                onChange={(e) => setOpenInNewTab(e.target.checked)}
                className="rounded text-cyan-500"
              />
              <span className="text-sm">Open in new tab</span>
            </label>
          </div>
        </div>

        {/* Actions */}
        <div className="flex justify-end gap-3 mt-6">
          <button
            onClick={onCancel}
            className="px-4 py-2 text-gray-300 hover:text-white transition-colors"
          >
            Cancel
          </button>
          <button
            onClick={handleSave}
            className="px-4 py-2 bg-cyan-600 hover:bg-cyan-700 rounded-lg font-medium transition-colors"
          >
            Save
          </button>
        </div>
      </div>
    </div>
  );
}
```

**Step 2: Commit**

```bash
git add components/cms/LinkEditModal.tsx
git commit -m "feat(cms): add LinkEditModal component"
```

---

## Task 12: Create LinkPreview Component

**Files:**

- Create: `components/cms/LinkPreview.tsx`

**Step 1: Create the preview component**

```typescript
/**
 * LinkPreview Component
 *
 * Live preview of how quick links will appear on the dealer page.
 * Shows two-column balanced layout with responsive toggle.
 */

'use client';

import { useState } from 'react';
import { LinkItem } from './LinkEditorContent';

interface LinkPreviewProps {
  links: LinkItem[];
}

export function LinkPreview({ links }: LinkPreviewProps) {
  const [viewMode, setViewMode] = useState<'desktop' | 'mobile'>('desktop');

  return (
    <div className="bg-gray-800 rounded-lg p-4">
      <div className="flex items-center justify-between mb-4">
        <h2 className="text-sm font-medium text-gray-400">Preview</h2>
        <div className="flex gap-1 bg-gray-700 rounded-lg p-1">
          <button
            onClick={() => setViewMode('desktop')}
            className={`px-3 py-1 text-xs rounded ${
              viewMode === 'desktop'
                ? 'bg-gray-600 text-white'
                : 'text-gray-400 hover:text-white'
            }`}
          >
            Desktop
          </button>
          <button
            onClick={() => setViewMode('mobile')}
            className={`px-3 py-1 text-xs rounded ${
              viewMode === 'mobile'
                ? 'bg-gray-600 text-white'
                : 'text-gray-400 hover:text-white'
            }`}
          >
            Mobile
          </button>
        </div>
      </div>

      {/* Preview container */}
      <div
        className={`bg-gray-900 rounded-lg p-4 ${
          viewMode === 'mobile' ? 'max-w-[320px] mx-auto' : ''
        }`}
      >
        {links.length === 0 ? (
          <div className="text-center text-gray-500 py-8">
            <p>No links yet</p>
            <p className="text-sm mt-1">Add links to see preview</p>
          </div>
        ) : (
          <div className="quick-links-preview">
            <h3 className="text-lg font-semibold text-white text-center mb-4">
              Quick Links
            </h3>
            <ul
              className="list-none p-0 m-0"
              style={{
                columnCount: viewMode === 'desktop' ? 2 : 1,
                columnFill: 'balance',
                columnGap: '2rem',
              }}
            >
              {links.map((link) => (
                <li
                  key={link.id}
                  className="break-inside-avoid py-2"
                >
                  <span className="flex items-center gap-2 text-cyan-400">
                    <span>•</span>
                    <span className="hover:underline cursor-pointer">
                      {link.label}
                    </span>
                    {link.openInNewTab && (
                      <svg
                        width="12"
                        height="12"
                        viewBox="0 0 24 24"
                        fill="none"
                        stroke="currentColor"
                        strokeWidth="2"
                        className="text-gray-500"
                      >
                        <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
                        <polyline points="15 3 21 3 21 9" />
                        <line x1="10" y1="14" x2="21" y2="3" />
                      </svg>
                    )}
                  </span>
                </li>
              ))}
            </ul>
          </div>
        )}
      </div>

      {/* Info text */}
      <p className="text-xs text-gray-500 mt-3 text-center">
        Links automatically balance across columns
      </p>
    </div>
  );
}
```

**Step 2: Commit**

```bash
git add components/cms/LinkPreview.tsx
git commit -m "feat(cms): add LinkPreview component with responsive toggle"
```

---

## Task 13: Add Link Editor to CMS Navigation

**Files:**

- Modify: `app/dashboard/cms/page.tsx` (or wherever CMS menu is)

**Step 1: Add link to link editor in CMS dashboard**

Find the CMS dashboard page and add a card/link for the Link Editor. The exact location depends on the existing layout, but it should be alongside the navigation editor link.

Look for where "Navigation Editor" or similar is linked and add:

```typescript
{/* Quick Links Editor - Growth+ */}
<Link
  href="/dashboard/cms/links"
  className="block p-4 bg-gray-800 rounded-lg hover:bg-gray-700 transition-colors"
>
  <h3 className="font-medium text-white">Quick Links</h3>
  <p className="text-sm text-gray-400 mt-1">
    Add custom links below your dealer info
  </p>
  <span className="text-xs text-cyan-500 mt-2 inline-block">
    Growth+
  </span>
</Link>
```

**Step 2: Commit**

```bash
git add app/dashboard/cms/page.tsx
git commit -m "feat(dashboard): add link editor to CMS navigation"
```

---

## Task 14: Manual Testing

**Step 1: Start dev server**

Run: `npm run power-cycle`

**Step 2: Login as Growth tier user**

Use dev auth with `test-growth@claude.dev`

**Step 3: Test scenarios**

1. Navigate to `/dashboard/cms/links`
2. Click "Add Link" - verify modal opens
3. Add an external link - verify it appears in list and preview
4. Add a page link - verify page dropdown works
5. Drag to reorder - verify preview updates
6. Use up/down arrows - verify accessibility works
7. Click "Save & Publish" - verify no errors
8. Visit dealer page - verify quick links display

**Step 4: Test tier restriction**

Login as `test-starter@claude.dev` and verify 403/upgrade message

**Step 5: Commit test results note**

```bash
git commit --allow-empty -m "test: manual QA passed for link editor"
```

---

## Summary

**Files created:**

- `lib/cms/link-validation.ts`
- `app/api/cms/links/route.ts`
- `components/QuickLinks.tsx`
- `app/dashboard/cms/links/page.tsx`
- `components/cms/LinkEditorContent.tsx`
- `components/cms/SortableLinkItem.tsx`
- `components/cms/LinkEditModal.tsx`
- `components/cms/LinkPreview.tsx`

**Files modified:**

- `prisma/schema.prisma`
- `lib/cms/types.ts`
- `types/dealer.ts`
- `app/dealer-template.css`
- `app/dealers/[subdomain]/page.tsx`
- `components/DealerTemplate.tsx`
- `app/dashboard/cms/page.tsx`

**Total commits:** 14
