0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Posted at

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. プロジェクト作成

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

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

# 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のセットアップ

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を編集:

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クライアントの生成とデータベースマイグレーション

npx prisma migrate dev --name init

実装コード

1. Prismaクライアント設定

// 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クライアント設定

// 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. 型定義

// 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. サーバーアクション

// 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. レイアウトとページ

// 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>
  );
}
// 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>
  );
}
// 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. コンポーネント

// 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>
  );
}
// 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>
  );
}
// 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>
  );
}
// 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. サンプルデータ作成スクリプト

// 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 スクリプトを追加

"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 ルート

// 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

/* 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. プロジェクトのセットアップ

# プロジェクトの作成
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にアクセスし、アカウントを作成
  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のセットアップ

# Prismaの初期化
npx prisma init

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

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

4. 画像の準備

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

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

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を使って構築できます。サーバーコンポーネントとクライアントコンポーネントを適切に分離し、サーバーアクションを活用することで、パフォーマンスと開発効率を両立させています。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?