[offers][feat] offer discussion section (#392)

* [offers][feat] add comment components

* [offers][feat] add comment reply components

* [offers][feat] offer discussion section

* [offers][chore] remove comments
pull/394/head
Zhang Ziqing 2 years ago committed by GitHub
parent bde445859a
commit 1ed11d9787
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -1,21 +1,102 @@
import { signIn, useSession } from 'next-auth/react';
import { useState } from 'react';
import { ClipboardDocumentIcon, ShareIcon } from '@heroicons/react/24/outline'; import { ClipboardDocumentIcon, ShareIcon } from '@heroicons/react/24/outline';
import { Button, Spinner } from '@tih/ui'; import { Button, HorizontalDivider, Spinner, TextArea } from '@tih/ui';
import ExpandableCommentCard from '~/components/offers/profile/comments/ExpandableCommentCard';
import { trpc } from '~/utils/trpc';
import type { OffersDiscussion, Reply } from '~/types/offers';
type ProfileHeaderProps = Readonly<{ type ProfileHeaderProps = Readonly<{
handleCopyEditLink: () => void;
handleCopyPublicLink: () => void;
isDisabled: boolean; isDisabled: boolean;
isEditable: boolean; isEditable: boolean;
isLoading: boolean; isLoading: boolean;
profileId: string;
profileName?: string;
token?: string;
}>; }>;
export default function ProfileComments({ export default function ProfileComments({
handleCopyEditLink,
handleCopyPublicLink,
isDisabled, isDisabled,
isEditable, isEditable,
isLoading, isLoading,
profileId,
profileName,
token,
}: ProfileHeaderProps) { }: ProfileHeaderProps) {
const { data: session, status } = useSession();
const [currentReply, setCurrentReply] = useState<string>('');
const [replies, setReplies] = useState<Array<Reply>>();
const commentsQuery = trpc.useQuery(
['offers.comments.getComments', { profileId }],
{
onSuccess(response: OffersDiscussion) {
setReplies(response.data);
},
},
);
const trpcContext = trpc.useContext();
const createCommentMutation = trpc.useMutation(['offers.comments.create'], {
onSuccess() {
trpcContext.invalidateQueries([
'offers.comments.getComments',
{ profileId },
]);
},
});
function handleComment(message: string) {
if (isEditable) {
// If it is with edit permission, send comment to API with username = null
createCommentMutation.mutate(
{
message,
profileId,
token,
},
{
onSuccess: () => {
setCurrentReply('');
},
},
);
} else if (status === 'authenticated') {
// If not the OP and logged in, send comment to API
createCommentMutation.mutate(
{
message,
profileId,
userId: session.user?.id,
},
{
onSuccess: () => {
setCurrentReply('');
},
},
);
} else {
// If not the OP and not logged in, direct users to log in
signIn();
}
}
function handleCopyEditLink() {
// TODO: Add notification
navigator.clipboard.writeText(
`${window.location.origin}/offers/profile/${profileId}?token=${token}`,
);
}
function handleCopyPublicLink() {
navigator.clipboard.writeText(
`${window.location.origin}/offers/profile/${profileId}`,
);
}
if (isLoading) { if (isLoading) {
return ( return (
<div className="col-span-10 pt-4"> <div className="col-span-10 pt-4">
@ -24,7 +105,7 @@ export default function ProfileComments({
); );
} }
return ( return (
<div className="m-4"> <div className="m-4 h-full">
<div className="flex-end flex justify-end space-x-4"> <div className="flex-end flex justify-end space-x-4">
{isEditable && ( {isEditable && (
<Button <Button
@ -49,10 +130,44 @@ export default function ProfileComments({
onClick={handleCopyPublicLink} onClick={handleCopyPublicLink}
/> />
</div> </div>
<h2 className="mt-2 text-2xl font-bold"> <h2 className="mt-2 mb-6 text-2xl font-bold">Discussions</h2>
Discussions feature coming soon <div>
</h2> <TextArea
{/* <TextArea label="Comment" placeholder="Type your comment here" /> */} label={`Comment as ${
isEditable ? profileName : session?.user?.name ?? 'anonymous'
}`}
placeholder="Type your comment here"
value={currentReply}
onChange={(value) => setCurrentReply(value)}
/>
<div className="mt-2 flex w-full justify-end">
<div className="w-fit">
<Button
disabled={commentsQuery.isLoading}
display="block"
isLabelHidden={false}
isLoading={createCommentMutation.isLoading}
label="Comment"
size="sm"
variant="primary"
onClick={() => handleComment(currentReply)}
/>
</div>
</div>
<HorizontalDivider />
</div>
<div className="h-full overflow-y-scroll">
<div className="h-content mb-96 w-full">
{replies?.map((reply: Reply) => (
<ExpandableCommentCard
key={reply.id}
comment={reply}
profileId={profileId}
token={isEditable ? token : undefined}
/>
))}
</div>
</div>
</div> </div>
); );
} }

@ -0,0 +1,152 @@
import { signIn, useSession } from 'next-auth/react';
import { useState } from 'react';
import { ChatBubbleBottomCenterIcon } from '@heroicons/react/24/outline';
import { Button, HorizontalDivider, TextArea } from '@tih/ui';
import { timeSinceNow } from '~/utils/offers/time';
import { trpc } from '../../../../utils/trpc';
import type { Reply } from '~/types/offers';
type Props = Readonly<{
comment: Reply;
disableReply?: boolean;
handleExpanded?: () => void;
isExpanded?: boolean;
profileId: string;
replyLength?: number;
token?: string;
}>;
export default function CommentCard({
comment: { createdAt, id, message, user },
disableReply,
handleExpanded,
isExpanded,
profileId,
token = '',
replyLength = 0,
}: Props) {
const { data: session, status } = useSession();
const [isReplying, setIsReplying] = useState(false);
const [currentReply, setCurrentReply] = useState<string>('');
const trpcContext = trpc.useContext();
const createCommentMutation = trpc.useMutation(['offers.comments.create'], {
onSuccess() {
trpcContext.invalidateQueries([
'offers.comments.getComments',
{ profileId },
]);
},
});
function handleReply() {
if (token && token.length > 0) {
// If it is with edit permission, send comment to API with username = null
createCommentMutation.mutate(
{
message: currentReply,
profileId,
replyingToId: id,
token,
},
{
onSuccess: () => {
setCurrentReply('');
setIsReplying(false);
if (!isExpanded) {
handleExpanded?.();
}
},
},
);
} else if (status === 'authenticated') {
// If not the OP and logged in, send comment to API
createCommentMutation.mutate(
{
message: currentReply,
profileId,
replyingToId: id,
userId: session.user?.id,
},
{
onSuccess: () => {
setCurrentReply('');
setIsReplying(false);
if (!isExpanded) {
handleExpanded?.();
}
},
},
);
} else {
// If not the OP and not logged in, direct users to log in
signIn();
}
}
return (
<>
<div className="flex pl-2">
<div className="flex w-full flex-col">
<div className="flex flex-row font-bold">
{user?.name ?? 'unknown user'}
</div>
<div className="mt-2 mb-2 flex flex-row ">{message}</div>
<div className="flex flex-row items-center justify-start space-x-4 ">
<div className="flex flex-col text-sm font-light text-gray-400">{`${timeSinceNow(
createdAt,
)} ago`}</div>
{replyLength > 0 && (
<div
className="flex cursor-pointer flex-col text-sm text-purple-600 hover:underline"
onClick={handleExpanded}>
{isExpanded ? `Hide replies` : `View replies (${replyLength})`}
</div>
)}
{!disableReply && (
<div className="flex flex-col">
<Button
icon={ChatBubbleBottomCenterIcon}
isLabelHidden={true}
label="Reply"
size="sm"
variant="tertiary"
onClick={() => setIsReplying(!isReplying)}
/>
</div>
)}
</div>
{!disableReply && isReplying && (
<div className="mt-2 mr-2">
<TextArea
isLabelHidden={true}
label="Comment"
placeholder="Type your comment here"
resize="none"
value={currentReply}
onChange={(value) => setCurrentReply(value)}
/>
<div className="mt-2 flex w-full justify-end">
<div className="w-fit">
<Button
display="block"
isLabelHidden={false}
isLoading={createCommentMutation.isLoading}
label="Reply"
size="sm"
variant="primary"
onClick={handleReply}
/>
</div>
</div>
</div>
)}
</div>
</div>
<HorizontalDivider />
</>
);
}

@ -0,0 +1,44 @@
import { useState } from 'react';
import CommentCard from '~/components/offers/profile/comments/CommentCard';
import type { Reply } from '~/types/offers';
type Props = Readonly<{
comment: Reply;
profileId: string;
token?: string;
}>;
export default function ExpandableCommentCard({
comment,
profileId,
token = '',
}: Props) {
const [isExpanded, setIsExpanded] = useState(false);
return (
<div>
<CommentCard
comment={comment}
handleExpanded={() => setIsExpanded(!isExpanded)}
isExpanded={isExpanded}
profileId={profileId}
replyLength={comment.replies?.length ?? 0}
token={token}
/>
{comment.replies && (
<div className="pl-8">
{isExpanded &&
comment.replies.map((reply) => (
<CommentCard
key={reply.id}
comment={reply}
disableReply={true}
profileId={profileId}
/>
))}
</div>
)}
</div>
);
}

@ -5,10 +5,3 @@ export enum YOE_CATEGORY {
MID = 2, MID = 2,
SENIOR = 3, SENIOR = 3,
} }
export type PaginationType = {
currentPage: number;
numOfItems: number;
numOfPages: number;
totalItems: number;
};

@ -158,3 +158,14 @@ export type BackgroundCard = {
specificYoes: Array<SpecificYoe>; specificYoes: Array<SpecificYoe>;
totalYoe: string; totalYoe: string;
}; };
export type CommentEntity = {
createdAt: Date;
id: string;
message: string;
profileId: string;
replies?: Array<CommentEntity>;
replyingToId: string;
userId: string;
username: string;
};

@ -10,6 +10,9 @@ import type { BackgroundCard, OfferEntity } from '~/components/offers/types';
import { convertCurrencyToString } from '~/utils/offers/currency'; import { convertCurrencyToString } from '~/utils/offers/currency';
import { formatDate } from '~/utils/offers/time'; import { formatDate } from '~/utils/offers/time';
import { trpc } from '~/utils/trpc'; import { trpc } from '~/utils/trpc';
import type { Profile, ProfileOffer } from '~/types/offers';
export default function OfferProfile() { export default function OfferProfile() {
const ErrorPage = ( const ErrorPage = (
<Error statusCode={404} title="Requested profile does not exist." /> <Error statusCode={404} title="Requested profile does not exist." />
@ -29,7 +32,7 @@ export default function OfferProfile() {
], ],
{ {
enabled: typeof offerProfileId === 'string', enabled: typeof offerProfileId === 'string',
onSuccess: (data) => { onSuccess: (data: Profile) => {
if (!data) { if (!data) {
router.push('/offers'); router.push('/offers');
} }
@ -42,7 +45,7 @@ export default function OfferProfile() {
if (data?.offers) { if (data?.offers) {
const filteredOffers: Array<OfferEntity> = data const filteredOffers: Array<OfferEntity> = data
? data?.offers.map((res) => { ? data?.offers.map((res: ProfileOffer) => {
if (res.offersFullTime) { if (res.offersFullTime) {
const filteredOffer: OfferEntity = { const filteredOffer: OfferEntity = {
base: convertCurrencyToString( base: convertCurrencyToString(
@ -153,19 +156,6 @@ export default function OfferProfile() {
} }
} }
function handleCopyEditLink() {
// TODO: Add notification
navigator.clipboard.writeText(
`${window.location.origin}/offers/profile/${offerProfileId}?token=${token}`,
);
}
function handleCopyPublicLink() {
navigator.clipboard.writeText(
`${window.location.origin}/offers/profile/${offerProfileId}`,
);
}
return ( return (
<> <>
{getProfileQuery.isError && ErrorPage} {getProfileQuery.isError && ErrorPage}
@ -191,11 +181,12 @@ export default function OfferProfile() {
</div> </div>
<div className="h-full w-1/3 bg-white"> <div className="h-full w-1/3 bg-white">
<ProfileComments <ProfileComments
handleCopyEditLink={handleCopyEditLink}
handleCopyPublicLink={handleCopyPublicLink}
isDisabled={deleteMutation.isLoading} isDisabled={deleteMutation.isLoading}
isEditable={isEditable} isEditable={isEditable}
isLoading={getProfileQuery.isLoading} isLoading={getProfileQuery.isLoading}
profileId={offerProfileId as string}
profileName={background?.profileName}
token={token as string}
/> />
</div> </div>
</div> </div>

@ -2,6 +2,34 @@ import { getMonth, getYear } from 'date-fns';
import type { MonthYear } from '~/components/shared/MonthYearPicker'; import type { MonthYear } from '~/components/shared/MonthYearPicker';
export function timeSinceNow(date: Date | number | string) {
const seconds = Math.floor(
new Date().getTime() / 1000 - new Date(date).getTime() / 1000,
);
let interval = seconds / 31536000;
if (interval > 1) {
return `${Math.floor(interval)} years`;
}
interval = seconds / 2592000;
if (interval > 1) {
return `${Math.floor(interval)} months`;
}
interval = seconds / 86400;
if (interval > 1) {
return `${Math.floor(interval)} days`;
}
interval = seconds / 3600;
if (interval > 1) {
return `${Math.floor(interval)} hours`;
}
interval = seconds / 60;
if (interval > 1) {
return `${Math.floor(interval)} minutes`;
}
return `${Math.floor(interval)} seconds`;
}
export function formatDate(value: Date | number | string) { export function formatDate(value: Date | number | string) {
const date = new Date(value); const date = new Date(value);
// Const day = date.toLocaleString('default', { day: '2-digit' }); // Const day = date.toLocaleString('default', { day: '2-digit' });

Loading…
Cancel
Save