← back to ClawCoder

src/app/marketplace/page.tsx

395 lines

'use client'

import { useState, useEffect, useRef, useCallback } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import type { MarketplaceListing } from '@/lib/marketplace/data'

// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------

type CategoryFilter = 'all' | 'skill' | 'subagent' | 'mcp-server' | 'hook' | 'template'

const CATEGORY_TABS: { id: CategoryFilter; label: string; color: string }[] = [
  { id: 'all', label: 'All', color: 'bg-slate-100 text-slate-700' },
  { id: 'skill', label: 'Skills', color: 'bg-violet-100 text-violet-700' },
  { id: 'subagent', label: 'Subagents', color: 'bg-amber-100 text-amber-700' },
  { id: 'mcp-server', label: 'MCP Servers', color: 'bg-sky-100 text-sky-700' },
  { id: 'hook', label: 'Hooks', color: 'bg-rose-100 text-rose-700' },
  { id: 'template', label: 'Templates', color: 'bg-emerald-100 text-emerald-700' },
]

const CATEGORY_COLORS: Record<string, string> = {
  skill: 'bg-violet-100 text-violet-700',
  subagent: 'bg-amber-100 text-amber-700',
  'mcp-server': 'bg-sky-100 text-sky-700',
  hook: 'bg-rose-100 text-rose-700',
  template: 'bg-emerald-100 text-emerald-700',
}

const TRUST_BADGES: Record<string, { label: string; className: string }> = {
  official: { label: 'Official', className: 'bg-green-100 text-green-700 ring-1 ring-green-300' },
  verified: { label: 'Verified', className: 'bg-blue-100 text-blue-700 ring-1 ring-blue-300' },
  community: { label: 'Community', className: 'bg-slate-100 text-slate-600 ring-1 ring-slate-300' },
}

const PROFILE_OPTIONS = [
  { value: '', label: 'All profiles' },
  { value: 'teen-beginner', label: 'Teen Beginner' },
  { value: 'teen-intermediate', label: 'Teen Intermediate' },
  { value: 'teen-expert', label: 'Teen Expert' },
  { value: 'young-adult-beginner', label: 'Young Adult Beginner' },
  { value: 'young-adult-intermediate', label: 'Young Adult Intermediate' },
  { value: 'young-adult-expert', label: 'Young Adult Expert' },
  { value: 'professional-beginner', label: 'Professional Beginner' },
  { value: 'professional-intermediate', label: 'Professional Intermediate' },
  { value: 'professional-expert', label: 'Professional Expert' },
  { value: 'pre-senior-beginner', label: 'Pre-Senior Beginner' },
  { value: 'pre-senior-intermediate', label: 'Pre-Senior Intermediate' },
  { value: 'pre-senior-expert', label: 'Pre-Senior Expert' },
  { value: 'senior-beginner', label: 'Senior Beginner' },
  { value: 'senior-intermediate', label: 'Senior Intermediate' },
  { value: 'senior-expert', label: 'Senior Expert' },
]

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

function formatStars(n?: number): string {
  if (!n) return ''
  if (n >= 1000) return `${(n / 1000).toFixed(1)}k`
  return String(n)
}

// ---------------------------------------------------------------------------
// Listing Card
// ---------------------------------------------------------------------------

function ListingCard({ listing }: { listing: MarketplaceListing }) {
  const [copied, setCopied] = useState(false)

  const handleCopy = async () => {
    try {
      await navigator.clipboard.writeText(listing.installCmd)
      setCopied(true)
      setTimeout(() => setCopied(false), 2000)
    } catch {
      // Fallback for insecure contexts
      const textarea = document.createElement('textarea')
      textarea.value = listing.installCmd
      document.body.appendChild(textarea)
      textarea.select()
      document.execCommand('copy')
      document.body.removeChild(textarea)
      setCopied(true)
      setTimeout(() => setCopied(false), 2000)
    }
  }

  const badge = TRUST_BADGES[listing.trustLevel]
  const catColor = CATEGORY_COLORS[listing.category] || 'bg-slate-100 text-slate-600'
  const maxTags = 3

  return (
    <motion.div
      layout
      initial={{ opacity: 0, y: 12 }}
      animate={{ opacity: 1, y: 0 }}
      exit={{ opacity: 0, y: -8, transition: { duration: 0.15 } }}
      transition={{ duration: 0.25 }}
      className="group flex flex-col rounded-xl border border-slate-200 bg-white shadow-sm transition-shadow hover:shadow-md"
    >
      {/* Header */}
      <div className="flex items-start justify-between gap-2 p-5 pb-3">
        <div className="min-w-0 flex-1">
          <h3 className="truncate text-base font-semibold text-slate-900 group-hover:text-blue-600 transition-colors">
            {listing.name}
          </h3>
          <div className="mt-1.5 flex flex-wrap items-center gap-1.5">
            <span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide ${catColor}`}>
              {listing.category.replace('-', ' ')}
            </span>
            <span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${badge.className}`}>
              {badge.label}
            </span>
          </div>
        </div>
        {listing.stars && (
          <div className="flex shrink-0 items-center gap-1 rounded-md bg-slate-50 px-2 py-1 text-xs text-slate-500">
            <svg className="h-3.5 w-3.5 text-amber-400" fill="currentColor" viewBox="0 0 20 20">
              <path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
            </svg>
            <span>{formatStars(listing.stars)}</span>
          </div>
        )}
      </div>

      {/* Description */}
      <p className="px-5 text-sm leading-relaxed text-slate-600 line-clamp-2">
        {listing.description}
      </p>

      {/* Author + Tags */}
      <div className="mt-auto space-y-3 px-5 pt-3 pb-4">
        <div className="flex items-center gap-2 text-xs text-slate-500">
          <svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
            <path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
          </svg>
          <span>{listing.author}</span>
        </div>

        {listing.tags.length > 0 && (
          <div className="flex flex-wrap gap-1">
            {listing.tags.slice(0, maxTags).map((tag) => (
              <span
                key={tag}
                className="rounded-md bg-slate-100 px-1.5 py-0.5 text-[10px] font-medium text-slate-500"
              >
                {tag}
              </span>
            ))}
            {listing.tags.length > maxTags && (
              <span className="rounded-md bg-slate-50 px-1.5 py-0.5 text-[10px] text-slate-400">
                +{listing.tags.length - maxTags}
              </span>
            )}
          </div>
        )}
      </div>

      {/* Actions */}
      <div className="flex items-center gap-2 border-t border-slate-100 px-5 py-3">
        <button
          type="button"
          onClick={handleCopy}
          className="flex min-h-[36px] flex-1 items-center justify-center gap-1.5 rounded-lg bg-blue-600 px-3 text-xs font-medium text-white transition-colors hover:bg-blue-700 active:bg-blue-800"
        >
          {copied ? (
            <>
              <svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
                <path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
              </svg>
              Copied!
            </>
          ) : (
            <>
              <svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
                <path strokeLinecap="round" strokeLinejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25z" />
              </svg>
              Install
            </>
          )}
        </button>
        <a
          href={listing.source}
          target="_blank"
          rel="noopener noreferrer"
          className="flex min-h-[36px] items-center gap-1 rounded-lg border border-slate-200 px-3 text-xs font-medium text-slate-600 transition-colors hover:bg-slate-50"
        >
          <svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
            <path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
          </svg>
          Source
        </a>
      </div>
    </motion.div>
  )
}

// ---------------------------------------------------------------------------
// Main Page
// ---------------------------------------------------------------------------

export default function MarketplacePage() {
  const [listings, setListings] = useState<MarketplaceListing[]>([])
  const [loading, setLoading] = useState(true)
  const [activeCategory, setActiveCategory] = useState<CategoryFilter>('all')
  const [searchQuery, setSearchQuery] = useState('')
  const [profileFilter, setProfileFilter] = useState('')
  const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)

  const fetchListings = useCallback(async (query: string, category: CategoryFilter, profile: string) => {
    setLoading(true)
    try {
      const params = new URLSearchParams()
      if (query.trim()) params.set('q', query.trim())
      if (category !== 'all') params.set('category', category)
      if (profile) params.set('profile', profile)

      const qs = params.toString()
      const res = await fetch(`/api/marketplace${qs ? `?${qs}` : ''}`)
      if (res.ok) {
        const data = await res.json()
        setListings(data)
      }
    } catch {
      // silent
    } finally {
      setLoading(false)
    }
  }, [])

  // Initial load
  useEffect(() => {
    fetchListings('', 'all', '')
  }, [fetchListings])

  // Debounced search
  const handleSearch = (value: string) => {
    setSearchQuery(value)
    if (debounceRef.current) clearTimeout(debounceRef.current)
    debounceRef.current = setTimeout(() => {
      fetchListings(value, activeCategory, profileFilter)
    }, 300)
  }

  // Category change
  const handleCategoryChange = (cat: CategoryFilter) => {
    setActiveCategory(cat)
    fetchListings(searchQuery, cat, profileFilter)
  }

  // Profile change
  const handleProfileChange = (profile: string) => {
    setProfileFilter(profile)
    fetchListings(searchQuery, activeCategory, profile)
  }

  // Count per category (from current query results, or show total without filter)
  const categoryCounts: Record<string, number> = { all: listings.length }
  for (const l of listings) {
    categoryCounts[l.category] = (categoryCounts[l.category] || 0) + 1
  }

  return (
    <div className="mx-auto w-full max-w-7xl px-4 py-8 sm:px-6">
      {/* Header */}
      <div className="mb-8">
        <h1 className="text-3xl font-bold tracking-tight text-slate-900 sm:text-4xl">
          Marketplace
        </h1>
        <p className="mt-2 text-base text-slate-600 sm:text-lg">
          Discover skills, subagents, MCP servers, hooks, and templates from the open-source Claude Code ecosystem.
        </p>
      </div>

      {/* Search + Profile filter */}
      <div className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center">
        <div className="relative flex-1">
          <svg
            className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400"
            fill="none"
            viewBox="0 0 24 24"
            strokeWidth={2}
            stroke="currentColor"
          >
            <path
              strokeLinecap="round"
              strokeLinejoin="round"
              d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
            />
          </svg>
          <input
            type="text"
            value={searchQuery}
            onChange={(e) => handleSearch(e.target.value)}
            placeholder="Search by name, tag, or description..."
            className="h-11 w-full rounded-xl border border-slate-300 bg-white pl-10 pr-4 text-sm text-slate-800 placeholder:text-slate-400 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20"
          />
        </div>
        <div className="flex items-center gap-2">
          <label htmlFor="profile-filter" className="whitespace-nowrap text-xs font-medium text-slate-500">
            Recommended for:
          </label>
          <select
            id="profile-filter"
            value={profileFilter}
            onChange={(e) => handleProfileChange(e.target.value)}
            className="h-11 rounded-xl border border-slate-300 bg-white px-3 text-sm text-slate-700 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20"
          >
            {PROFILE_OPTIONS.map((opt) => (
              <option key={opt.value} value={opt.value}>
                {opt.label}
              </option>
            ))}
          </select>
        </div>
      </div>

      {/* Category Tabs */}
      <div className="mb-6 -mx-4 overflow-x-auto px-4 sm:mx-0 sm:px-0">
        <div className="flex gap-2">
          {CATEGORY_TABS.map((tab) => {
            const isActive = activeCategory === tab.id
            return (
              <button
                key={tab.id}
                type="button"
                onClick={() => handleCategoryChange(tab.id)}
                className={`flex shrink-0 items-center gap-1.5 rounded-full px-4 py-2 text-sm font-medium transition-all ${
                  isActive
                    ? 'bg-blue-600 text-white shadow-sm'
                    : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
                }`}
              >
                {tab.label}
                {activeCategory === 'all' && tab.id !== 'all' && categoryCounts[tab.id] !== undefined && (
                  <span className={`rounded-full px-1.5 py-0 text-[10px] ${
                    isActive ? 'bg-blue-500 text-blue-100' : 'bg-slate-200 text-slate-500'
                  }`}>
                    {categoryCounts[tab.id]}
                  </span>
                )}
              </button>
            )
          })}
        </div>
      </div>

      {/* Results */}
      {loading ? (
        <div className="flex items-center justify-center py-20">
          <div className="h-8 w-8 animate-spin rounded-full border-2 border-slate-300 border-t-blue-600" />
        </div>
      ) : listings.length === 0 ? (
        <div className="flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-slate-200 py-20">
          <svg
            className="mb-4 h-12 w-12 text-slate-300"
            fill="none"
            viewBox="0 0 24 24"
            strokeWidth={1}
            stroke="currentColor"
          >
            <path
              strokeLinecap="round"
              strokeLinejoin="round"
              d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
            />
          </svg>
          <p className="text-base font-medium text-slate-500">No results found</p>
          <p className="mt-1 text-sm text-slate-400">Try a different search or category.</p>
        </div>
      ) : (
        <AnimatePresence mode="popLayout">
          <motion.div
            layout
            className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"
          >
            {listings.map((listing) => (
              <ListingCard key={listing.id} listing={listing} />
            ))}
          </motion.div>
        </AnimatePresence>
      )}

      {/* Footer count */}
      {!loading && listings.length > 0 && (
        <p className="mt-6 text-center text-xs text-slate-400">
          Showing {listings.length} listing{listings.length !== 1 ? 's' : ''}
        </p>
      )}
    </div>
  )
}