Airbnb風宿泊検索サイトの実装

# Airbnb風宿泊検索サイトの実装 - Next.js 14 + App Router + Shadcn + Supabase + Prisma

画像を忠実に再現した宿泊検索サイトを、最新のNext.js 14とApp Router、Shadcn UI、Supabase、Prismaを使って実装ほうほ。

## プロジェクト構成

```
airbnb-clone/
├── .env                    # 環境変数
├── .env.example            # 環境変数のサンプル
├── prisma/
│   ├── schema.prisma       # Prismaスキーマ
│   └── seed.ts             # サンプルデータ作成スクリプト
├── public/
│   └── images/
│       ├── logo.png
│       ├── categories/     # カテゴリーアイコン
│       └── accommodations/ # 宿泊施設画像
├── src/
│   ├── app/
│   │   ├── api/
│   │   │   └── search/
│   │   │       └── route.ts # 検索API
│   │   ├── search/
│   │   │   └── page.tsx     # 検索結果ページ
│   │   ├── layout.tsx       # ルートレイアウト
│   │   └── page.tsx         # ホームページ
│   ├── components/
│   │   ├── ui/              # Shadcn UIコンポーネント
│   │   ├── layout/
│   │   │   ├── header.tsx
│   │   │   └── footer.tsx
│   │   ├── search/
│   │   │   ├── search-form.tsx
│   │   │   └── search-results.tsx
│   │   ├── accommodation/
│   │   │   └── accommodation-card.tsx
│   │   └── category/
│   │       └── category-list.tsx
│   ├── lib/
│   │   ├── prisma.ts        # Prismaクライアント
│   │   ├── supabase.ts      # Supabaseクライアント
│   │   └── utils.ts         # ユーティリティ関数
│   ├── types/
│   │   └── index.ts         # 型定義
│   └── actions/
│       └── search-actions.ts # サーバーアクション
└── package.json
```

## 環境設定

### 1. プロジェクト作成

```bash
npx create-next-app@latest airbnb-clone --typescript --tailwind --app
cd airbnb-clone
```

### 2. 必要なパッケージのインストール

```bash
# Shadcn UI
npm install shadcn-ui @radix-ui/react-slot @radix-ui/react-dialog @radix-ui/react-popover @radix-ui/react-toggle @radix-ui/react-checkbox @radix-ui/react-select @radix-ui/react-tabs class-variance-authority clsx tailwind-merge lucide-react

# Prisma
npm install prisma @prisma/client
npx prisma init

# Supabase
npm install @supabase/supabase-js

# その他のユーティリティ
npm install date-fns react-day-picker react-icons
```

### 3. Shadcn UIのセットアップ

```bash
npx shadcn-ui@latest init
```

設定オプション:
- Style: Default
- Base color: Slate
- Global CSS: src/app/globals.css
- CSS variables: Yes
- React Server Components: Yes
- Components directory: @/components/ui
- Utility folder: @/lib/utils

### 4. Supabaseのセットアップ

1. Supabaseアカウントを作成し、新しいプロジェクトを作成
2. `.env`ファイルに以下の環境変数を追加:

```
NEXT_PUBLIC_SUPABASE_URL=https://your-project-url.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
```

### 5. Prismaのセットアップ

`prisma/schema.prisma`を編集:

```prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model Category {
  id              Int             @id @default(autoincrement())
  name            String          @unique
  iconUrl         String
  accommodations  Accommodation[]
  createdAt       DateTime        @default(now())
  updatedAt       DateTime        @updatedAt
}

model Accommodation {
  id                Int      @id @default(autoincrement())
  name              String
  description       String?
  prefecture        String
  city              String
  imageUrls         String[]
  pricePerNight     Decimal  @db.Decimal(10, 2)
  rating            Float
  reviewCount       Int      @default(0)
  guestChoice       Boolean  @default(false)
  availableDateFrom DateTime
  availableDateTo   DateTime
  categoryId        Int
  category          Category @relation(fields: [categoryId], references: [id])
  createdAt         DateTime @default(now())
  updatedAt         DateTime @updatedAt
}
```

データベースURLを`.env`に追加:

```
DATABASE_URL="postgresql://postgres:password@localhost:5432/airbnb_clone"
```

Supabaseを使用する場合は、Supabaseが提供するPostgreSQLのURLを使用します。

### 6. Prismaクライアントの生成とデータベースマイグレーション

```bash
npx prisma migrate dev --name init
```

## 実装コード

### 1. Prismaクライアント設定

```typescript
// src/lib/prisma.ts
import { PrismaClient } from '@prisma/client';

const globalForPrisma = global as unknown as { prisma: PrismaClient };

export const prisma = globalForPrisma.prisma || new PrismaClient();

if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;

export default prisma;
```

### 2. Supabaseクライアント設定

```typescript
// src/lib/supabase.ts
import { createClient } from '@supabase/supabase-js';

const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;

export const supabase = createClient(supabaseUrl, supabaseAnonKey);
```

### 3. 型定義

```typescript
// src/types/index.ts
export type Category = {
  id: number;
  name: string;
  iconUrl: string;
};

export type Accommodation = {
  id: number;
  name: string;
  description?: string;
  prefecture: string;
  city: string;
  imageUrls: string[];
  pricePerNight: number;
  rating: number;
  reviewCount: number;
  guestChoice: boolean;
  availableDateFrom: Date;
  availableDateTo: Date;
  categoryId: number;
  category?: Category;
};

export type SearchCriteria = {
  location?: string;
  checkInDate?: Date;
  checkOutDate?: Date;
  guestCount?: number;
  categoryId?: number;
};
```

### 4. サーバーアクション

```typescript
// src/actions/search-actions.ts
'use server';

import { prisma } from '@/lib/prisma';
import { SearchCriteria } from '@/types';
import { revalidatePath } from 'next/cache';

export async function searchAccommodations(criteria: SearchCriteria) {
  try {
    const { location, checkInDate, checkOutDate, categoryId } = criteria;
    
    const whereClause: any = {};
    
    if (location) {
      whereClause.OR = [
        { prefecture: { contains: location, mode: 'insensitive' } },
        { city: { contains: location, mode: 'insensitive' } }
      ];
    }
    
    if (checkInDate && checkOutDate) {
      whereClause.AND = [
        { availableDateFrom: { lte: checkOutDate } },
        { availableDateTo: { gte: checkInDate } }
      ];
    }
    
    if (categoryId) {
      whereClause.categoryId = categoryId;
    }
    
    const accommodations = await prisma.accommodation.findMany({
      where: whereClause,
      include: {
        category: true
      },
      orderBy: {
        rating: 'desc'
      }
    });
    
    return accommodations;
  } catch (error) {
    console.error('Search error:', error);
    throw new Error('Failed to search accommodations');
  }
}

export async function getCategories() {
  try {
    const categories = await prisma.category.findMany({
      orderBy: {
        name: 'asc'
      }
    });
    
    return categories;
  } catch (error) {
    console.error('Error fetching categories:', error);
    throw new Error('Failed to fetch categories');
  }
}

export async function getAccommodationsByCategory(categoryId: number) {
  try {
    const accommodations = await prisma.accommodation.findMany({
      where: {
        categoryId
      },
      include: {
        category: true
      },
      orderBy: {
        rating: 'desc'
      }
    });
    
    revalidatePath('/');
    return accommodations;
  } catch (error) {
    console.error('Error fetching accommodations by category:', error);
    throw new Error('Failed to fetch accommodations by category');
  }
}
```

### 5. レイアウトとページ

```typescript
// src/app/layout.tsx
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import Header from '@/components/layout/header';

const inter = Inter({ subsets: ['latin'] });

export const metadata: Metadata = {
  title: 'Airbnb Clone',
  description: '宿泊施設検索サイト',
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="ja">
      <body className={inter.className}>
        <Header />
        <main>{children}</main>
      </body>
    </html>
  );
}
```

```typescript
// src/app/page.tsx
import { getCategories, searchAccommodations } from '@/actions/search-actions';
import AccommodationCard from '@/components/accommodation/accommodation-card';
import CategoryList from '@/components/category/category-list';
import SearchForm from '@/components/search/search-form';

export default async function Home() {
  const categories = await getCategories();
  const featuredAccommodations = await searchAccommodations({ 
    // 初期表示用のおすすめ宿泊施設
  });
  
  return (
    <div className="container mx-auto px-4 py-8">
      <div className="flex justify-center mb-8">
        <SearchForm />
      </div>
      
      <CategoryList categories={categories} />
      
      <section className="mt-8">
        <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
          {featuredAccommodations.map((accommodation) => (
            <AccommodationCard 
              key={accommodation.id} 
              accommodation={accommodation} 
            />
          ))}
        </div>
      </section>
    </div>
  );
}
```

```typescript
// src/app/search/page.tsx
import { getCategories, searchAccommodations } from '@/actions/search-actions';
import AccommodationCard from '@/components/accommodation/accommodation-card';
import CategoryList from '@/components/category/category-list';
import SearchForm from '@/components/search/search-form';
import { SearchCriteria } from '@/types';

export default async function SearchPage({
  searchParams,
}: {
  searchParams: { [key: string]: string | string[] | undefined };
}) {
  const location = searchParams.location as string;
  const checkInDate = searchParams.checkInDate ? new Date(searchParams.checkInDate as string) : undefined;
  const checkOutDate = searchParams.checkOutDate ? new Date(searchParams.checkOutDate as string) : undefined;
  const guestCount = searchParams.guestCount ? parseInt(searchParams.guestCount as string) : undefined;
  const categoryId = searchParams.categoryId ? parseInt(searchParams.categoryId as string) : undefined;
  
  const searchCriteria: SearchCriteria = {
    location,
    checkInDate,
    checkOutDate,
    guestCount,
    categoryId
  };
  
  const [categories, searchResults] = await Promise.all([
    getCategories(),
    searchAccommodations(searchCriteria)
  ]);
  
  return (
    <div className="container mx-auto px-4 py-8">
      <div className="flex justify-center mb-8">
        <SearchForm initialValues={searchCriteria} />
      </div>
      
      <CategoryList 
        categories={categories} 
        selectedCategoryId={categoryId} 
      />
      
      <div className="flex justify-between items-center mt-8 mb-4">
        <button className="px-4 py-2 border rounded-full flex items-center gap-2">
          <span>フィルター</span>
        </button>
        
        <div className="flex items-center gap-2">
          <span>合計金額（税抜き）を表示</span>
          <div className="relative inline-block w-10 h-6 transition duration-200 ease-in-out rounded-full">
            <input type="checkbox" className="absolute w-6 h-6 rounded-full bg-white border-2 appearance-none cursor-pointer peer checked:right-0 checked:bg-blue-500" />
            <span className="block w-full h-full rounded-full bg-gray-300 peer-checked:bg-blue-300"></span>
          </div>
        </div>
      </div>
      
      <section>
        {searchResults.length === 0 ? (
          <div className="text-center py-12">
            <p className="text-gray-500">検索条件に一致する宿泊施設が見つかりませんでした。</p>
          </div>
        ) : (
          <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
            {searchResults.map((accommodation) => (
              <AccommodationCard 
                key={accommodation.id} 
                accommodation={accommodation} 
              />
            ))}
          </div>
        )}
      </section>
    </div>
  );
}
```

### 6. コンポーネント

```typescript
// src/components/layout/header.tsx
import Image from 'next/image';
import Link from 'next/link';
import { FaGlobe, FaBars, FaUserCircle } from 'react-icons/fa';

export default function Header() {
  return (
    <header className="border-b border-gray
**user**: 続けて
**claude-api**: ```typescript
// src/components/layout/header.tsx (続き)
export default function Header() {
  return (
    <header className="border-b border-gray-200 py-4">
      <div className="container mx-auto px-4 flex justify-between items-center">
        <Link href="/" className="flex items-center">
          <Image 
            src="/images/logo.png" 
            alt="Airbnb" 
            width={102} 
            height={32} 
            className="h-8 w-auto"
          />
        </Link>
        
        <div className="hidden md:flex items-center space-x-4">
          <Link href="/" className="font-medium text-sm">宿泊</Link>
          <Link href="/experiences" className="font-medium text-sm">体験</Link>
        </div>
        
        <div className="flex items-center space-x-4">
          <button className="hidden md:flex items-center text-sm font-medium">
            ホストモードへ
          </button>
          <button className="p-2 rounded-full hover:bg-gray-100">
            <FaGlobe className="h-5 w-5" />
          </button>
          <div className="flex items-center border rounded-full p-1 shadow-sm hover:shadow-md">
            <FaBars className="h-4 w-4 mx-2" />
            <FaUserCircle className="h-8 w-8 text-gray-500" />
          </div>
        </div>
      </div>
    </header>
  );
}
```

```typescript
// src/components/search/search-form.tsx
'use client';

import { useRouter, useSearchParams } from 'next/navigation';
import { useState, useEffect } from 'react';
import { FaSearch } from 'react-icons/fa';
import { SearchCriteria } from '@/types';
import { format } from 'date-fns';
import { ja } from 'date-fns/locale';

interface SearchFormProps {
  initialValues?: SearchCriteria;
}

export default function SearchForm({ initialValues }: SearchFormProps) {
  const router = useRouter();
  const searchParams = useSearchParams();
  
  const [location, setLocation] = useState(initialValues?.location || '');
  const [checkInDate, setCheckInDate] = useState<Date | undefined>(initialValues?.checkInDate);
  const [checkOutDate, setCheckOutDate] = useState<Date | undefined>(initialValues?.checkOutDate);
  const [guestCount, setGuestCount] = useState(initialValues?.guestCount || 1);
  
  useEffect(() => {
    if (initialValues) {
      setLocation(initialValues.location || '');
      setCheckInDate(initialValues.checkInDate);
      setCheckOutDate(initialValues.checkOutDate);
      setGuestCount(initialValues.guestCount || 1);
    }
  }, [initialValues]);
  
  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    
    const params = new URLSearchParams();
    if (location) params.set('location', location);
    if (checkInDate) params.set('checkInDate', checkInDate.toISOString());
    if (checkOutDate) params.set('checkOutDate', checkOutDate.toISOString());
    if (guestCount) params.set('guestCount', guestCount.toString());
    
    const categoryId = searchParams.get('categoryId');
    if (categoryId) params.set('categoryId', categoryId);
    
    router.push(`/search?${params.toString()}`);
  };
  
  return (
    <div className="w-full max-w-4xl bg-white rounded-full shadow-md">
      <form onSubmit={handleSubmit} className="flex items-center">
        <div className="flex-1 px-6 py-3 border-r border-gray-200">
          <label className="block text-xs font-bold">ロケーション</label>
          <input
            type="text"
            value={location}
            onChange={(e) => setLocation(e.target.value)}
            placeholder="目的地を検索"
            className="w-full outline-none text-sm"
          />
        </div>
        
        <div className="flex-1 px-6 py-3 border-r border-gray-200">
          <label className="block text-xs font-bold">チェックイン</label>
          <input
            type="date"
            value={checkInDate ? format(checkInDate, 'yyyy-MM-dd') : ''}
            onChange={(e) => setCheckInDate(e.target.value ? new Date(e.target.value) : undefined)}
            className="w-full outline-none text-sm"
          />
        </div>
        
        <div className="flex-1 px-6 py-3 border-r border-gray-200">
          <label className="block text-xs font-bold">チェックアウト</label>
          <input
            type="date"
            value={checkOutDate ? format(checkOutDate, 'yyyy-MM-dd') : ''}
            onChange={(e) => setCheckOutDate(e.target.value ? new Date(e.target.value) : undefined)}
            className="w-full outline-none text-sm"
          />
        </div>
        
        <div className="flex-1 px-6 py-3">
          <label className="block text-xs font-bold">旅行者</label>
          <input
            type="number"
            min="1"
            value={guestCount}
            onChange={(e) => setGuestCount(parseInt(e.target.value))}
            className="w-full outline-none text-sm"
          />
        </div>
        
        <button
          type="submit"
          className="bg-rose-500 text-white p-4 rounded-full mx-2 hover:bg-rose-600 transition"
        >
          <FaSearch className="h-5 w-5" />
        </button>
      </form>
    </div>
  );
}
```

```typescript
// src/components/category/category-list.tsx
'use client';

import { Category } from '@/types';
import Image from 'next/image';
import { useRouter, useSearchParams } from 'next/navigation';
import { useCallback } from 'react';

interface CategoryListProps {
  categories: Category[];
  selectedCategoryId?: number;
}

export default function CategoryList({ categories, selectedCategoryId }: CategoryListProps) {
  const router = useRouter();
  const searchParams = useSearchParams();
  
  const createQueryString = useCallback(
    (name: string, value: string) => {
      const params = new URLSearchParams(searchParams.toString());
      params.set(name, value);
      return params.toString();
    },
    [searchParams]
  );
  
  const handleCategoryClick = (categoryId: number) => {
    if (selectedCategoryId === categoryId) {
      // 選択解除
      const params = new URLSearchParams(searchParams.toString());
      params.delete('categoryId');
      router.push(`${window.location.pathname}?${params.toString()}`);
    } else {
      // カテゴリー選択
      router.push(`${window.location.pathname}?${createQueryString('categoryId', categoryId.toString())}`);
    }
  };
  
  return (
    <div className="flex overflow-x-auto space-x-8 py-4 border-b border-gray-200 no-scrollbar">
      {categories.map((category) => (
        <div
          key={category.id}
          className={`flex flex-col items-center cursor-pointer min-w-[70px] pb-2 ${
            selectedCategoryId === category.id 
              ? 'opacity-100 border-b-2 border-black' 
              : 'opacity-60 hover:opacity-100'
          }`}
          onClick={() => handleCategoryClick(category.id)}
        >
          <div className="w-6 h-6 mb-2">
            <Image
              src={category.iconUrl}
              alt={category.name}
              width={24}
              height={24}
            />
          </div>
          <span className="text-xs whitespace-nowrap">{category.name}</span>
        </div>
      ))}
    </div>
  );
}
```

```typescript
// src/components/accommodation/accommodation-card.tsx
'use client';

import { Accommodation } from '@/types';
import Image from 'next/image';
import { useState } from 'react';
import { FaHeart, FaRegHeart } from 'react-icons/fa';
import { format } from 'date-fns';
import { ja } from 'date-fns/locale';

interface AccommodationCardProps {
  accommodation: Accommodation;
}

export default function AccommodationCard({ accommodation }: AccommodationCardProps) {
  const [isFavorite, setIsFavorite] = useState(false);
  const [currentImageIndex, setCurrentImageIndex] = useState(0);
  
  const toggleFavorite = (e: React.MouseEvent) => {
    e.preventDefault();
    setIsFavorite(!isFavorite);
  };
  
  const formatDateRange = () => {
    const from = format(new Date(accommodation.availableDateFrom), 'M月d日', { locale: ja });
    const to = format(new Date(accommodation.availableDateTo), 'd日', { locale: ja });
    return `${from}〜${to}`;
  };
  
  return (
    <div className="group">
      <div className="relative aspect-square rounded-xl overflow-hidden mb-2">
        <Image
          src={accommodation.imageUrls[currentImageIndex]}
          alt={accommodation.name}
          fill
          className="object-cover"
        />
        <button
          onClick={toggleFavorite}
          className="absolute top-3 right-3 z-10"
        >
          {isFavorite ? (
            <FaHeart className="h-6 w-6 text-rose-500" />
          ) : (
            <FaRegHeart className="h-6 w-6 text-white drop-shadow-md" />
          )}
        </button>
        
        {accommodation.guestChoice && (
          <div className="absolute top-3 left-3 bg-white/80 px-2 py-1 rounded text-xs font-medium">
            ゲストチョイス
          </div>
        )}
      </div>
      
      <div>
        <div className="flex justify-between items-start">
          <h3 className="font-medium text-sm">
            {accommodation.prefecture}, {accommodation.city}
          </h3>
          <div className="flex items-center">
            <span className="mr-1">★</span>
            <span>{accommodation.rating.toFixed(2)}</span>
          </div>
        </div>
        
        <p className="text-gray-500 text-sm">{accommodation.name}</p>
        <p className="text-gray-500 text-sm">{formatDateRange()}</p>
        <p className="mt-1">
          <span className="font-semibold">
            ¥{accommodation.pricePerNight.toLocaleString()}
          </span>
          <span className="text-sm"> /泊</span>
        </p>
      </div>
    </div>
  );
}
```

### 7. サンプルデータ作成スクリプト

```typescript
// prisma/seed.ts
import { PrismaClient } from '@prisma/client';
import { addDays } from 'date-fns';

const prisma = new PrismaClient();

async function main() {
  // カテゴリーの作成
  const categories = [
    { name: '自然の中の家', iconUrl: '/images/categories/cabin.png' },
    { name: 'ログハウス', iconUrl: '/images/categories/loghouse.png' },
    { name: 'ビーチフロント', iconUrl: '/images/categories/beach.png' },
    { name: 'アイコニック', iconUrl: '/images/categories/iconic.png' },
    { name: 'ゴルフ', iconUrl: '/images/categories/golf.png' },
    { name: '豪華なプール', iconUrl: '/images/categories/pool.png' },
    { name: '大邸宅・城館', iconUrl: '/images/categories/castle.png' },
    { name: 'ツリーハウス', iconUrl: '/images/categories/treehouse.png' }
  ];
  
  for (const category of categories) {
    await prisma.category.upsert({
      where: { name: category.name },
      update: {},
      create: category
    });
  }
  
  // カテゴリーの取得
  const cabinCategory = await prisma.category.findUnique({ where: { name: '自然の中の家' } });
  const beachCategory = await prisma.category.findUnique({ where: { name: 'ビーチフロント' } });
  const iconicCategory = await prisma.category.findUnique({ where: { name: 'アイコニック' } });
  const poolCategory = await prisma.category.findUnique({ where: { name: '豪華なプール' } });
  
  // 宿泊施設の作成
  const accommodations = [
    {
      name: '山の眺望',
      description: 'とてもセンスの良い日本家屋です。',
      prefecture: '日本、愛知県',
      city: '三ヶ根町',
      imageUrls: ['/images/accommodations/mountain1.jpg'],
      pricePerNight: 57765,
      rating: 5.0,
      reviewCount: 28,
      guestChoice: true,
      availableDateFrom: addDays(new Date(), 30), // 3月2日相当
      availableDateTo: addDays(new Date(), 35),   // 3月7日相当
      categoryId: cabinCategory!.id
    },
    {
      name: '大海原と海の眺望',
      description: '夕陽が絶景の海辺の宿です。',
      prefecture: '日本、長野県',
      city: '那須烏山市',
      imageUrls: ['/images/accommodations/ocean1.jpg'],
      pricePerNight: 72192,
      rating: 4.93,
      reviewCount: 42,
      guestChoice: true,
      availableDateFrom: addDays(new Date(), 40), // 3月12日相当
      availableDateTo: addDays(new Date(), 45),   // 3月17日相当
      categoryId: beachCategory!.id
    },
    {
      name: '山の眺望',
      description: '必要なものはすべて揃っています。',
      prefecture: '日本、富士吉田市',
      city: '山の麓',
      imageUrls: ['/images/accommodations/mountain2.jpg'],
      pricePerNight: 28800,
      rating: 5.0,
      reviewCount: 15,
      guestChoice: true,
      availableDateFrom: addDays(new Date(), 98), // 5月8日相当
      availableDateTo: addDays(new Date(), 103),  // 5月13日相当
      categoryId: iconicCategory!.id
    },
    {
      name: '海の眺望',
      description: 'プライベートプール付きの豪華な宿です。',
      prefecture: '日本、千葉市',
      city: '海の
**user**: 続きを書いて
**claude-api**: ```typescript
// prisma/seed.ts (続き)
      name: '海の眺望',
      description: 'プライベートプール付きの豪華な宿です。',
      prefecture: '日本、千葉市',
      city: '海の近く',
      imageUrls: ['/images/accommodations/pool1.jpg'],
      pricePerNight: 99000,
      rating: 4.86,
      reviewCount: 35,
      guestChoice: true,
      availableDateFrom: addDays(new Date(), 30), // 3月2日相当
      availableDateTo: addDays(new Date(), 35),   // 3月7日相当
      categoryId: poolCategory!.id
    }
  ];
  
  for (const accommodation of accommodations) {
    await prisma.accommodation.create({
      data: accommodation
    });
  }
  
  // さらに多くのサンプルデータを追加
  const prefectures = ['北海道', '東京都', '京都府', '大阪府', '沖縄県'];
  const cities = ['札幌市', '新宿区', '京都市', '大阪市', '那覇市'];
  const descriptions = [
    '自然に囲まれた静かな宿です。',
    '都会の喧騒から離れてリラックスできます。',
    '伝統的な日本家屋で特別な体験を。',
    'モダンな設備が整った快適な宿泊施設。',
    '絶景を望む贅沢なひとときを。'
  ];
  
  const allCategories = await prisma.category.findMany();
  
  // 追加で20件のサンプルデータを作成
  for (let i = 0; i < 20; i++) {
    const randomPrefectureIndex = Math.floor(Math.random() * prefectures.length);
    const randomCityIndex = Math.floor(Math.random() * cities.length);
    const randomDescriptionIndex = Math.floor(Math.random() * descriptions.length);
    const randomCategoryIndex = Math.floor(Math.random() * allCategories.length);
    
    const price = Math.floor(Math.random() * 100000) + 10000;
    const rating = (Math.random() * 1) + 4; // 4.0〜5.0の間
    const reviewCount = Math.floor(Math.random() * 50) + 5;
    const isGuestChoice = Math.random() > 0.7; // 30%の確率でゲストチョイス
    
    const startDate = addDays(new Date(), Math.floor(Math.random() * 60) + 30);
    const endDate = addDays(startDate, Math.floor(Math.random() * 10) + 5);
    
    const imageNumber = (i % 4) + 1; // 1〜4の画像を循環使用
    
    await prisma.accommodation.create({
      data: {
        name: `${prefectures[randomPrefectureIndex]}の宿 ${i + 5}`,
        description: descriptions[randomDescriptionIndex],
        prefecture: prefectures[randomPrefectureIndex],
        city: cities[randomCityIndex],
        imageUrls: [`/images/accommodations/sample${imageNumber}.jpg`],
        pricePerNight: price,
        rating: parseFloat(rating.toFixed(2)),
        reviewCount: reviewCount,
        guestChoice: isGuestChoice,
        availableDateFrom: startDate,
        availableDateTo: endDate,
        categoryId: allCategories[randomCategoryIndex].id
      }
    });
  }
  
  console.log('Seed data created successfully');
}

main()
  .catch((e) => {
    console.error(e);
    process.exit(1);
  })
  .finally(async () => {
    await prisma.$disconnect();
  });
```

### 8. package.json に seed スクリプトを追加

```json
"scripts": {
  "dev": "next dev",
  "build": "next build",
  "start": "next start",
  "lint": "next lint",
  "prisma:generate": "prisma generate",
  "prisma:migrate": "prisma migrate dev",
  "db:seed": "ts-node --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts"
}
```

### 9. API ルート

```typescript
// src/app/api/search/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';

export async function GET(request: NextRequest) {
  try {
    const { searchParams } = new URL(request.url);
    
    const location = searchParams.get('location');
    const checkInDate = searchParams.get('checkInDate') ? new Date(searchParams.get('checkInDate')!) : null;
    const checkOutDate = searchParams.get('checkOutDate') ? new Date(searchParams.get('checkOutDate')!) : null;
    const categoryId = searchParams.get('categoryId') ? parseInt(searchParams.get('categoryId')!) : null;
    
    const whereClause: any = {};
    
    if (location) {
      whereClause.OR = [
        { prefecture: { contains: location, mode: 'insensitive' } },
        { city: { contains: location, mode: 'insensitive' } }
      ];
    }
    
    if (checkInDate && checkOutDate) {
      whereClause.AND = [
        { availableDateFrom: { lte: checkOutDate } },
        { availableDateTo: { gte: checkInDate } }
      ];
    }
    
    if (categoryId) {
      whereClause.categoryId = categoryId;
    }
    
    const accommodations = await prisma.accommodation.findMany({
      where: whereClause,
      include: {
        category: true
      },
      orderBy: {
        rating: 'desc'
      }
    });
    
    return NextResponse.json(accommodations);
  } catch (error) {
    console.error('Search API error:', error);
    return NextResponse.json(
      { error: 'Failed to search accommodations' },
      { status: 500 }
    );
  }
}
```

### 10. グローバルCSS

```css
/* src/app/globals.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
 
@layer base {
  :root {
    --background: 0 0% 100%;
    --foreground: 222.2 84% 4.9%;
 
    --card: 0 0% 100%;
    --card-foreground: 222.2 84% 4.9%;
 
    --popover: 0 0% 100%;
    --popover-foreground: 222.2 84% 4.9%;
 
    --primary: 222.2 47.4% 11.2%;
    --primary-foreground: 210 40% 98%;
 
    --secondary: 210 40% 96.1%;
    --secondary-foreground: 222.2 47.4% 11.2%;
 
    --muted: 210 40% 96.1%;
    --muted-foreground: 215.4 16.3% 46.9%;
 
    --accent: 210 40% 96.1%;
    --accent-foreground: 222.2 47.4% 11.2%;
 
    --destructive: 0 84.2% 60.2%;
    --destructive-foreground: 210 40% 98%;
 
    --border: 214.3 31.8% 91.4%;
    --input: 214.3 31.8% 91.4%;
    --ring: 222.2 84% 4.9%;
 
    --radius: 0.5rem;
  }
 
  .dark {
    --background: 222.2 84% 4.9%;
    --foreground: 210 40% 98%;
 
    --card: 222.2 84% 4.9%;
    --card-foreground: 210 40% 98%;
 
    --popover: 222.2 84% 4.9%;
    --popover-foreground: 210 40% 98%;
 
    --primary: 210 40% 98%;
    --primary-foreground: 222.2 47.4% 11.2%;
 
    --secondary: 217.2 32.6% 17.5%;
    --secondary-foreground: 210 40% 98%;
 
    --muted: 217.2 32.6% 17.5%;
    --muted-foreground: 215 20.2% 65.1%;
 
    --accent: 217.2 32.6% 17.5%;
    --accent-foreground: 210 40% 98%;
 
    --destructive: 0 62.8% 30.6%;
    --destructive-foreground: 210 40% 98%;
 
    --border: 217.2 32.6% 17.5%;
    --input: 217.2 32.6% 17.5%;
    --ring: 212.7 26.8% 83.9%;
  }
}
 
@layer base {
  * {
    @apply border-border;
  }
  body {
    @apply bg-background text-foreground;
  }
}

@layer utilities {
  /* スクロールバーを非表示にするユーティリティクラス */
  .no-scrollbar::-webkit-scrollbar {
    display: none;
  }
  
  .no-scrollbar {
    -ms-overflow-style: none;
    scrollbar-width: none;
  }
}
```

## 環境構築と実行手順

### 1. プロジェクトのセットアップ

```bash
# プロジェクトの作成
npx create-next-app@latest airbnb-clone --typescript --tailwind --app
cd airbnb-clone

# 必要なパッケージのインストール
npm install shadcn-ui @radix-ui/react-slot @radix-ui/react-dialog @radix-ui/react-popover @radix-ui/react-toggle @radix-ui/react-checkbox @radix-ui/react-select @radix-ui/react-tabs class-variance-authority clsx tailwind-merge lucide-react
npm install prisma @prisma/client
npm install @supabase/supabase-js
npm install date-fns react-day-picker react-icons
npm install ts-node --save-dev

# Shadcn UIのセットアップ
npx shadcn-ui@latest init
```

### 2. Supabaseのセットアップ

1. [Supabase](https://supabase.com/)にアクセスし、アカウントを作成
2. 新しいプロジェクトを作成
3. プロジェクトのURLとAPIキーを取得
4. `.env`ファイルを作成し、以下の環境変数を設定:

```
NEXT_PUBLIC_SUPABASE_URL=https://your-project-url.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
DATABASE_URL=postgresql://postgres:password@db.your-project-url.supabase.co:5432/postgres
```

### 3. Prismaのセットアップ

```bash
# Prismaの初期化
npx prisma init

# スキーマを編集後、マイグレーションを実行
npx prisma migrate dev --name init

# サンプルデータの作成
npm run db:seed
```

### 4. 画像の準備

1. `public/images/categories/`ディレクトリを作成し、カテゴリーアイコンを配置
2. `public/images/accommodations/`ディレクトリを作成し、宿泊施設の画像を配置

### 5. アプリケーションの実行

```bash
npm run dev
```

ブラウザで http://localhost:3000 にアクセスして、アプリケーションを確認できます。

## 実装のポイント

1. **サーバーコンポーネントとクライアントコンポーネント**:
   - サーバーコンポーネント: ページコンポーネント、データ取得ロジック
   - クライアントコンポーネント: インタラクティブな要素（検索フォーム、カテゴリーリスト、お気に入りボタン）

2. **サーバーアクション**:
   - 検索機能やカテゴリーフィルタリングにサーバーアクションを活用
   - フォームの送信やデータ取得を効率化

3. **Prismaとデータベース**:
   - 型安全なデータベースアクセス
   - リレーショナルデータモデル（カテゴリーと宿泊施設の関連付け）

4. **Supabase統合**:
   - PostgreSQLデータベースの利用
   - 将来的に認証や画像ストレージの拡張が容易

5. **Shadcn UI**:
   - 高品質なUIコンポーネントを活用
   - カスタマイズ性の高いデザインシステム

6. **レスポンシブデザイン**:
   - モバイルからデスクトップまで対応したレイアウト
   - Tailwind CSSによる効率的なスタイリング

## 拡張ポイント

1. **認証機能**:
   - Supabaseの認証機能を活用してユーザー登録・ログイン機能を追加

2. **予約システム**:
   - 宿泊施設の予約機能の実装
   - カレンダーUIの追加

3. **詳細ページ**:
   - 宿泊施設の詳細情報を表示するページの追加
   - 画像ギャラリーの実装

4. **レビューシステム**:
   - ユーザーがレビューを投稿できる機能
   - 評価の集計と表示

5. **地図統合**:
   - Google MapsやMapboxを使用して宿泊施設の位置を表示

6. **多言語対応**:
   - next-intlなどを使用した多言語サポート

7. **支払い統合**:
   - StripeなどのAPIを使用した支払い処理の実装

この実装により、画像のAirbnb風の宿泊検索サイトを最新のNext.js 14とApp Router、Shadcn UI、Supabase、Prismaを使って構築できます。サーバーコンポーネントとクライアントコンポーネントを適切に分離し、サーバーアクションを活用することで、パフォーマンスと開発効率を両立させています。