All files / src/features/Events/EventDetail/reviews useEventReviews.ts

97.61% Statements 41/42
92.85% Branches 26/28
90.9% Functions 10/11
97.5% Lines 39/40

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 218 219 220 221 222 223 224 225 226                                                                      5x   5x 1x     5x                                         5x 94x 94x       94x 94x 94x 94x 94x   94x   22x                     94x 94x                   94x                     94x   94x   94x 46x         94x 94x                                         94x   94x 1x 1x                             94x       2x 2x 2x 1x     1x       1x         1x             1x   2x       94x                               1x                                  
import { useMemo, useState } from "react";
import { useQueryClient } from "react-query";
import { showMessage } from "@common/Toast";
import { useGetCurrentMerchantId } from "@hooks/common";
import { useGetFeatureFlagValues } from "FeatureFlags/useGetFeatureFlagValues";
import { useProductPermission } from "@features/Permissions/AccessControl/hooks";
import {
  QKEY_EVENT_REVIEWS,
  QKEY_EVENT_REVIEW_STATS,
} from "@constants/queryKeys";
import {
  buildReviewFilter,
  useGetEventReviewsInfinite,
  useGetEventReviewStats,
  usePublishReviewReply,
} from "@services/api/products/reviews";
import {
  EventReviewRow,
  EventReviewStats,
  ReviewRatingFilter,
} from "./review.types";
 
/**
 * The awaiting-reply filter needs a `replyDeadlineAt >` bound, and the only
 * clock this hook has is the browser's. Two consequences, both bounded here
 * rather than left to `Date.now()`:
 *
 * - It is not the clock the row's own `canReply` was closed against (the app
 *   clock, server-side), so a review sitting within a minute of its deadline can
 *   carry a composer and still be absent from this list. The bucket makes that
 *   window explicit and small instead of arbitrary.
 * - The stamp lands in the query key, so a raw millisecond reading mints a fresh
 *   cache entry on every toggle-on and refetches a list react-query already
 *   holds. Flooring to the bucket makes repeated toggles inside it reuse it.
 */
const AWAITING_REPLY_CLOCK_BUCKET_SECONDS = 60;
 
const awaitingReplyClock = () =>
  Math.floor(Date.now() / 1000 / AWAITING_REPLY_CLOCK_BUCKET_SECONDS) *
  AWAITING_REPLY_CLOCK_BUCKET_SECONDS;
 
const EMPTY_STATS: EventReviewStats = {
  total: 0,
  averageRating: 0,
  numRating1: 0,
  numRating2: 0,
  numRating3: 0,
  numRating4: 0,
  numRating5: 0,
  numAwaitingReply: 0,
};
 
/**
 * The Reviews tab's data.
 *
 * Two reads, deliberately: the list carries the search and the filters, the
 * summary carries neither, so the score and the distribution keep describing
 * the whole event while the merchant narrows the list beside them.
 *
 * Both are gated on the feature flag as well as the ids — with the flag off
 * nothing here reaches the network.
 */
export const useEventReviews = (eventId?: string, searchQuery?: string) => {
  const { merchantId } = useGetCurrentMerchantId();
  const { isEventReviewsEnabled } = useGetFeatureFlagValues();
  // Publishing a reply is routed on the product-update grant, not the
  // product/stat read that renders the tab — so a user can legitimately be able
  // to see every review here and not be allowed to answer one.
  const { isEditProductAllowed } = useProductPermission();
  const queryClient = useQueryClient();
  const [ratings, setRatings] = useState<ReviewRatingFilter[]>([]);
  const [awaitingReplyOnly, setAwaitingReplyOnly] = useState(false);
  const [replyingTo, setReplyingTo] = useState<number | null>(null);
 
  const filter = useMemo(
    () =>
      buildReviewFilter(
        ratings,
        // The deadline half of "awaiting reply from me" needs a clock, and it is
        // stamped here — once, when a filter moves — on purpose. Reading it
        // inside buildReviewFilter would hand a new filter string, and so a new
        // query key, to every single render. See awaitingReplyClock for why the
        // reading is bucketed rather than exact.
        awaitingReplyOnly ? { nowSeconds: awaitingReplyClock() } : undefined,
      ),
    [ratings, awaitingReplyOnly],
  );
  const trimmedQuery = searchQuery?.trim() || undefined;
  const enabled = Boolean(merchantId && eventId && isEventReviewsEnabled);
 
  const {
    data: pages,
    isLoading,
    isFetching,
    isError,
    hasNextPage,
    fetchNextPage,
    isFetchingNextPage,
  } = useGetEventReviewsInfinite(
    merchantId,
    eventId,
    { filter, q: trimmedQuery },
    { enabled },
  );
 
  const {
    data: stats,
    isLoading: isStatsLoading,
    isError: isStatsError,
  } = useGetEventReviewStats(merchantId, eventId, { enabled });
 
  const { mutateAsync: publish } = usePublishReviewReply(merchantId, eventId);
 
  const rows: EventReviewRow[] = useMemo(
    () => (pages?.pages ?? []).flatMap((page) => page?.data ?? []),
    [pages],
  );
  // The list read's own count, not the summary's: the summary describes the
  // whole event, while this is what the current search and filters matched.
  const total = pages?.pages?.[0]?.total ?? 0;
  const hasFilters = ratings.length > 0 || awaitingReplyOnly;
 
  /**
   * Both reads are still disabled — and a disabled react-query v3 query sits at
   * `idle`, not `loading`: `isLoading` is false before the first fetch is even
   * allowed to start. The tab is already mounted by then (EventDetailPage holds
   * a `?tab=reviews` deep link open rather than flashing Transactions), so
   * without this it would paint the empty state and tell the merchant their
   * customers wrote nothing — on every deep link, which is the flow this feature
   * exists for.
   *
   * Derived from `enabled` itself rather than from the flag, because the flag is
   * only one of the two halves that start out unresolved — and not the longer
   * one. `merchantId` is 0 until `useInitialLogin` has awaited `getAccounts()`
   * and dispatched `setSelectedAccount`, while `useGetProductById` is not gated
   * on it, so this tab mounts during that round trip. Either half being
   * unanswered is the same "no read has been allowed to start yet" state.
   *
   * With the flag resolved to off the tab is not mounted at all — EventDetailPage
   * drops it from the tab set — so this cannot hold a skeleton forever.
   */
  const isLoadingReviews = !enabled || isLoading || isStatsLoading;
 
  const toggleRating = (rating: ReviewRatingFilter) =>
    setRatings((current) =>
      current.includes(rating)
        ? current.filter((value) => value !== rating)
        : [...current, rating],
    );
 
  /**
   * Publish one reply. `replyingTo` locks that card's send button, so a double
   * click cannot produce two replies — the server refuses the second either
   * way (a unique index backs it), but the merchant should not have to see a
   * conflict error for something they did not mean to do twice.
   *
   * Resolves to whether it published: the composer keeps the typed draft on a
   * rejection, so a 409 or a timeout leaves something to retry from rather than
   * an emptied field.
   */
  const submitReply = async (
    review: EventReviewRow,
    body: string,
  ): Promise<boolean> => {
    setReplyingTo(review.id);
    try {
      await publish({ reviewId: review.id, body });
      showMessage("Success", "", true, "Your reply is now live");
      // Both reads move: the row gains its reply, and the summary's
      // awaiting-reply count drops by one.
      await Promise.all([
        queryClient.invalidateQueries([QKEY_EVENT_REVIEWS]),
        queryClient.invalidateQueries([QKEY_EVENT_REVIEW_STATS]),
      ]);
      return true;
    } catch (error: any) {
      // The failures that matter here sit outside the 400 the interceptor
      // toasts: already replied (409) and past the 30-day window (400). Without
      // this the composer just empties with nothing said.
      showMessage(
        "Error",
        error?.response?.data?.message ||
          "Your reply could not be published. Please try again.",
        true,
        "Reply not published",
      );
      return false;
    } finally {
      setReplyingTo(null);
    }
  };
 
  return {
    rows,
    total,
    // The summary read is the authority on whether the event has feedback at
    // all — the list's length is only what the current search and filters left.
    stats: stats ?? EMPTY_STATS,
    isStatsError,
    isLoading: isLoadingReviews,
    isFetching,
    isError,
    hasNextPage: Boolean(hasNextPage),
    fetchNextPage,
    isFetchingNextPage,
    ratings,
    awaitingReplyOnly,
    toggleRating,
    toggleAwaitingReply: () => setAwaitingReplyOnly((value) => !value),
    hasFilters,
    /** True only when the event has no reviews at all, search and filters aside. */
    isUnfiltered: !hasFilters && !trimmedQuery,
    /** The search term the empty state echoes back, already trimmed. */
    searchTerm: trimmedQuery,
    isEmpty: !isLoadingReviews && rows.length === 0,
    /**
     * Whether this user may publish a reply at all. Separate from the row's own
     * `canReply`, which is only about the review: both have to hold before a
     * composer is worth drawing.
     */
    canReply: isEditProductAllowed,
    submitReply,
    replyingTo,
  };
};