# Admin Subdomain Editor Implementation Plan

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

**Goal:** Enable admins to edit dealer subdomains via a quick actions dropdown menu, with Cloudflare DNS updates for published dealers.

**Architecture:** Quick actions dropdown replaces the "Support" button in DealerTable. "Edit DNS" action opens a modal for subdomain input. API endpoint handles validation, database update, and Cloudflare DNS record update (delete old + create new) for published dealers. AdminAction audit logging records all changes.

**Tech Stack:** Next.js 16 App Router, React 19, Prisma 7, Cloudflare API, TypeScript

---

## Task 1: API Endpoint - Subdomain Validation Utility

**Files:**
- Create: `lib/subdomain-validation.ts`
- Create: `lib/__tests__/subdomain-validation.test.ts`

**Step 1: Write the failing test**

```typescript
// lib/__tests__/subdomain-validation.test.ts
import { validateSubdomain } from '../subdomain-validation';

describe('validateSubdomain', () => {
  it('accepts valid lowercase alphanumeric subdomain', () => {
    expect(validateSubdomain('bobsoil')).toEqual({ valid: true });
  });

  it('accepts subdomain with hyphens in middle', () => {
    expect(validateSubdomain('bob-smith-oil')).toEqual({ valid: true });
  });

  it('rejects subdomain starting with hyphen', () => {
    const result = validateSubdomain('-bobsoil');
    expect(result.valid).toBe(false);
    expect(result.error).toContain('cannot start');
  });

  it('rejects subdomain ending with hyphen', () => {
    const result = validateSubdomain('bobsoil-');
    expect(result.valid).toBe(false);
    expect(result.error).toContain('cannot end');
  });

  it('rejects subdomain shorter than 3 characters', () => {
    const result = validateSubdomain('ab');
    expect(result.valid).toBe(false);
    expect(result.error).toContain('3');
  });

  it('rejects subdomain longer than 63 characters', () => {
    const result = validateSubdomain('a'.repeat(64));
    expect(result.valid).toBe(false);
    expect(result.error).toContain('63');
  });

  it('rejects subdomain with uppercase letters', () => {
    const result = validateSubdomain('BobSoil');
    expect(result.valid).toBe(false);
    expect(result.error).toContain('lowercase');
  });

  it('rejects subdomain with special characters', () => {
    const result = validateSubdomain('bob_oil');
    expect(result.valid).toBe(false);
    expect(result.error).toContain('lowercase');
  });

  it('rejects empty subdomain', () => {
    const result = validateSubdomain('');
    expect(result.valid).toBe(false);
  });
});
```

**Step 2: Run test to verify it fails**

Run: `npx jest lib/__tests__/subdomain-validation.test.ts --no-coverage`
Expected: FAIL with "Cannot find module '../subdomain-validation'"

**Step 3: Write minimal implementation**

```typescript
// lib/subdomain-validation.ts

export interface ValidationResult {
  valid: boolean;
  error?: string;
}

/**
 * Validate subdomain format according to DNS rules:
 * - 3-63 characters
 * - Lowercase alphanumeric and hyphens only
 * - Cannot start or end with hyphen
 */
export function validateSubdomain(subdomain: string): ValidationResult {
  if (!subdomain || subdomain.length === 0) {
    return { valid: false, error: 'Subdomain is required' };
  }

  if (subdomain.length < 3) {
    return { valid: false, error: 'Subdomain must be at least 3 characters' };
  }

  if (subdomain.length > 63) {
    return { valid: false, error: 'Subdomain must be at most 63 characters' };
  }

  if (!/^[a-z0-9-]+$/.test(subdomain)) {
    return { valid: false, error: 'Subdomain must contain only lowercase letters, numbers, and hyphens' };
  }

  if (subdomain.startsWith('-')) {
    return { valid: false, error: 'Subdomain cannot start with a hyphen' };
  }

  if (subdomain.endsWith('-')) {
    return { valid: false, error: 'Subdomain cannot end with a hyphen' };
  }

  return { valid: true };
}
```

**Step 4: Run test to verify it passes**

Run: `npx jest lib/__tests__/subdomain-validation.test.ts --no-coverage`
Expected: PASS (9 tests)

**Step 5: Commit**

```bash
git add lib/subdomain-validation.ts lib/__tests__/subdomain-validation.test.ts
git commit -m "feat(admin): add subdomain validation utility"
```

---

## Task 2: API Endpoint - PATCH Route Handler

**Files:**
- Create: `app/api/admin/dealers/[id]/subdomain/route.ts`

**Step 1: Create the API route**

```typescript
// app/api/admin/dealers/[id]/subdomain/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { requireAdmin, getRequestContext } from '@/lib/admin-auth';
import { validateSubdomain } from '@/lib/subdomain-validation';
import {
  createDNSRecord,
  deleteDNSRecord,
  getZoneIdForDomain,
  DomainInfo,
} from '@/lib/cloudflare-api';
import { logger } from '@/lib/logger';

interface RouteParams {
  params: Promise<{ id: string }>;
}

export async function PATCH(request: NextRequest, { params }: RouteParams) {
  try {
    // Verify admin authentication
    const admin = await requireAdmin();
    if (!admin) {
      return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
    }

    const { id: dealerId } = await params;
    const body = await request.json();
    const { subdomain: newSubdomain } = body;

    // Validate subdomain format
    const validation = validateSubdomain(newSubdomain);
    if (!validation.valid) {
      return NextResponse.json({ error: validation.error }, { status: 400 });
    }

    // Get dealer
    const dealer = await prisma.dealer.findUnique({
      where: { id: dealerId },
      select: {
        id: true,
        subdomain: true,
        domain: true,
        domainPrefix: true,
        cloudflareRecordId: true,
        status: true,
        lastPublishedAt: true,
      },
    });

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

    // Check if subdomain is already taken by another dealer
    if (newSubdomain !== dealer.subdomain) {
      const existingDealer = await prisma.dealer.findFirst({
        where: {
          subdomain: newSubdomain,
          id: { not: dealerId },
        },
      });

      if (existingDealer) {
        return NextResponse.json(
          { error: 'Subdomain is already in use by another dealer' },
          { status: 409 }
        );
      }
    }

    // If subdomain unchanged, return early
    if (newSubdomain === dealer.subdomain) {
      return NextResponse.json({ success: true, subdomain: newSubdomain });
    }

    const oldSubdomain = dealer.subdomain;
    const domainInfo: DomainInfo = {
      domain: dealer.domain as 'com' | 'ca',
      domainPrefix: (dealer.domainPrefix || 'myamsoil') as 'myamsoil' | 'shopamsoil',
    };

    let newCloudflareRecordId: string | null = null;

    // Handle Cloudflare DNS if dealer has published (has DNS record)
    if (dealer.cloudflareRecordId && dealer.lastPublishedAt) {
      const zoneId = getZoneIdForDomain(domainInfo);

      // Delete old DNS record
      try {
        await deleteDNSRecord(dealer.cloudflareRecordId, zoneId);
        logger.info(
          { dealerId, oldSubdomain, recordId: dealer.cloudflareRecordId },
          'Deleted old Cloudflare DNS record'
        );
      } catch (error) {
        logger.error(
          { err: error, dealerId, oldSubdomain },
          'Failed to delete old DNS record - continuing with update'
        );
        // Continue anyway - old record might already be gone
      }

      // Create new DNS record
      try {
        const newRecord = await createDNSRecord(newSubdomain, domainInfo);
        newCloudflareRecordId = newRecord.id;
        logger.info(
          { dealerId, newSubdomain, recordId: newRecord.id },
          'Created new Cloudflare DNS record'
        );
      } catch (error) {
        logger.error(
          { err: error, dealerId, newSubdomain },
          'Failed to create new DNS record'
        );
        return NextResponse.json(
          { error: 'Failed to update DNS record. Please try again.' },
          { status: 500 }
        );
      }
    }

    // Update database
    const updatedDealer = await prisma.dealer.update({
      where: { id: dealerId },
      data: {
        subdomain: newSubdomain,
        ...(newCloudflareRecordId && { cloudflareRecordId: newCloudflareRecordId }),
      },
    });

    // Log admin action
    const { ipAddress, userAgent } = getRequestContext(request);
    await prisma.adminAction.create({
      data: {
        adminUserId: admin.id,
        action: 'update_subdomain',
        targetType: 'dealer',
        targetId: dealerId,
        details: {
          oldSubdomain,
          newSubdomain,
          hadDnsRecord: !!dealer.cloudflareRecordId,
        },
        reason: body.reason || null,
        ipAddress,
        userAgent,
      },
    });

    logger.info(
      { dealerId, oldSubdomain, newSubdomain, adminId: admin.id },
      'Admin updated dealer subdomain'
    );

    return NextResponse.json({
      success: true,
      subdomain: updatedDealer.subdomain,
      domain: updatedDealer.domain,
      domainPrefix: updatedDealer.domainPrefix,
    });
  } catch (error) {
    logger.error({ err: error }, 'Failed to update dealer subdomain');
    return NextResponse.json(
      { error: 'Internal server error' },
      { status: 500 }
    );
  }
}
```

**Step 2: Verify the route compiles**

Run: `npx tsc --noEmit`
Expected: No errors

**Step 3: Commit**

```bash
git add app/api/admin/dealers/[id]/subdomain/route.ts
git commit -m "feat(admin): add PATCH endpoint for subdomain updates"
```

---

## Task 3: Quick Actions Dropdown Component

**Files:**
- Create: `components/admin/QuickActionsMenu.tsx`
- Create: `components/admin/QuickActionsMenu.module.css`

**Step 1: Create the dropdown component**

```typescript
// components/admin/QuickActionsMenu.tsx
'use client';

import React, { useState, useRef, useEffect } from 'react';
import styles from './QuickActionsMenu.module.css';

interface QuickActionsMenuProps {
  onStartSupport: () => void;
  onEditDNS: () => void;
  isStartingSupport?: boolean;
}

export function QuickActionsMenu({
  onStartSupport,
  onEditDNS,
  isStartingSupport,
}: QuickActionsMenuProps) {
  const [isOpen, setIsOpen] = useState(false);
  const menuRef = useRef<HTMLDivElement>(null);

  // Close menu when clicking outside
  useEffect(() => {
    function handleClickOutside(event: MouseEvent) {
      if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
        setIsOpen(false);
      }
    }

    if (isOpen) {
      document.addEventListener('mousedown', handleClickOutside);
    }

    return () => {
      document.removeEventListener('mousedown', handleClickOutside);
    };
  }, [isOpen]);

  // Close menu on Escape key
  useEffect(() => {
    function handleEscape(event: KeyboardEvent) {
      if (event.key === 'Escape') {
        setIsOpen(false);
      }
    }

    if (isOpen) {
      document.addEventListener('keydown', handleEscape);
    }

    return () => {
      document.removeEventListener('keydown', handleEscape);
    };
  }, [isOpen]);

  return (
    <div className={styles.container} ref={menuRef}>
      <button
        className={styles.trigger}
        onClick={() => setIsOpen(!isOpen)}
        aria-haspopup="true"
        aria-expanded={isOpen}
        aria-label="Actions menu"
      >
        <svg
          width="16"
          height="16"
          viewBox="0 0 16 16"
          fill="currentColor"
        >
          <circle cx="8" cy="3" r="1.5" />
          <circle cx="8" cy="8" r="1.5" />
          <circle cx="8" cy="13" r="1.5" />
        </svg>
      </button>

      {isOpen && (
        <div className={styles.dropdown} role="menu">
          <button
            className={styles.menuItem}
            onClick={() => {
              onStartSupport();
              setIsOpen(false);
            }}
            disabled={isStartingSupport}
            role="menuitem"
          >
            <svg
              className={styles.menuIcon}
              width="16"
              height="16"
              viewBox="0 0 24 24"
              fill="none"
              stroke="currentColor"
              strokeWidth="2"
            >
              <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
              <circle cx="12" cy="7" r="4" />
            </svg>
            {isStartingSupport ? 'Starting...' : 'Start Support Session'}
          </button>

          <div className={styles.divider} />

          <button
            className={styles.menuItem}
            onClick={() => {
              onEditDNS();
              setIsOpen(false);
            }}
            role="menuitem"
          >
            <svg
              className={styles.menuIcon}
              width="16"
              height="16"
              viewBox="0 0 24 24"
              fill="none"
              stroke="currentColor"
              strokeWidth="2"
            >
              <path d="M12 2L2 7l10 5 10-5-10-5z" />
              <path d="M2 17l10 5 10-5" />
              <path d="M2 12l10 5 10-5" />
            </svg>
            Edit DNS
          </button>
        </div>
      )}
    </div>
  );
}
```

**Step 2: Create the styles**

```css
/* components/admin/QuickActionsMenu.module.css */
.container {
  position: relative;
  display: inline-block;
}

.trigger {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 36px;
  height: 36px;
  background: transparent;
  border: 1px solid var(--color-border, rgba(148, 163, 184, 0.25));
  border-radius: 8px;
  color: var(--color-text-secondary, rgba(203, 213, 225, 0.9));
  cursor: pointer;
  transition: all 0.2s;
}

.trigger:hover {
  background: rgba(148, 163, 184, 0.15);
  border-color: rgba(148, 163, 184, 0.4);
  color: var(--color-text-primary, #f8fafc);
}

.dropdown {
  position: absolute;
  right: 0;
  top: calc(100% + 4px);
  min-width: 200px;
  background: rgba(30, 41, 59, 0.98);
  border: 1px solid var(--color-border, rgba(148, 163, 184, 0.25));
  border-radius: 12px;
  box-shadow: 0 10px 40px -10px rgba(0, 0, 0, 0.5);
  padding: 6px;
  z-index: 50;
}

.menuItem {
  display: flex;
  align-items: center;
  gap: 10px;
  width: 100%;
  padding: 10px 12px;
  background: transparent;
  border: none;
  border-radius: 8px;
  font-size: 0.875rem;
  font-weight: 500;
  color: var(--color-text-secondary, rgba(203, 213, 225, 0.9));
  cursor: pointer;
  transition: all 0.15s;
  text-align: left;
}

.menuItem:hover:not(:disabled) {
  background: rgba(148, 163, 184, 0.15);
  color: var(--color-text-primary, #f8fafc);
}

.menuItem:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

.menuIcon {
  flex-shrink: 0;
  opacity: 0.7;
}

.divider {
  height: 1px;
  background: var(--color-border, rgba(148, 163, 184, 0.25));
  margin: 6px 0;
}
```

**Step 3: Verify TypeScript compiles**

Run: `npx tsc --noEmit`
Expected: No errors

**Step 4: Commit**

```bash
git add components/admin/QuickActionsMenu.tsx components/admin/QuickActionsMenu.module.css
git commit -m "feat(admin): add QuickActionsMenu dropdown component"
```

---

## Task 4: Edit DNS Modal Component

**Files:**
- Create: `components/admin/EditDNSModal.tsx`
- Create: `components/admin/EditDNSModal.module.css`

**Step 1: Create the modal component**

```typescript
// components/admin/EditDNSModal.tsx
'use client';

import React, { useState, useEffect } from 'react';
import { Modal, ModalBody } from '@/components/ui/Modal';
import { validateSubdomain } from '@/lib/subdomain-validation';
import styles from './EditDNSModal.module.css';

interface Dealer {
  id: string;
  subdomain: string | null;
  domain: string;
  domainPrefix?: string;
}

interface EditDNSModalProps {
  isOpen: boolean;
  onClose: () => void;
  dealer: Dealer | null;
  onSave: (dealerId: string, newSubdomain: string) => Promise<void>;
}

export function EditDNSModal({ isOpen, onClose, dealer, onSave }: EditDNSModalProps) {
  const [subdomain, setSubdomain] = useState('');
  const [error, setError] = useState<string | null>(null);
  const [isSaving, setIsSaving] = useState(false);

  // Reset form when modal opens/closes or dealer changes
  useEffect(() => {
    if (isOpen && dealer) {
      setSubdomain(dealer.subdomain || '');
      setError(null);
      setIsSaving(false);
    }
  }, [isOpen, dealer]);

  if (!dealer) return null;

  const domainPrefix = dealer.domainPrefix || 'myamsoil';
  const fullDomain = `${domainPrefix}.${dealer.domain === 'ca' ? 'ca' : 'com'}`;
  const hasExistingSubdomain = !!dealer.subdomain;

  const handleSubdomainChange = (value: string) => {
    // Auto-lowercase as user types
    const lowercased = value.toLowerCase();
    setSubdomain(lowercased);

    // Clear error on change
    if (error) setError(null);
  };

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();

    // Validate
    const validation = validateSubdomain(subdomain);
    if (!validation.valid) {
      setError(validation.error || 'Invalid subdomain');
      return;
    }

    setIsSaving(true);
    setError(null);

    try {
      await onSave(dealer.id, subdomain);
      onClose();
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Failed to update subdomain');
    } finally {
      setIsSaving(false);
    }
  };

  const footer = (
    <div className={styles.footer}>
      <div className={styles.buttons}>
        <button
          type="button"
          className={styles.cancelButton}
          onClick={onClose}
          disabled={isSaving}
        >
          Cancel
        </button>
        <button
          type="submit"
          form="edit-dns-form"
          className={styles.confirmButton}
          disabled={isSaving || !subdomain}
        >
          {isSaving ? 'Saving...' : 'Confirm'}
        </button>
      </div>
      {hasExistingSubdomain && subdomain !== dealer.subdomain && (
        <div className={styles.warning}>
          <svg
            className={styles.warningIcon}
            width="16"
            height="16"
            viewBox="0 0 24 24"
            fill="none"
            stroke="currentColor"
            strokeWidth="2"
          >
            <path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
            <line x1="12" y1="9" x2="12" y2="13" />
            <line x1="12" y1="17" x2="12.01" y2="17" />
          </svg>
          <span>
            This will break existing links to{' '}
            <strong>{dealer.subdomain}.{fullDomain}</strong>
          </span>
        </div>
      )}
    </div>
  );

  return (
    <Modal isOpen={isOpen} onClose={onClose} title="Edit DNS" size="small" footer={footer}>
      <ModalBody>
        <form id="edit-dns-form" onSubmit={handleSubmit} className={styles.form}>
          {hasExistingSubdomain && (
            <div className={styles.currentValue}>
              <span className={styles.label}>Current:</span>
              <code className={styles.currentSubdomain}>
                {dealer.subdomain}.{fullDomain}
              </code>
            </div>
          )}

          <div className={styles.inputGroup}>
            <label htmlFor="subdomain" className={styles.label}>
              {hasExistingSubdomain ? 'New subdomain:' : 'Subdomain:'}
            </label>
            <div className={styles.inputWrapper}>
              <input
                id="subdomain"
                type="text"
                value={subdomain}
                onChange={(e) => handleSubdomainChange(e.target.value)}
                placeholder="dealer-name"
                className={styles.input}
                autoFocus
                autoComplete="off"
                disabled={isSaving}
              />
              <span className={styles.suffix}>.{fullDomain}</span>
            </div>
            {error && <p className={styles.error}>{error}</p>}
          </div>
        </form>
      </ModalBody>
    </Modal>
  );
}
```

**Step 2: Create the styles**

```css
/* components/admin/EditDNSModal.module.css */
.form {
  display: flex;
  flex-direction: column;
  gap: 20px;
}

.currentValue {
  display: flex;
  flex-direction: column;
  gap: 6px;
}

.currentSubdomain {
  font-size: 0.875rem;
  background: rgba(56, 189, 248, 0.16);
  border: 1px solid rgba(56, 189, 248, 0.35);
  padding: 8px 12px;
  border-radius: 8px;
  color: var(--color-primary-accent, #38bdf8);
}

.inputGroup {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.label {
  font-size: 0.8125rem;
  font-weight: 500;
  color: var(--color-text-secondary, rgba(203, 213, 225, 0.9));
}

.inputWrapper {
  display: flex;
  align-items: center;
  background: rgba(15, 23, 42, 0.6);
  border: 1px solid var(--color-border, rgba(148, 163, 184, 0.25));
  border-radius: 8px;
  overflow: hidden;
  transition: border-color 0.2s;
}

.inputWrapper:focus-within {
  border-color: var(--color-primary-accent, #38bdf8);
}

.input {
  flex: 1;
  padding: 10px 12px;
  background: transparent;
  border: none;
  font-size: 0.9375rem;
  color: var(--color-text-primary, #f8fafc);
  outline: none;
}

.input::placeholder {
  color: var(--color-text-tertiary, rgba(148, 163, 184, 0.85));
}

.suffix {
  padding: 10px 12px;
  font-size: 0.875rem;
  color: var(--color-text-tertiary, rgba(148, 163, 184, 0.85));
  background: rgba(15, 23, 42, 0.4);
  border-left: 1px solid var(--color-border, rgba(148, 163, 184, 0.25));
}

.error {
  font-size: 0.8125rem;
  color: #ef4444;
  margin: 0;
}

.footer {
  display: flex;
  flex-direction: column;
  gap: 16px;
}

.buttons {
  display: flex;
  justify-content: flex-end;
  gap: 12px;
}

.cancelButton {
  padding: 10px 20px;
  background: transparent;
  border: 1px solid var(--color-border, rgba(148, 163, 184, 0.25));
  border-radius: 8px;
  font-size: 0.875rem;
  font-weight: 500;
  color: var(--color-text-secondary, rgba(203, 213, 225, 0.9));
  cursor: pointer;
  transition: all 0.2s;
}

.cancelButton:hover:not(:disabled) {
  background: rgba(148, 163, 184, 0.15);
  border-color: rgba(148, 163, 184, 0.4);
}

.cancelButton:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

.confirmButton {
  padding: 10px 20px;
  background: var(--color-primary-accent, #38bdf8);
  border: none;
  border-radius: 8px;
  font-size: 0.875rem;
  font-weight: 600;
  color: #0f172a;
  cursor: pointer;
  transition: all 0.2s;
}

.confirmButton:hover:not(:disabled) {
  transform: translateY(-1px);
  box-shadow: 0 8px 20px -8px rgba(14, 165, 233, 0.7);
}

.confirmButton:disabled {
  background: rgba(71, 85, 105, 0.4);
  color: rgba(226, 232, 240, 0.65);
  cursor: not-allowed;
  transform: none;
  box-shadow: none;
}

.warning {
  display: flex;
  align-items: flex-start;
  gap: 10px;
  padding: 12px;
  background: rgba(245, 158, 11, 0.1);
  border: 1px solid rgba(245, 158, 11, 0.3);
  border-radius: 8px;
  font-size: 0.8125rem;
  color: #fbbf24;
}

.warningIcon {
  flex-shrink: 0;
  margin-top: 1px;
}

.warning strong {
  color: #fcd34d;
}
```

**Step 3: Verify TypeScript compiles**

Run: `npx tsc --noEmit`
Expected: No errors

**Step 4: Commit**

```bash
git add components/admin/EditDNSModal.tsx components/admin/EditDNSModal.module.css
git commit -m "feat(admin): add EditDNSModal component"
```

---

## Task 5: Integrate Components into DealerTable

**Files:**
- Modify: `components/admin/DealerTable.tsx`

**Step 1: Update DealerTable to use QuickActionsMenu and EditDNSModal**

Replace the entire `components/admin/DealerTable.tsx` file with:

```typescript
// components/admin/DealerTable.tsx
'use client';

/**
 * Dealer Table Component
 *
 * Paginated table of dealers with quick actions menu.
 */

import React, { useState } from 'react';
import styles from './DealerTable.module.css';
import { QuickActionsMenu } from './QuickActionsMenu';
import { EditDNSModal } from './EditDNSModal';

interface Dealer {
  id: string;
  userId: string;
  subdomain: string | null;
  domain: string;
  domainPrefix?: string;
  subscriptionTier: string;
  status: string;
  dealerNumber: string | null;
  businessName: string | null;
  createdAt: string;
  lastPublishedAt: string | null;
  user: {
    id: string;
    email: string;
    name: string | null;
    image: string | null;
    role: string;
  };
}

interface Pagination {
  page: number;
  limit: number;
  total: number;
  totalPages: number;
}

interface DealerTableProps {
  dealers: Dealer[];
  pagination: Pagination;
  onPageChange: (page: number) => void;
  onImpersonate: (userId: string) => void;
  onDealerUpdated?: () => void;
  isLoading?: boolean;
  isImpersonating?: string | null;
}

const TIER_COLORS: Record<string, string> = {
  starter: '#6b7280',
  growth: '#3b82f6',
  enhanced: '#8b5cf6',
  professional: '#f59e0b',
};

const STATUS_COLORS: Record<string, string> = {
  active: '#10b981',
  pending: '#f59e0b',
  suspended: '#ef4444',
  cancelled: '#6b7280',
  payment_failed: '#ef4444',
};

export function DealerTable({
  dealers,
  pagination,
  onPageChange,
  onImpersonate,
  onDealerUpdated,
  isLoading,
  isImpersonating,
}: DealerTableProps) {
  const [editingDealer, setEditingDealer] = useState<Dealer | null>(null);
  const [isEditDNSOpen, setIsEditDNSOpen] = useState(false);

  const formatDate = (dateStr: string) => {
    return new Date(dateStr).toLocaleDateString('en-US', {
      year: 'numeric',
      month: 'short',
      day: 'numeric',
    });
  };

  const handleEditDNS = (dealer: Dealer) => {
    setEditingDealer(dealer);
    setIsEditDNSOpen(true);
  };

  const handleSaveSubdomain = async (dealerId: string, newSubdomain: string) => {
    const response = await fetch(`/api/admin/dealers/${dealerId}/subdomain`, {
      method: 'PATCH',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ subdomain: newSubdomain }),
    });

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

    // Refresh the dealer list
    onDealerUpdated?.();
  };

  if (isLoading) {
    return (
      <div className={styles.loadingContainer}>
        <div className={styles.spinner}></div>
        <span>Loading dealers...</span>
      </div>
    );
  }

  if (dealers.length === 0) {
    return (
      <div className={styles.emptyState}>
        <svg
          className={styles.emptyIcon}
          viewBox="0 0 24 24"
          fill="none"
          stroke="currentColor"
          strokeWidth="1.5"
        >
          <path
            strokeLinecap="round"
            strokeLinejoin="round"
            d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"
          />
        </svg>
        <p>No dealers found</p>
        <span className={styles.emptyHint}>Try adjusting your search or filters</span>
      </div>
    );
  }

  return (
    <>
      <div className={styles.container}>
        <div className={styles.tableWrapper}>
          <table className={styles.table}>
            <thead>
              <tr>
                <th>Dealer</th>
                <th>Subdomain</th>
                <th>Tier</th>
                <th>Status</th>
                <th>Created</th>
                <th>Actions</th>
              </tr>
            </thead>
            <tbody>
              {dealers.map((dealer) => (
                <tr key={dealer.id}>
                  <td>
                    <div className={styles.dealerInfo}>
                      <div className={styles.dealerPrimary}>
                        {dealer.businessName || dealer.user.name || 'Unnamed'}
                      </div>
                      <div className={styles.dealerSecondary}>{dealer.user.email}</div>
                      {dealer.dealerNumber && (
                        <div className={styles.dealerNumber}>#{dealer.dealerNumber}</div>
                      )}
                    </div>
                  </td>
                  <td>
                    {dealer.subdomain ? (
                      <code className={styles.subdomain}>
                        {dealer.subdomain}.{dealer.domain === 'ca' ? 'myamsoil.ca' : 'myamsoil.com'}
                      </code>
                    ) : (
                      <span className={styles.noSubdomain}>Not set</span>
                    )}
                  </td>
                  <td>
                    <span
                      className={styles.badge}
                      style={{ backgroundColor: TIER_COLORS[dealer.subscriptionTier] || '#6b7280' }}
                    >
                      {dealer.subscriptionTier}
                    </span>
                  </td>
                  <td>
                    <span
                      className={styles.statusBadge}
                      style={{ borderColor: STATUS_COLORS[dealer.status] || '#6b7280' }}
                    >
                      <span
                        className={styles.statusDot}
                        style={{ backgroundColor: STATUS_COLORS[dealer.status] || '#6b7280' }}
                      ></span>
                      {dealer.status.replace('_', ' ')}
                    </span>
                  </td>
                  <td className={styles.dateCell}>{formatDate(dealer.createdAt)}</td>
                  <td>
                    <QuickActionsMenu
                      onStartSupport={() => onImpersonate(dealer.userId)}
                      onEditDNS={() => handleEditDNS(dealer)}
                      isStartingSupport={isImpersonating === dealer.userId}
                    />
                  </td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>

        {/* Pagination */}
        <div className={styles.pagination}>
          <div className={styles.paginationInfo}>
            Showing {(pagination.page - 1) * pagination.limit + 1} to{' '}
            {Math.min(pagination.page * pagination.limit, pagination.total)} of {pagination.total}{' '}
            dealers
          </div>
          <div className={styles.paginationControls}>
            <button
              className={styles.pageButton}
              onClick={() => onPageChange(pagination.page - 1)}
              disabled={pagination.page <= 1}
            >
              Previous
            </button>
            <span className={styles.pageIndicator}>
              Page {pagination.page} of {pagination.totalPages}
            </span>
            <button
              className={styles.pageButton}
              onClick={() => onPageChange(pagination.page + 1)}
              disabled={pagination.page >= pagination.totalPages}
            >
              Next
            </button>
          </div>
        </div>
      </div>

      <EditDNSModal
        isOpen={isEditDNSOpen}
        onClose={() => {
          setIsEditDNSOpen(false);
          setEditingDealer(null);
        }}
        dealer={editingDealer}
        onSave={handleSaveSubdomain}
      />
    </>
  );
}
```

**Step 2: Verify TypeScript compiles**

Run: `npx tsc --noEmit`
Expected: No errors

**Step 3: Commit**

```bash
git add components/admin/DealerTable.tsx
git commit -m "feat(admin): integrate QuickActionsMenu and EditDNSModal into DealerTable"
```

---

## Task 5.5: Add "View in Stripe" Menu Item

**Files:**
- Modify: `app/api/admin/dealers/route.ts` (add `stripeCustomerId` to response)
- Modify: `components/admin/DealerTable.tsx` (add to interface, pass to menu)
- Modify: `components/admin/QuickActionsMenu.tsx` (add menu item)

**Step 1: Update dealers API to include stripeCustomerId**

In `app/api/admin/dealers/route.ts`, add `stripeCustomerId` to the response mapping (around line 92-104):

```typescript
// Transform for response
const dealerList = dealers.map((dealer: DealerWithUser) => ({
  id: dealer.id,
  userId: dealer.userId,
  subdomain: dealer.subdomain,
  domain: dealer.domain,
  domainPrefix: dealer.domainPrefix,
  subscriptionTier: dealer.subscriptionTier,
  status: dealer.status,
  dealerNumber: dealer.dealerNumber,
  businessName: dealer.businessName,
  stripeCustomerId: dealer.stripeCustomerId,  // ADD THIS LINE
  createdAt: dealer.createdAt.toISOString(),
  lastPublishedAt: dealer.lastPublishedAt?.toISOString() || null,
  user: dealer.user,
}));
```

**Step 2: Update Dealer interface in DealerTable**

In `components/admin/DealerTable.tsx`, add `stripeCustomerId` to the Dealer interface:

```typescript
interface Dealer {
  id: string;
  userId: string;
  subdomain: string | null;
  domain: string;
  domainPrefix?: string;
  subscriptionTier: string;
  status: string;
  dealerNumber: string | null;
  businessName: string | null;
  stripeCustomerId: string | null;  // ADD THIS LINE
  createdAt: string;
  lastPublishedAt: string | null;
  user: {
    id: string;
    email: string;
    name: string | null;
    image: string | null;
    role: string;
  };
}
```

**Step 3: Update QuickActionsMenu props and add menu item**

Update `components/admin/QuickActionsMenu.tsx`:

```typescript
// Update interface
interface QuickActionsMenuProps {
  onStartSupport: () => void;
  onEditDNS: () => void;
  stripeCustomerId: string | null;  // ADD THIS
  isStartingSupport?: boolean;
}

// Update component signature
export function QuickActionsMenu({
  onStartSupport,
  onEditDNS,
  stripeCustomerId,  // ADD THIS
  isStartingSupport,
}: QuickActionsMenuProps) {
```

Add helper function to build Stripe URL (before the return statement):

```typescript
  // Build Stripe dashboard URL
  // Test keys start with sk_test_, live keys start with sk_live_
  const getStripeCustomerUrl = (customerId: string) => {
    const isTestMode = process.env.NEXT_PUBLIC_STRIPE_TEST_MODE === 'true';
    const basePath = isTestMode ? '/test' : '';
    return `https://dashboard.stripe.com${basePath}/customers/${customerId}`;
  };
```

Add menu item after "Start Support Session" button, before the divider:

```typescript
          <button
            className={styles.menuItem}
            onClick={() => {
              if (stripeCustomerId) {
                window.open(getStripeCustomerUrl(stripeCustomerId), '_blank');
              }
              setIsOpen(false);
            }}
            disabled={!stripeCustomerId}
            role="menuitem"
            title={!stripeCustomerId ? 'No Stripe customer ID - checkout may not be complete' : undefined}
          >
            <svg
              className={styles.menuIcon}
              width="16"
              height="16"
              viewBox="0 0 24 24"
              fill="none"
              stroke="currentColor"
              strokeWidth="2"
            >
              <path d="M21 4H3a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h18a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2z" />
              <path d="M1 10h22" />
            </svg>
            View in Stripe
            {!stripeCustomerId && (
              <svg
                className={styles.warningIcon}
                width="14"
                height="14"
                viewBox="0 0 24 24"
                fill="none"
                stroke="#f59e0b"
                strokeWidth="2"
              >
                <circle cx="12" cy="12" r="10" />
                <line x1="12" y1="8" x2="12" y2="12" />
                <line x1="12" y1="16" x2="12.01" y2="16" />
              </svg>
            )}
          </button>
```

**Step 4: Add warningIcon style to QuickActionsMenu.module.css**

```css
.warningIcon {
  margin-left: auto;
}
```

**Step 5: Update DealerTable to pass stripeCustomerId to menu**

In `components/admin/DealerTable.tsx`, update the QuickActionsMenu usage:

```typescript
<QuickActionsMenu
  onStartSupport={() => onImpersonate(dealer.userId)}
  onEditDNS={() => handleEditDNS(dealer)}
  stripeCustomerId={dealer.stripeCustomerId}  // ADD THIS LINE
  isStartingSupport={isImpersonating === dealer.userId}
/>
```

**Step 6: Add NEXT_PUBLIC_STRIPE_TEST_MODE to .env.local**

The implementing engineer should ensure `.env.local` has:
```
NEXT_PUBLIC_STRIPE_TEST_MODE=true  # or false for production
```

Alternatively, detect from `STRIPE_SECRET_KEY` prefix if preferred.

**Step 7: Verify and commit**

Run: `npx tsc --noEmit`
Expected: No errors

```bash
git add app/api/admin/dealers/route.ts components/admin/DealerTable.tsx components/admin/QuickActionsMenu.tsx components/admin/QuickActionsMenu.module.css
git commit -m "feat(admin): add View in Stripe menu item with missing customer warning"
```

---

## Task 6: Update Admin Page to Pass Refresh Callback

**Files:**
- Modify: `app/admin/page.tsx`

**Step 1: Read current admin page**

First read the current implementation to understand where to add the `onDealerUpdated` callback.

**Step 2: Add refresh callback to DealerTable**

Find the `<DealerTable>` component usage and add the `onDealerUpdated` prop that triggers a data refresh. The exact implementation depends on how data fetching is currently done (likely via `useState` + `useEffect` or React Query).

Look for the pattern used and add:
```typescript
onDealerUpdated={() => fetchDealers()}
```

or if using React Query:
```typescript
onDealerUpdated={() => refetch()}
```

**Step 3: Verify and commit**

Run: `npx tsc --noEmit`
Expected: No errors

```bash
git add app/admin/page.tsx
git commit -m "feat(admin): add onDealerUpdated callback to refresh dealer list"
```

---

## Task 7: Manual Testing Checklist

**Step 1: Start the dev server**

```bash
npm run power-cycle
```

**Step 2: Test scenarios**

1. **View quick actions menu:**
   - Navigate to `/admin`
   - Click the ⋮ button on any dealer row
   - Verify dropdown shows "Start Support Session" and "Edit DNS"

2. **Edit DNS for dealer WITHOUT subdomain:**
   - Find a dealer with "Not set" in subdomain column
   - Click ⋮ → Edit DNS
   - Verify modal shows only input field (no warning)
   - Enter valid subdomain → Confirm
   - Verify subdomain updates in table

3. **Edit DNS for dealer WITH subdomain:**
   - Find a dealer with existing subdomain
   - Click ⋮ → Edit DNS
   - Verify modal shows current subdomain
   - Enter new subdomain → Confirm
   - Verify warning appears below buttons
   - Verify subdomain updates in table

4. **Validation errors:**
   - Try subdomain < 3 chars → should show error
   - Try subdomain with uppercase → should auto-lowercase
   - Try subdomain starting with hyphen → should show error

5. **Duplicate subdomain:**
   - Try setting subdomain already used by another dealer
   - Should show "Subdomain is already in use" error

**Step 3: Commit any fixes**

```bash
git add -A
git commit -m "fix(admin): address issues found during manual testing"
```

---

## Task 8: Final Commit and Push

**Step 1: Run full test suite**

```bash
npm test
```

**Step 2: Run type check**

```bash
npx tsc --noEmit
```

**Step 3: Run build**

```bash
npm run build
```

**Step 4: Push branch**

```bash
git push -u origin feature/admin-subdomain-editor
```

---

## Summary

| Task | Description | Files |
|------|-------------|-------|
| 1 | Subdomain validation utility | `lib/subdomain-validation.ts` |
| 2 | PATCH API endpoint | `app/api/admin/dealers/[id]/subdomain/route.ts` |
| 3 | QuickActionsMenu component | `components/admin/QuickActionsMenu.tsx` |
| 4 | EditDNSModal component | `components/admin/EditDNSModal.tsx` |
| 5 | DealerTable integration | `components/admin/DealerTable.tsx` |
| 5.5 | View in Stripe menu item | `QuickActionsMenu.tsx`, `DealerTable.tsx`, dealers API |
| 6 | Admin page refresh callback | `app/admin/page.tsx` |
| 7 | Manual testing | - |
| 8 | Final verification and push | - |
