178
resources/client/reviews/review-list/new-review-form.tsx
Executable file
178
resources/client/reviews/review-list/new-review-form.tsx
Executable file
@@ -0,0 +1,178 @@
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useState,
|
||||
} from 'react';
|
||||
import {useAuth} from '@common/auth/use-auth';
|
||||
import {
|
||||
CreateReviewPayload,
|
||||
useCreateReview,
|
||||
} from '@app/reviews/requests/use-create-review';
|
||||
import {useForm} from 'react-hook-form';
|
||||
import {Form} from '@common/ui/forms/form';
|
||||
import {Avatar} from '@common/ui/images/avatar';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {StarSelector} from '@app/reviews/review-list/star-selector';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
import {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';
|
||||
import {Reviewable} from '@app/reviews/reviewable';
|
||||
import {toast} from '@common/ui/toast/toast';
|
||||
import {message} from '@common/i18n/message';
|
||||
import clsx from 'clsx';
|
||||
import {Review} from '@app/titles/models/review';
|
||||
|
||||
export interface NewReviewFormActions {
|
||||
openReviewPanel: () => void;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
reviewable: Reviewable;
|
||||
currentReview?: Review;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
export const NewReviewForm = forwardRef<NewReviewFormActions, Props>(
|
||||
({reviewable, currentReview, className, disabled}, ref) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const {user} = useAuth();
|
||||
const form = useForm<CreateReviewPayload>({
|
||||
defaultValues: {
|
||||
score: 8,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (currentReview) {
|
||||
form.setValue('title', currentReview.title);
|
||||
form.setValue('body', currentReview.body);
|
||||
form.setValue('score', currentReview.score);
|
||||
}
|
||||
}, [form, currentReview]);
|
||||
|
||||
const openReviewPanel = useCallback(() => {
|
||||
setIsExpanded(true);
|
||||
}, []);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
openReviewPanel,
|
||||
}),
|
||||
[openReviewPanel],
|
||||
);
|
||||
|
||||
const createReview = useCreateReview(form);
|
||||
return (
|
||||
<Form
|
||||
className={clsx('rounded border bg-alt p-14', className)}
|
||||
form={form}
|
||||
onSubmit={newValues => {
|
||||
if (disabled) return;
|
||||
createReview.mutate(
|
||||
{
|
||||
...newValues,
|
||||
reviewable,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast(message('Review posted'));
|
||||
setIsExpanded(false);
|
||||
},
|
||||
},
|
||||
);
|
||||
}}
|
||||
>
|
||||
<div className="items-center gap-24 lg:flex">
|
||||
<Avatar
|
||||
size="xl"
|
||||
circle
|
||||
src={user?.avatar}
|
||||
label={user?.display_name}
|
||||
/>
|
||||
<div className="flex-auto">
|
||||
<div className="mb-4 text-xs text-muted max-md:mt-10">
|
||||
<Trans
|
||||
message="Review as :name"
|
||||
values={{
|
||||
name: (
|
||||
<span className="font-medium text">
|
||||
{user?.display_name}
|
||||
</span>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<StarSelector
|
||||
readonly={disabled}
|
||||
className="-ml-8 max-lg:mb-12"
|
||||
count={10}
|
||||
value={disabled ? 0 : form.watch('score')}
|
||||
onValueChange={newScore => {
|
||||
form.setValue('score', newScore);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{!isExpanded && (
|
||||
<Button
|
||||
variant="flat"
|
||||
color="primary"
|
||||
onClick={() => openReviewPanel()}
|
||||
disabled={!user || disabled}
|
||||
>
|
||||
{currentReview ? (
|
||||
<Trans message="Update review" />
|
||||
) : (
|
||||
<Trans message="Add review" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="mt-24">
|
||||
<FormTextField
|
||||
name="title"
|
||||
className="mb-24"
|
||||
label={<Trans message="Title" />}
|
||||
labelSuffix={<Trans message="10 character minimum" />}
|
||||
autoFocus
|
||||
minLength={10}
|
||||
required
|
||||
/>
|
||||
<FormTextField
|
||||
name="body"
|
||||
label={<Trans message="Review" />}
|
||||
labelSuffix={<Trans message="100 character minimum" />}
|
||||
inputElementType="textarea"
|
||||
rows={5}
|
||||
minLength={100}
|
||||
required
|
||||
/>
|
||||
<div className="mt-16 flex items-center justify-end gap-8">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="min-w-100"
|
||||
onClick={() => {
|
||||
setIsExpanded(false);
|
||||
form.reset(currentReview);
|
||||
}}
|
||||
>
|
||||
<Trans message="Cancel" />
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="flat"
|
||||
color="primary"
|
||||
className="min-w-100"
|
||||
disabled={createReview.isPending}
|
||||
>
|
||||
<Trans message="Post" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
);
|
||||
},
|
||||
);
|
||||
315
resources/client/reviews/review-list/review-list-item.tsx
Executable file
315
resources/client/reviews/review-list/review-list-item.tsx
Executable file
@@ -0,0 +1,315 @@
|
||||
import {Review} from '@app/titles/models/review';
|
||||
import {UserAvatar} from '@common/ui/images/user-avatar';
|
||||
import {FormattedRelativeTime} from '@common/i18n/formatted-relative-time';
|
||||
import {TitleRating} from '@app/reviews/title-rating';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import React, {
|
||||
Fragment,
|
||||
ReactElement,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import {Link, useLocation} from 'react-router-dom';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
import {useSubmitReviewFeedback} from '@app/reviews/requests/use-submit-review-feedback';
|
||||
import clsx from 'clsx';
|
||||
import {useAuth} from '@common/auth/use-auth';
|
||||
import {useAuthClickCapture} from '@app/use-auth-click-capture';
|
||||
import {IconButton} from '@common/ui/buttons/icon-button';
|
||||
import {Tooltip} from '@common/ui/tooltip/tooltip';
|
||||
import {useSubmitReport} from '@common/reports/requests/use-submit-report';
|
||||
import {useDeleteReport} from '@common/reports/requests/use-delete-report';
|
||||
import {ShareIcon} from '@common/icons/material/Share';
|
||||
import useClipboard from 'react-use-clipboard';
|
||||
import {useSettings} from '@common/core/settings/use-settings';
|
||||
import {toast} from '@common/ui/toast/toast';
|
||||
import {message} from '@common/i18n/message';
|
||||
import {
|
||||
Menu,
|
||||
MenuItem,
|
||||
MenuTrigger,
|
||||
} from '@common/ui/navigation/menu/menu-trigger';
|
||||
import {MoreVertIcon} from '@common/icons/material/MoreVert';
|
||||
import {DialogTrigger} from '@common/ui/overlays/dialog/dialog-trigger';
|
||||
import {ConfirmationDialog} from '@common/ui/overlays/dialog/confirmation-dialog';
|
||||
import {useDeleteReviews} from '@app/reviews/requests/use-delete-reviews';
|
||||
import {User} from '@common/auth/user';
|
||||
import {SiteConfigContext} from '@common/core/settings/site-config-context';
|
||||
import {useIsMobileMediaQuery} from '@common/utils/hooks/is-mobile-media-query';
|
||||
|
||||
interface Props {
|
||||
review: Review;
|
||||
isShared?: boolean;
|
||||
hideShareButton?: boolean;
|
||||
avatar?: ReactElement;
|
||||
}
|
||||
export function ReviewListItem({
|
||||
review,
|
||||
isShared,
|
||||
hideShareButton,
|
||||
avatar,
|
||||
}: Props) {
|
||||
const isMobile = useIsMobileMediaQuery();
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const scrolled = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isShared && !scrolled.current) {
|
||||
setTimeout(() => {
|
||||
ref.current?.scrollIntoView({behavior: 'smooth'});
|
||||
scrolled.current = true;
|
||||
}, 50);
|
||||
}
|
||||
}, [isShared]);
|
||||
|
||||
return (
|
||||
<div ref={ref}>
|
||||
{isShared && (
|
||||
<div className="mb-8 mt-16 text-sm">
|
||||
<Trans message="Shared review" />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={clsx(
|
||||
'group flex min-h-70 items-start gap-24 rounded py-18',
|
||||
isShared && 'mb-34 border bg-alt pl-12',
|
||||
)}
|
||||
>
|
||||
{!isMobile &&
|
||||
(avatar || <UserAvatar user={review.user} size="xl" circle />)}
|
||||
<div className="flex-auto text-sm">
|
||||
<div className="mb-4 flex items-center gap-8">
|
||||
{review.user && <UserDisplayName user={review.user} />}
|
||||
<time className="text-xs text-muted">
|
||||
<FormattedRelativeTime date={review.created_at} />
|
||||
</time>
|
||||
</div>
|
||||
<TitleRating className="mb-8 mt-10" score={review.score} />
|
||||
{review.title && (
|
||||
<div className="mb-8 text-base font-medium">{review.title}</div>
|
||||
)}
|
||||
<div className="whitespace-break-spaces text-sm">{review.body}</div>
|
||||
<div className="mt-16 items-center gap-8 md:flex">
|
||||
<Feedback review={review} />
|
||||
{!hideShareButton && <ShareButton review={review} />}
|
||||
<ReviewOptionsTrigger review={review} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ShareButtonProps {
|
||||
review: Review;
|
||||
}
|
||||
function ShareButton({review}: ShareButtonProps) {
|
||||
const {base_url} = useSettings();
|
||||
const location = useLocation();
|
||||
const url = `${base_url}${location.pathname}?reviewId=${review.id}`;
|
||||
const [, copyLink] = useClipboard(url);
|
||||
return (
|
||||
<Tooltip label={<Trans message="Share" />}>
|
||||
<IconButton
|
||||
className="text-muted"
|
||||
onClick={() => {
|
||||
copyLink();
|
||||
toast(message('Review link copied to clipboard'));
|
||||
}}
|
||||
>
|
||||
<ShareIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
interface FeedbackProps {
|
||||
review: Review;
|
||||
}
|
||||
function Feedback({review}: FeedbackProps) {
|
||||
const {user} = useAuth();
|
||||
const authHandler = useAuthClickCapture();
|
||||
const submitFeedback = useSubmitReviewFeedback(review);
|
||||
const isDisabled =
|
||||
submitFeedback.isPending || (user != null && user.id === review.user_id);
|
||||
|
||||
const [helpfulCount, setHelpfulCount] = useState(review.helpful_count || 1);
|
||||
const [total, setTotal] = useState(
|
||||
review.helpful_count + review.not_helpful_count || 1,
|
||||
);
|
||||
|
||||
let initialFeedback: string | undefined;
|
||||
if (review.current_user_feedback != null) {
|
||||
initialFeedback = review.current_user_feedback ? 'helpful' : 'not_helpful';
|
||||
}
|
||||
const [currentFeedback, setCurrentFeedback] = useState<string | undefined>(
|
||||
initialFeedback,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mr-auto flex flex-wrap items-center gap-6 max-md:mb-12">
|
||||
<div className="text-xs text-muted">
|
||||
<Trans
|
||||
message=":helpfulCount out of :total people found this helpful. Was this review helpful?"
|
||||
values={{helpfulCount, total}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-6 pb-2">
|
||||
<Button
|
||||
variant="link"
|
||||
className={clsx(
|
||||
'uppercase',
|
||||
currentFeedback === 'helpful' && 'pointer-events-none',
|
||||
)}
|
||||
color={currentFeedback === 'helpful' ? 'primary' : undefined}
|
||||
disabled={isDisabled}
|
||||
onClickCapture={authHandler}
|
||||
onClick={() =>
|
||||
submitFeedback.mutate(
|
||||
{isHelpful: true},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setHelpfulCount(count => count + 1);
|
||||
setCurrentFeedback('helpful');
|
||||
if (!currentFeedback) {
|
||||
setTotal(count => count + 1);
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trans message="Yes" />
|
||||
</Button>
|
||||
<div className="h-14 w-1 bg-divider"></div>
|
||||
<Button
|
||||
variant="link"
|
||||
className={clsx(
|
||||
'uppercase',
|
||||
currentFeedback === 'not_helpful' && 'pointer-events-none',
|
||||
)}
|
||||
color={currentFeedback === 'not_helpful' ? 'primary' : undefined}
|
||||
disabled={isDisabled}
|
||||
onClickCapture={authHandler}
|
||||
onClick={() =>
|
||||
submitFeedback.mutate(
|
||||
{isHelpful: false},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setHelpfulCount(count => count - 1);
|
||||
setCurrentFeedback('not_helpful');
|
||||
if (!currentFeedback) {
|
||||
setTotal(count => count + 1);
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trans message="No" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ReviewOptionsTriggerProps {
|
||||
review: Review;
|
||||
}
|
||||
export function ReviewOptionsTrigger({review}: ReviewOptionsTriggerProps) {
|
||||
const {user, hasPermission} = useAuth();
|
||||
const report = useSubmitReport(review);
|
||||
const deleteReport = useDeleteReport(review);
|
||||
const [isReported, setIsReported] = useState(review.current_user_reported);
|
||||
const handleReport = () => {
|
||||
if (isReported) {
|
||||
deleteReport.mutate(undefined, {
|
||||
onSuccess: () => setIsReported(false),
|
||||
});
|
||||
} else {
|
||||
report.mutate({}, {onSuccess: () => setIsReported(true)});
|
||||
}
|
||||
};
|
||||
|
||||
const deleteReview = useDeleteReviews();
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const showDeleteButton =
|
||||
(user && review.user_id === user.id) || hasPermission('reviews.delete');
|
||||
const handleDelete = (isConfirmed: boolean) => {
|
||||
setIsDeleteDialogOpen(false);
|
||||
if (isConfirmed) {
|
||||
deleteReview.mutate({reviewIds: [review.id]});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<MenuTrigger>
|
||||
<IconButton className="text-muted" aria-label="More options">
|
||||
<MoreVertIcon />
|
||||
</IconButton>
|
||||
<Menu>
|
||||
<MenuItem value="report" onSelected={() => handleReport()}>
|
||||
{isReported ? (
|
||||
<Trans message="Remove report" />
|
||||
) : (
|
||||
<Trans message="Report review" />
|
||||
)}
|
||||
</MenuItem>
|
||||
{showDeleteButton && (
|
||||
<MenuItem
|
||||
value="delete"
|
||||
onSelected={() => setIsDeleteDialogOpen(true)}
|
||||
>
|
||||
<Trans message="Delete" />
|
||||
</MenuItem>
|
||||
)}
|
||||
</Menu>
|
||||
</MenuTrigger>
|
||||
<DialogTrigger
|
||||
type="modal"
|
||||
isOpen={isDeleteDialogOpen}
|
||||
onClose={isConfirmed => handleDelete(isConfirmed)}
|
||||
>
|
||||
<ConfirmationDialog
|
||||
isDanger
|
||||
title={<Trans message="Delete review?" />}
|
||||
body={
|
||||
<Trans message="Are you sure you want to delete this review?" />
|
||||
}
|
||||
confirm={<Trans message="Delete" />}
|
||||
/>
|
||||
</DialogTrigger>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
interface UserDisplayNameProps {
|
||||
user: User;
|
||||
}
|
||||
function UserDisplayName({user}: UserDisplayNameProps) {
|
||||
const isMobile = useIsMobileMediaQuery();
|
||||
const {auth} = useContext(SiteConfigContext);
|
||||
const sharedClassName = 'flex items-center gap-8 text-base font-medium';
|
||||
if (auth.getUserProfileLink) {
|
||||
return (
|
||||
<Fragment>
|
||||
{isMobile && <UserAvatar user={user} size="sm" circle />}
|
||||
<Link
|
||||
to={auth.getUserProfileLink(user)}
|
||||
className={clsx('hover:underline', sharedClassName)}
|
||||
>
|
||||
{user.display_name}
|
||||
</Link>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={sharedClassName}>
|
||||
{isMobile && <UserAvatar user={user} size="sm" circle />}
|
||||
{user.display_name}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
66
resources/client/reviews/review-list/review-list-sort-button.tsx
Executable file
66
resources/client/reviews/review-list/review-list-sort-button.tsx
Executable file
@@ -0,0 +1,66 @@
|
||||
import {message} from '@common/i18n/message';
|
||||
import {
|
||||
Menu,
|
||||
MenuItem,
|
||||
MenuTrigger,
|
||||
} from '@common/ui/navigation/menu/menu-trigger';
|
||||
import {Button, ButtonProps} from '@common/ui/buttons/button';
|
||||
import {SortIcon} from '@common/icons/material/Sort';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
|
||||
const SortOptions = [
|
||||
{
|
||||
value: 'created_at:desc',
|
||||
label: message('Newest'),
|
||||
},
|
||||
{
|
||||
value: 'created_at:asc',
|
||||
label: message('Oldest'),
|
||||
},
|
||||
{
|
||||
value: 'mostHelpful',
|
||||
label: message('Most helpful'),
|
||||
},
|
||||
{
|
||||
value: 'reports_count:desc',
|
||||
label: message('Most reported'),
|
||||
},
|
||||
];
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onValueChange: (newValue: string) => void;
|
||||
color?: ButtonProps['color'];
|
||||
showReportsItem?: boolean;
|
||||
}
|
||||
export function ReviewListSortButton({
|
||||
value,
|
||||
onValueChange,
|
||||
color,
|
||||
showReportsItem,
|
||||
}: Props) {
|
||||
let selectedOption = SortOptions.find(option => option.value === value);
|
||||
if (!selectedOption) {
|
||||
selectedOption = SortOptions[0];
|
||||
}
|
||||
return (
|
||||
<MenuTrigger
|
||||
selectedValue={value}
|
||||
onSelectionChange={newValue => onValueChange(newValue as string)}
|
||||
selectionMode="single"
|
||||
>
|
||||
<Button variant="outline" startIcon={<SortIcon />} color={color}>
|
||||
<Trans {...selectedOption.label} />
|
||||
</Button>
|
||||
<Menu>
|
||||
{SortOptions.filter(
|
||||
option => option.value !== 'reports_count:desc' || showReportsItem
|
||||
).map(option => (
|
||||
<MenuItem value={option.value} key={option.value}>
|
||||
<Trans {...option.label} />
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</MenuTrigger>
|
||||
);
|
||||
}
|
||||
158
resources/client/reviews/review-list/review-list.tsx
Executable file
158
resources/client/reviews/review-list/review-list.tsx
Executable file
@@ -0,0 +1,158 @@
|
||||
import {useReviews} from '@app/reviews/requests/use-reviews';
|
||||
import {Reviewable} from '@app/reviews/reviewable';
|
||||
import {
|
||||
NewReviewForm,
|
||||
NewReviewFormActions,
|
||||
} from '@app/reviews/review-list/new-review-form';
|
||||
import React, {ReactNode, useRef} from 'react';
|
||||
import {ReviewListItem} from '@app/reviews/review-list/review-list-item';
|
||||
import {InfiniteScrollSentinel} from '@common/ui/infinite-scroll/infinite-scroll-sentinel';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {AnimatePresence, m} from 'framer-motion';
|
||||
import {opacityAnimation} from '@common/ui/animation/opacity-animation';
|
||||
import {Skeleton} from '@common/ui/skeleton/skeleton';
|
||||
import {IllustratedMessage} from '@common/ui/images/illustrated-message';
|
||||
import {Review} from '@app/titles/models/review';
|
||||
import {AccountRequiredCard} from '@common/comments/comment-list/account-required-card';
|
||||
import {useAuth} from '@common/auth/use-auth';
|
||||
import {message} from '@common/i18n/message';
|
||||
|
||||
const accountRequiredMessage = message(
|
||||
'Please <l>login</l> or <r>create account</r> to add a review',
|
||||
);
|
||||
|
||||
interface Props {
|
||||
reviewable: Reviewable;
|
||||
disabled?: boolean;
|
||||
noResultsMessage?: ReactNode;
|
||||
showAccountRequiredMessage?: boolean;
|
||||
}
|
||||
export function ReviewList({
|
||||
reviewable,
|
||||
disabled,
|
||||
noResultsMessage,
|
||||
showAccountRequiredMessage,
|
||||
}: Props) {
|
||||
const query = useReviews(reviewable);
|
||||
const actionsRef = useRef<NewReviewFormActions>(null);
|
||||
const {user} = useAuth();
|
||||
|
||||
const currentUserReview = query.data?.pages[0].current_user_review;
|
||||
const sharedReview = query.data?.pages[0].shared_review;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<NewReviewForm
|
||||
className="mb-14 md:-mx-14"
|
||||
reviewable={reviewable}
|
||||
currentReview={currentUserReview}
|
||||
ref={actionsRef}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<div>
|
||||
{showAccountRequiredMessage && (
|
||||
<AccountRequiredCard message={accountRequiredMessage} />
|
||||
)}
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
{query.isLoading ? (
|
||||
<ReviewListSkeletons count={4} />
|
||||
) : (
|
||||
<ReviewListItems
|
||||
reviews={query.items}
|
||||
sharedReview={sharedReview}
|
||||
noResultsMessage={noResultsMessage}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<div className="ml-84">
|
||||
<InfiniteScrollSentinel
|
||||
query={query}
|
||||
variant="loadMore"
|
||||
loaderMarginTop="mt-14"
|
||||
loadMoreExtraContent={
|
||||
<Button
|
||||
variant="flat"
|
||||
color="primary"
|
||||
disabled={!user}
|
||||
onClick={() => {
|
||||
actionsRef.current?.openReviewPanel();
|
||||
}}
|
||||
>
|
||||
<Trans message="Add a review" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ReviewListItemsProps {
|
||||
reviews: Review[];
|
||||
sharedReview?: Review;
|
||||
noResultsMessage?: ReactNode;
|
||||
}
|
||||
function ReviewListItems({
|
||||
reviews,
|
||||
sharedReview,
|
||||
noResultsMessage,
|
||||
}: ReviewListItemsProps) {
|
||||
const {user} = useAuth();
|
||||
|
||||
let content: ReactNode;
|
||||
|
||||
if (!reviews.length) {
|
||||
content = user
|
||||
? noResultsMessage || (
|
||||
<IllustratedMessage
|
||||
className="mt-24"
|
||||
size="sm"
|
||||
title={<Trans message="Seems a little quiet over here" />}
|
||||
description={<Trans message="Be the first to leave a review" />}
|
||||
/>
|
||||
)
|
||||
: null;
|
||||
} else {
|
||||
content = reviews.map(review => (
|
||||
<ReviewListItem key={review.id} review={review} />
|
||||
));
|
||||
}
|
||||
|
||||
return (
|
||||
<m.div key="reviews" {...opacityAnimation}>
|
||||
{sharedReview && <ReviewListItem review={sharedReview} isShared />}
|
||||
{content}
|
||||
</m.div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ReviewListSkeletonsProps {
|
||||
count: number;
|
||||
}
|
||||
export function ReviewListSkeletons({count}: ReviewListSkeletonsProps) {
|
||||
return (
|
||||
<m.div key="loading-skeleton" {...opacityAnimation}>
|
||||
{[...new Array(count).keys()].map(index => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-start gap-24 py-18 min-h-[212px] group"
|
||||
>
|
||||
<Skeleton variant="avatar" radius="rounded-full" size="w-60 h-60" />
|
||||
<div className="flex-auto text-sm">
|
||||
<Skeleton
|
||||
className="text-base font-medium max-w-200 mb-4"
|
||||
variant="text"
|
||||
/>
|
||||
<Skeleton variant="text" className="max-w-60 mb-8 mt-10 text-lg" />
|
||||
<Skeleton variant="text" className="mb-8 text-base max-w-240" />
|
||||
<Skeleton className="text-sm" variant="text" />
|
||||
<Skeleton className="text-sm" variant="text" />
|
||||
<Skeleton className="text-xs mt-16" variant="text" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</m.div>
|
||||
);
|
||||
}
|
||||
62
resources/client/reviews/review-list/star-selector.tsx
Executable file
62
resources/client/reviews/review-list/star-selector.tsx
Executable file
@@ -0,0 +1,62 @@
|
||||
import {useState} from 'react';
|
||||
import {IconButton} from '@common/ui/buttons/icon-button';
|
||||
import {StarIcon} from '@common/icons/material/Star';
|
||||
import {StarBorderIcon} from '@common/icons/material/StarBorder';
|
||||
import clsx from 'clsx';
|
||||
import {useIsMobileMediaQuery} from '@common/utils/hooks/is-mobile-media-query';
|
||||
import {useTrans} from '@common/i18n/use-trans';
|
||||
import {message} from '@common/i18n/message';
|
||||
|
||||
interface Props {
|
||||
count: number;
|
||||
value: number;
|
||||
onValueChange?: (value: number) => void;
|
||||
className?: string;
|
||||
readonly?: boolean;
|
||||
}
|
||||
export function StarSelector({
|
||||
count,
|
||||
value,
|
||||
onValueChange,
|
||||
className,
|
||||
readonly,
|
||||
}: Props) {
|
||||
const isMobile = useIsMobileMediaQuery();
|
||||
const [hoverRating, setHoverRating] = useState(value);
|
||||
const {trans} = useTrans();
|
||||
return (
|
||||
<div
|
||||
className={clsx('flex items-center', className)}
|
||||
onPointerLeave={() => {
|
||||
if (!readonly) {
|
||||
setHoverRating(value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{Array.from({length: count}).map((_, i) => {
|
||||
const number = i + 1;
|
||||
const isActive = hoverRating >= number;
|
||||
return (
|
||||
<IconButton
|
||||
key={i}
|
||||
size={isMobile ? 'xs' : 'sm'}
|
||||
aria-label={trans(
|
||||
message('Rate :count stars', {values: {count: number}})
|
||||
)}
|
||||
iconSize="md"
|
||||
color={isActive ? 'primary' : undefined}
|
||||
disabled={readonly}
|
||||
onClick={() => {
|
||||
onValueChange?.(number);
|
||||
}}
|
||||
onPointerEnter={() => {
|
||||
setHoverRating(number);
|
||||
}}
|
||||
>
|
||||
{isActive ? <StarIcon /> : <StarBorderIcon />}
|
||||
</IconButton>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user