All files / src/features/Campaigns/CampaignDetails/components/reviews CampaignReviewsSection.tsx

78.94% Statements 30/38
86.11% Branches 31/36
66.66% Functions 6/9
82.85% Lines 29/35

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 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217                                                                              3x 10x 10x 10x     10x 10x 10x           10x           10x           10x       10x         10x 10x 10x     10x       10x                       10x                                                                     10x             10x 1x                           9x 2x                         7x         20x                                 3x 17x 7x                       3x                 3x          
import { useNavigate } from "react-router-dom";
import { Box, Stack } from "@mui/material";
import GiveLink from "@shared/Link/GiveLink";
import GiveText from "@shared/Text/GiveText";
import { styled } from "@theme/v2/Provider";
import { useGetCurrentMerchantId } from "@hooks/common";
import { useGetFeatureFlagValues } from "FeatureFlags/useGetFeatureFlagValues";
import { useProductPermission } from "@features/Permissions/AccessControl/hooks";
import {
  EVENT_REVIEWS_PREVIEW_MAX,
  useGetEventReviews,
  useGetEventReviewStats,
} from "@services/api/products/reviews";
import EventReviewCard from "@features/Events/EventDetail/reviews/EventReviewCard";
import EventReviewsEmptyState from "@features/Events/EventDetail/reviews/EventReviewsEmptyState";
import EventReviewSummary from "@features/Events/EventDetail/reviews/EventReviewSummary";
import {
  EVENT_DETAIL_REVIEW_PARAM,
  EVENT_DETAIL_TAB_PARAM,
  EVENT_DETAIL_TABS,
} from "@features/Events/EventDetail/constants";
import { EventReviewRow } from "@features/Events/EventDetail/reviews/review.types";
 
type Props = {
  productId?: number | string;
  /** Closes the panel before navigating away from it. */
  onNavigate?: () => void;
};
 
/**
 * PayBuilder 034 — the Reviews card at the foot of the event side panel
 * (frames 7878-60582 / 7878-58753 / 7878-57940 / 7878-56665).
 *
 * A preview, not a workspace: summary, at most four reviews, and links out. The
 * merchant writes the reply on the Reviews tab, so every action here navigates.
 *
 * The caller decides whether to render this at all — it is drawn only for an
 * event that has already ended, and only with the feature flag on.
 */
const CampaignReviewsSection = ({ productId, onNavigate }: Props) => {
  const navigate = useNavigate();
  const { merchantId } = useGetCurrentMerchantId();
  const { isEventReviewsEnabled } = useGetFeatureFlagValues();
  // Reply leads to the tab's composer, which posts on the product-update grant.
  // Without this the link is an invitation to the access-denied modal.
  const { isEditProductAllowed } = useProductPermission();
  const eventId = productId ? String(productId) : undefined;
  const enabled = Boolean(merchantId && eventId && isEventReviewsEnabled);
 
  const {
    data: list,
    isLoading,
    isError,
  } = useGetEventReviews(
    merchantId,
    eventId,
    { max: EVENT_REVIEWS_PREVIEW_MAX },
    { enabled },
  );
  const { data: stats, isLoading: isStatsLoading } = useGetEventReviewStats(
    merchantId,
    eventId,
    { enabled },
  );
 
  const rows: EventReviewRow[] = list?.data ?? [];
  // The list read failed and returned nothing. `isEmpty` already keeps that out
  // of "No reviews yet", but something has to take its place — otherwise the
  // card is a bare "Reviews" heading over an empty grid.
  const hasFailed = !isLoading && isError && rows.length === 0;
  // The summary read is the authority on how many reviews the event has, but it
  // is a separate request and can fail on its own. Deciding emptiness from it
  // alone would throw away rows the list actually returned and tell the merchant
  // nobody had reviewed the event.
  const total = stats?.total ?? 0;
  const loading = isLoading || isStatsLoading;
  const isEmpty = !loading && rows.length === 0 && total === 0 && !isError;
  // Same reasoning for the link out: normally the summary's count decides, but
  // with it unavailable a full preview page is the signal that the tab has more.
  const hasMoreThanPreview = stats
    ? total > EVENT_REVIEWS_PREVIEW_MAX
    : rows.length >= EVENT_REVIEWS_PREVIEW_MAX;
 
  const goToReviews = (review?: EventReviewRow) => {
    const params = new URLSearchParams({
      [EVENT_DETAIL_TAB_PARAM]: EVENT_DETAIL_TABS.REVIEWS,
    });
    if (review) params.set(EVENT_DETAIL_REVIEW_PARAM, String(review.id));
    onNavigate?.();
    // The absolute route rather than the panel's current pathname: the panel
    // opens over more than one list, and every one of them links to the same
    // event page.
    navigate(`/merchant/events/${productId}?${params.toString()}`);
  };
 
  return (
    <Stack gap="16px" width="100%" data-testid="campaign-reviews-section">
      {/* Centred rather than top-aligned: the left block is two lines once the
          summary is there, and pinning "View all" to the title left it sitting
          high against them. */}
      <Stack
        direction="row"
        justifyContent="space-between"
        alignItems="center"
        gap="12px"
      >
        <Stack gap="8px">
          <GiveText variant="bodyM" color="primary">
            Reviews
          </GiveText>
          {/* The summary row is absent in the empty state — the header is the
              bare title (frame 7878-60582). */}
          {!loading && total > 0 && stats && (
            <EventReviewSummary stats={stats} />
          )}
        </Stack>
        {/* "View all" only once there are more than the four previewed
            (annotation on frame 7878-58753). */}
        {hasMoreThanPreview && (
          <GiveLink
            component="button"
            color="primary"
            onClick={() => goToReviews()}
          >
            View all
          </GiveLink>
        )}
      </Stack>
 
      {(() => {
        Iif (loading) {
          return (
            <Grid singleColumn>
              <SkeletonCard data-testid="campaign-reviews-skeleton" />
            </Grid>
          );
        }
        if (hasFailed) {
          return (
            <EmptyCard>
              {/* Compact, like the empty state beside it: the card is a preview,
                  and the full-size error state belongs to the tab. */}
              <EventReviewsEmptyState
                ratings={[]}
                awaitingReplyOnly={false}
                isUnfiltered
                compact
                isError
              />
            </EmptyCard>
          );
        }
        if (isEmpty) {
          return (
            <EmptyCard>
              {/* The tab's own empty state, compact: the copy for "nobody has
                  reviewed this event" belongs in one place, not two. */}
              <EventReviewsEmptyState
                ratings={[]}
                awaitingReplyOnly={false}
                isUnfiltered
                compact
              />
            </EmptyCard>
          );
        }
        return (
          // A single review fills the container rather than sitting in a half
          // width column (annotation on frame 7878-58753).
          <Grid singleColumn={rows.length === 1}>
            {rows.map((review) => (
              <EventReviewCard
                key={review.id}
                review={review}
                canReply={isEditProductAllowed}
                onReply={goToReviews}
                onViewReply={goToReviews}
              />
            ))}
          </Grid>
        );
      })()}
    </Stack>
  );
};
 
export default CampaignReviewsSection;
 
const Grid = styled(Box, {
  shouldForwardProp: (prop) => prop !== "singleColumn",
})<{ singleColumn?: boolean }>(({ theme, singleColumn }) => ({
  display: "grid",
  gridTemplateColumns: singleColumn
    ? "minmax(0, 1fr)"
    : "repeat(2, minmax(0, 1fr))",
  gap: "12px",
  width: "100%",
  [theme.breakpoints.down("v2_sm")]: {
    gridTemplateColumns: "minmax(0, 1fr)",
  },
}));
 
const EmptyCard = styled(Box)(({ theme }) => ({
  display: "flex",
  flexDirection: "column",
  width: "100%",
  padding: "20px",
  borderRadius: `${theme?.customs?.radius?.medium}px`,
  backgroundColor: theme.palette.background.paper,
}));
 
const SkeletonCard = styled(Box)(({ theme }) => ({
  height: "190px",
  borderRadius: `${theme?.customs?.radius?.medium}px`,
  backgroundColor: theme.palette.surface?.tertiary,
}));