Skip to main content

🧩 Next.js Commerce コンポーネント設計

BFF統合対応・ブランド特化UI設計
既存アーキテクチャと調和するコンポーネント設計戦略

🎯 コンポーネント設計原則

基本方針

設計原則:
統合性: BFF API + Firebase Auth完全対応
再利用性: 猫アプリ × 時の絵 共通コンポーネント
型安全性: TypeScript完全対応
保守性: 単一責任・疎結合設計

技術制約:
- Cloudflare Workers環境対応
- PII分離アーキテクチャ遵守
- 既存デザインガイドライン準拠
- パフォーマンス最適化必須

📦 コンポーネント階層設計

アーキテクチャ構造

components/
├── ui/ # 基本UIコンポーネント
│ ├── Button/
│ ├── Card/
│ ├── Input/
│ └── Modal/
├── commerce/ # EC機能コンポーネント
│ ├── ProductCard/
│ ├── ProductGallery/
│ ├── AddToCart/
│ └── Cart/
├── brand/ # ブランド特化コンポーネント
│ ├── neko/
│ └── tokinoe/
├── layout/ # レイアウトコンポーネント
│ ├── Header/
│ ├── Footer/
│ └── Navigation/
└── providers/ # コンテキストプロバイダー
├── AuthProvider/
├── CartProvider/
└── ThemeProvider/

🛒 Commerce コアコンポーネント

ProductCard - 商品カード

// components/commerce/ProductCard/ProductCard.tsx
import { Product } from '@/types/product'
import { Card } from '@/components/ui/Card'
import { Badge } from '@/components/ui/Badge'
import Image from 'next/image'

interface ProductCardProps {
product: Product
brand: 'neko' | 'tokinoe'
size?: 'small' | 'medium' | 'large'
onClick?: (product: Product) => void
}

export function ProductCard({
product,
brand,
size = 'medium',
onClick
}: ProductCardProps) {
const brandConfig = {
neko: {
bgColor: 'bg-orange-50',
accentColor: 'text-orange-600',
badge: '📸 写真プリント'
},
tokinoe: {
bgColor: 'bg-tokinoe-cream',
accentColor: 'text-tokinoe-accent',
badge: '🎨 日本画'
}
}

const config = brandConfig[brand]
const imageUrl = product.images[0]?.url || '/placeholder.jpg'

return (
<Card
className={`
${config.bgColor} hover:shadow-lg transition-all duration-300
cursor-pointer group
`}
onClick={() => onClick?.(product)}
>
{/* 商品画像 */}
<div className="aspect-[4/3] overflow-hidden rounded-t-lg">
<Image
src={imageUrl}
alt={product.images[0]?.altText || product.title}
width={400}
height={300}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
/>
</div>

{/* 商品情報 */}
<div className="p-4 space-y-2">
<Badge variant="secondary" className="text-xs">
{config.badge}
</Badge>

<h3 className="font-medium text-gray-900 line-clamp-2">
{product.title}
</h3>

{brand === 'tokinoe' && (
<p className="text-sm text-gray-600">
作家: {product.vendor}
</p>
)}

<div className="flex items-center justify-between">
<span className={`font-semibold ${config.accentColor}`}>
¥{product.priceRange.minVariantPrice.amount.toLocaleString()}
</span>

{!product.availableForSale && (
<Badge variant="outline" className="text-red-600">
売り切れ
</Badge>
)}
</div>
</div>
</Card>
)
}

ProductGallery - 商品ギャラリー

// components/commerce/ProductGallery/ProductGallery.tsx
'use client'

import { useState } from 'react'
import Image from 'next/image'
import { ProductImage, ProductVariant } from '@/types/product'
import { cn } from '@/lib/utils'

interface ProductGalleryProps {
images: ProductImage[]
selectedVariant?: ProductVariant
className?: string
}

export function ProductGallery({
images,
selectedVariant,
className
}: ProductGalleryProps) {
const [selectedImage, setSelectedImage] = useState(0)

// バリアント選択時の画像フィルタリング
const filteredImages = selectedVariant
? images.filter(img =>
img.altText?.includes(selectedVariant.title) ||
img.variantId === selectedVariant.id
)
: images

const displayImages = filteredImages.length > 0 ? filteredImages : images

return (
<div className={cn("grid grid-cols-1 lg:grid-cols-4 gap-4", className)}>
{/* メイン画像 */}
<div className="lg:col-span-3">
<div className="aspect-square overflow-hidden rounded-lg bg-gray-100">
<Image
src={displayImages[selectedImage]?.url || '/placeholder.jpg'}
alt={displayImages[selectedImage]?.altText || '商品画像'}
width={800}
height={800}
className="w-full h-full object-cover"
priority
/>
</div>
</div>

{/* サムネイル */}
<div className="grid grid-cols-4 lg:grid-cols-1 gap-2">
{displayImages.map((image, index) => (
<button
key={image.id}
onClick={() => setSelectedImage(index)}
className={cn(
"aspect-square overflow-hidden rounded-md border-2 transition-colors",
selectedImage === index
? "border-tokinoe-accent"
: "border-gray-200 hover:border-gray-300"
)}
>
<Image
src={image.url}
alt={image.altText || `商品画像 ${index + 1}`}
width={120}
height={120}
className="w-full h-full object-cover"
/>
</button>
))}
</div>
</div>
)
}

AddToCart - カート追加

// components/commerce/AddToCart/AddToCart.tsx
'use client'

import { useState } from 'react'
import { Button } from '@/components/ui/Button'
import { useCart } from '@/providers/CartProvider'
import { useAuth } from '@/providers/AuthProvider'
import { Product, ProductVariant } from '@/types/product'
import { ShoppingCart, Loader2 } from 'lucide-react'

interface AddToCartProps {
product: Product
selectedVariant?: ProductVariant
quantity?: number
className?: string
}

export function AddToCart({
product,
selectedVariant,
quantity = 1,
className
}: AddToCartProps) {
const [isLoading, setIsLoading] = useState(false)
const { addToCart } = useCart()
const { user, isAuthenticated } = useAuth()

const handleAddToCart = async () => {
if (!isAuthenticated) {
// ログインモーダル表示など
return
}

if (!selectedVariant) {
// バリアント選択促すアラート
return
}

setIsLoading(true)
try {
await addToCart({
productId: product.id,
variantId: selectedVariant.id,
quantity,
// Firebase UID経由でBFF API認証
firebaseUid: user?.uid
})
} catch (error) {
console.error('カート追加エラー:', error)
} finally {
setIsLoading(false)
}
}

const isDisabled =
isLoading ||
!selectedVariant ||
!selectedVariant.availableForSale

return (
<Button
onClick={handleAddToCart}
disabled={isDisabled}
className={className}
size="lg"
>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
追加中...
</>
) : (
<>
<ShoppingCart className="w-4 h-4 mr-2" />
カートに追加
</>
)}
</Button>
)
}

🎨 ブランド特化コンポーネント

TOKINOE - 時の絵特化UI

// components/brand/tokinoe/ArtworkDisplay.tsx
import { Product } from '@/types/product'
import { Badge } from '@/components/ui/Badge'

interface ArtworkDisplayProps {
product: Product
}

export function TokinoeArtworkDisplay({ product }: ArtworkDisplayProps) {
const technique = product.metafields?.technique
const artist = product.vendor

return (
<div className="space-y-6">
{/* 作品情報ヘッダー */}
<div className="border-b border-gray-200 pb-6">
<h1 className="text-3xl font-serif text-tokinoe-black mb-2">
{product.title}
</h1>

<div className="flex items-center space-x-4 text-sm text-tokinoe-gray">
<span>作家: {artist}</span>
{technique && (
<>
<span></span>
<span>技法: {technique}</span>
</>
)}
</div>

{/* 技法バッジ */}
{technique && (
<Badge variant="outline" className="mt-3">
🎨 {technique}
</Badge>
)}
</div>

{/* 作品解説 */}
<div className="prose prose-gray max-w-none">
<h3 className="text-lg font-medium text-tokinoe-black mb-3">
作品について
</h3>
<p className="text-tokinoe-gray leading-relaxed">
{product.description}
</p>
</div>
</div>
)
}
// components/brand/tokinoe/FrameSelector.tsx
'use client'

import { useState } from 'react'
import { ProductVariant } from '@/types/product'
import { cn } from '@/lib/utils'

interface FrameSelectorProps {
variants: ProductVariant[]
onVariantSelect: (variant: ProductVariant) => void
}

export function TokinoeFrameSelector({
variants,
onVariantSelect
}: FrameSelectorProps) {
const [selectedVariant, setSelectedVariant] = useState<string>('')

// バリアントをフレームタイプ別にグループ化
const groupedVariants = variants.reduce((acc, variant) => {
const frameType = variant.title.includes('プレミアム') ? 'premium' :
variant.title.includes('木製') ? 'wood' :
variant.title.includes('スタンド') ? 'stand' : 'postcard'

if (!acc[frameType]) acc[frameType] = []
acc[frameType].push(variant)
return acc
}, {} as Record<string, ProductVariant[]>)

const handleSelect = (variant: ProductVariant) => {
setSelectedVariant(variant.id)
onVariantSelect(variant)
}

return (
<div className="space-y-8">
{/* プレミアムフレーム */}
{groupedVariants.premium && (
<div>
<h3 className="font-medium text-tokinoe-black mb-4">
🖼️ プレミアムフレーム
</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{groupedVariants.premium.map((variant) => (
<button
key={variant.id}
onClick={() => handleSelect(variant)}
className={cn(
"p-3 border-2 rounded-lg text-sm transition-colors",
selectedVariant === variant.id
? "border-tokinoe-accent bg-tokinoe-accent/10"
: "border-gray-200 hover:border-gray-300"
)}
>
<div className="space-y-1">
<div className="w-full h-8 bg-gradient-to-r from-gray-800 to-gray-600 rounded"></div>
<p className="font-medium">{variant.title.split('・')[1]}</p>
<p className="text-tokinoe-accent font-semibold">
¥{variant.price.amount.toLocaleString()}
</p>
</div>
</button>
))}
</div>
</div>
)}

{/* 木製フレーム */}
{groupedVariants.wood && (
<div>
<h3 className="font-medium text-tokinoe-black mb-4">
🌳 木製フレーム
</h3>
<div className="grid grid-cols-3 gap-3">
{groupedVariants.wood.map((variant) => (
<button
key={variant.id}
onClick={() => handleSelect(variant)}
className={cn(
"p-3 border-2 rounded-lg text-sm transition-colors",
selectedVariant === variant.id
? "border-tokinoe-accent bg-tokinoe-accent/10"
: "border-gray-200 hover:border-gray-300"
)}
>
<div className="space-y-1">
<div className="w-full h-8 bg-gradient-to-r from-amber-200 to-amber-400 rounded"></div>
<p className="font-medium">{variant.title}</p>
<p className="text-tokinoe-accent font-semibold">
¥{variant.price.amount.toLocaleString()}
</p>
</div>
</button>
))}
</div>
</div>
)}
</div>
)
}

NEKO - 猫アプリ特化UI

// components/brand/neko/PhotoUploadZone.tsx
'use client'

import { useState, useCallback } from 'react'
import { useDropzone } from 'react-dropzone'
import { Upload, Image as ImageIcon } from 'lucide-react'
import { Button } from '@/components/ui/Button'

interface PhotoUploadZoneProps {
onUpload: (files: File[]) => void
maxFiles?: number
acceptedFileTypes?: string[]
}

export function NekoPhotoUploadZone({
onUpload,
maxFiles = 10,
acceptedFileTypes = ['image/jpeg', 'image/png', 'image/heic']
}: PhotoUploadZoneProps) {
const [uploadedFiles, setUploadedFiles] = useState<File[]>([])

const onDrop = useCallback((acceptedFiles: File[]) => {
const newFiles = [...uploadedFiles, ...acceptedFiles].slice(0, maxFiles)
setUploadedFiles(newFiles)
onUpload(newFiles)
}, [uploadedFiles, maxFiles, onUpload])

const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: acceptedFileTypes.reduce((acc, type) => ({ ...acc, [type]: [] }), {}),
maxFiles: maxFiles - uploadedFiles.length
})

return (
<div className="space-y-4">
{/* ドロップゾーン */}
<div
{...getRootProps()}
className={`
border-2 border-dashed rounded-xl p-8 text-center cursor-pointer
transition-colors duration-200
${isDragActive
? 'border-orange-400 bg-orange-50'
: 'border-gray-300 hover:border-gray-400'
}
`}
>
<input {...getInputProps()} />

<div className="space-y-4">
<div className="w-16 h-16 mx-auto bg-orange-100 rounded-full flex items-center justify-center">
{isDragActive ? (
<Upload className="w-8 h-8 text-orange-600" />
) : (
<ImageIcon className="w-8 h-8 text-orange-600" />
)}
</div>

<div>
<h3 className="text-lg font-medium text-gray-900 mb-2">
📸 写真をアップロード
</h3>
<p className="text-sm text-gray-600">
{isDragActive
? "ここにドロップしてください"
: `クリックまたはドラッグ&ドロップ (最大${maxFiles}枚)`
}
</p>
</div>
</div>
</div>

{/* アップロード済みファイル一覧 */}
{uploadedFiles.length > 0 && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{uploadedFiles.map((file, index) => (
<div key={index} className="relative group">
<img
src={URL.createObjectURL(file)}
alt={`アップロード ${index + 1}`}
className="w-full aspect-square object-cover rounded-lg"
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors rounded-lg">
<button
onClick={() => {
const newFiles = uploadedFiles.filter((_, i) => i !== index)
setUploadedFiles(newFiles)
onUpload(newFiles)
}}
className="absolute top-2 right-2 w-6 h-6 bg-red-500 text-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
>
×
</button>
</div>
</div>
))}
</div>
)}

{/* 進行ボタン */}
{uploadedFiles.length > 0 && (
<Button className="w-full" size="lg">
{uploadedFiles.length}枚の写真でプリント作成 →
</Button>
)}
</div>
)
}

🔧 Provider・Context設計

CartProvider - カート管理

// providers/CartProvider.tsx
'use client'

import { createContext, useContext, useReducer, ReactNode } from 'react'
import { useAuth } from './AuthProvider'

interface CartItem {
id: string
productId: string
variantId: string
quantity: number
product: Product
variant: ProductVariant
}

interface CartState {
items: CartItem[]
isLoading: boolean
error?: string
}

interface CartContextType extends CartState {
addToCart: (item: AddToCartData) => Promise<void>
updateQuantity: (itemId: string, quantity: number) => Promise<void>
removeFromCart: (itemId: string) => Promise<void>
clearCart: () => Promise<void>
}

const CartContext = createContext<CartContextType | null>(null)

export function CartProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(cartReducer, {
items: [],
isLoading: false
})
const { getIdToken } = useAuth()

const addToCart = async (data: AddToCartData) => {
dispatch({ type: 'SET_LOADING', payload: true })

try {
const token = await getIdToken()
const response = await fetch('/api/cart/add', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
})

if (!response.ok) throw new Error('カート追加に失敗')

const result = await response.json()
dispatch({ type: 'ADD_ITEM', payload: result.item })

} catch (error) {
dispatch({ type: 'SET_ERROR', payload: error.message })
} finally {
dispatch({ type: 'SET_LOADING', payload: false })
}
}

// ... その他のカート操作

return (
<CartContext.Provider value={{ ...state, addToCart, updateQuantity, removeFromCart, clearCart }}>
{children}
</CartContext.Provider>
)
}

export const useCart = () => {
const context = useContext(CartContext)
if (!context) throw new Error('useCart must be used within CartProvider')
return context
}

📊 型定義・インターフェース

統合型定義

// types/product.ts - BFF API準拠
export interface Product {
id: string
handle: string
title: string
description: string
vendor: string // 作家名 (時の絵) / ブランド名 (猫アプリ)
productType: 'neko' | 'tokinoe'

// 価格情報
priceRange: {
minVariantPrice: Money
maxVariantPrice: Money
}

// 在庫・販売状況
availableForSale: boolean
totalInventory?: number

// 画像
images: ProductImage[]
featuredImage?: ProductImage

// バリアント
variants: ProductVariant[]

// SEO
seo: {
title?: string
description?: string
}

// メタフィールド(ブランド特化情報)
metafields?: {
brand: 'neko' | 'tokinoe'
firebase_uid?: string
technique?: string // 技法 (時の絵)
artist_profile?: string // 作家プロフィール
print_size?: string // プリントサイズ (猫アプリ)
image_hash?: string // 画像ハッシュ値
}

// タイムスタンプ
createdAt: string
updatedAt: string
}

export interface ProductVariant {
id: string
title: string // "プレミアムフレーム・ブラック×ホワイト"
price: Money
compareAtPrice?: Money
availableForSale: boolean
quantityAvailable?: number

// 画像(バリアント固有)
image?: ProductImage

// Shopify互換
selectedOptions: {
name: string // "Frame Type", "Frame Color", "Mat Color"
value: string // "Premium", "Black", "White"
}[]

// 重量・配送
weight?: number
requiresShipping: boolean
}

export interface Money {
amount: number
currencyCode: string // 'JPY'
}

export interface ProductImage {
id: string
url: string
altText?: string // "紅葉_プレミアムフレーム_ブラック×ホワイト"
width?: number
height?: number
variantId?: string // 関連バリアントID
}

📍 実装チェックリスト

基本コンポーネント

  • ProductCard (共通)
  • ProductGallery (画像切替対応)
  • AddToCart (BFF統合)
  • Cart (セッション管理)

ブランド特化

  • TokinoeArtworkDisplay (作家・技法表示)
  • TokinoeFrameSelector (16+3+1バリアント)
  • NekoPhotoUploadZone (写真アップロード)
  • NekoSizeSelector (プリントサイズ)

Provider・状態管理

  • CartProvider (BFF API統合)
  • AuthProvider (Firebase連携)
  • ThemeProvider (ダークモード対応)

型定義・インターフェース

  • Product型 (BFF API準拠)
  • Variant型 (バリアント管理)
  • Cart型 (カート状態管理)

📍 関連ドキュメント: 統合戦略 | パフォーマンス最適化 | 時の絵ガイドライン

文書作成日: 2025-08-23
最終更新: 2025-08-23
バージョン: 1.0 - コンポーネント設計版