All files / src/components/features/product/Reviews ReviewCard.tsx

100% Statements 187/187
86.36% Branches 19/22
100% Functions 5/5
100% Lines 187/187

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 1881x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 39x 39x 39x 39x 39x 39x 39x 39x 39x 39x 39x 39x 2x 2x 2x 39x 39x 39x 39x 39x 39x 39x 39x 39x 39x 1x 1x 1x 1x 1x 1x 1x 39x 38x 38x 38x 39x 39x 39x 39x 39x 39x 39x 39x 39x 39x 39x 39x 39x 39x 39x 39x 39x 39x 39x 39x 39x 39x 38x 39x 39x 39x 39x 14x 14x 14x 14x 14x 14x 14x 14x 14x 14x 14x 14x 14x 14x 14x 14x 14x 14x 14x 14x 14x 14x 14x 14x 14x 14x 14x 14x 14x 14x 14x 14x 14x 14x 14x 14x 14x 14x 14x 39x 39x 39x 39x 39x 39x 1x 1x 1x 1x 13x 13x 13x 13x 13x 13x 13x 13x 13x 13x 13x 13x 13x 13x 13x 13x 13x 13x 13x 13x  
'use client';
 
import Image from 'next/image';
import { formatDistanceToNow } from 'date-fns';
import { StarRating } from '@/components/ui/StarRating';
import { Button } from '@/components/ui/Button';
import { Icon } from '@/components/ui/icons';
import { cn } from '@/lib/core';
 
export interface ReviewUser {
  id: number;
  name: string | null;
  image: string | null;
}
 
export interface ReviewData {
  id: number;
  rating: number;
  comment: string | null;
  createdAt: string | Date;
  updatedAt?: string | Date;
  user: ReviewUser;
  helpfulCount?: number;
  notHelpfulCount?: number;
}
 
export interface ReviewCardProps {
  review: ReviewData;
  onHelpful?: (reviewId: number, helpful: boolean) => void;
  userVote?: boolean | null;
  isLoading?: boolean;
  showActions?: boolean;
  className?: string;
}
 
/**
 * ReviewCard - Displays a single product review
 *
 * Features:
 * - User avatar with fallback
 * - Star rating display
 * - Comment text
 * - Time since review
 * - Helpful/not helpful voting
 *
 * @example
 * ```tsx
 * <ReviewCard
 *   review={reviewData}
 *   onHelpful={(id, helpful) => markHelpful(id, helpful)}
 *   userVote={true}
 *   showActions={true}
 * />
 * ```
 */
export function ReviewCard({
  review,
  onHelpful,
  userVote,
  isLoading = false,
  showActions = true,
  className}: ReviewCardProps) {
  const createdAt = typeof review.createdAt === 'string'
    ? new Date(review.createdAt)
    : review.createdAt;
 
  const handleHelpfulClick = (helpful: boolean) => {
    if (onHelpful && !isLoading) {
      onHelpful(review.id, helpful);
    }
  };
 
  const userInitial = review.user.name?.[0]?.toUpperCase() || 'U';
 
  return (
    <div className={cn('border-b border-gray-200 pb-6 last:border-b-0', className)}>
      <div className="flex items-start gap-4">
        {/* User Avatar */}
        <div className="w-10 h-10 rounded-full overflow-hidden bg-gray-200 flex-shrink-0">
          {review.user.image ? (
            <Image
              src={review.user.image}
              alt={review.user.name || 'User'}
              width={40}
              height={40}
              className="w-full h-full object-cover"
            />
          ) : (
            <div className="w-full h-full flex items-center justify-center text-gray-500 font-medium">
              {userInitial}
            </div>
          )}
        </div>
 
        {/* Review Content */}
        <div className="flex-1 min-w-0">
          {/* Header: Name and Date */}
          <div className="flex items-center gap-2 flex-wrap">
            <span className="font-medium text-gray-900">
              {review.user.name || 'Anonymous'}
            </span>
            <span className="text-sm text-gray-600 dark:text-gray-400">
              {formatDistanceToNow(createdAt, { addSuffix: true })}
            </span>
          </div>
 
          {/* Star Rating */}
          <div className="mt-1">
            <StarRating rating={review.rating} size="sm" showCount={false} />
          </div>
 
          {/* Comment */}
          {review.comment && (
            <p className="mt-2 text-gray-700 whitespace-pre-wrap">{review.comment}</p>
          )}
 
          {/* Helpful Actions */}
          {showActions && (
            <div className="mt-3 flex items-center gap-4">
              <span className="text-sm text-gray-600 dark:text-gray-400">Was this helpful?</span>
 
              <div className="flex items-center gap-2">
                <Button
                  variant="ghost"
                  size="sm"
                  onClick={() => handleHelpfulClick(true)}
                  disabled={isLoading}
                  className={cn(
                    'flex items-center gap-1 px-2 py-1',
                    userVote === true && 'text-green-600 bg-green-50'
                  )}
                  aria-label="Mark as helpful"
                >
                  <Icon name="check" size={14} />
                  <span className="text-xs">
                    Yes ({review.helpfulCount || 0})
                  </span>
                </Button>
 
                <Button
                  variant="ghost"
                  size="sm"
                  onClick={() => handleHelpfulClick(false)}
                  disabled={isLoading}
                  className={cn(
                    'flex items-center gap-1 px-2 py-1',
                    userVote === false && 'text-red-600 bg-red-50'
                  )}
                  aria-label="Mark as not helpful"
                >
                  <Icon name="close" size={14} />
                  <span className="text-xs">
                    No ({review.notHelpfulCount || 0})
                  </span>
                </Button>
              </div>
            </div>
          )}
        </div>
      </div>
    </div>
  );
}
 
/**
 * ReviewCardSkeleton - Loading state for ReviewCard
 */
export function ReviewCardSkeleton() {
  return (
    <div className="border-b border-gray-200 pb-6 animate-pulse">
      <div className="flex items-start gap-4">
        <div className="w-10 h-10 rounded-full bg-gray-200" />
        <div className="flex-1">
          <div className="flex items-center gap-2">
            <div className="h-4 w-24 bg-gray-200 rounded" />
            <div className="h-3 w-16 bg-gray-200 rounded" />
          </div>
          <div className="mt-2 h-4 w-20 bg-gray-200 rounded" />
          <div className="mt-3 space-y-2">
            <div className="h-3 w-full bg-gray-200 rounded" />
            <div className="h-3 w-3/4 bg-gray-200 rounded" />
          </div>
        </div>
      </div>
    </div>
  );
}