[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 { 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<{
handleCopyEditLink: () => void;
handleCopyPublicLink: () => void;
isDisabled: boolean;
isEditable: boolean;
isLoading: boolean;
profileId: string;
profileName?: string;
token?: string;
}>;
export default function ProfileComments({
handleCopyEditLink,
handleCopyPublicLink,
isDisabled,
isEditable,
isLoading,
profileId,
profileName,
token,
}: 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) {
return (
<div className="col-span-10 pt-4">
@ -24,7 +105,7 @@ export default function ProfileComments({
);
}
return (
<div className="m-4">
<div className="m-4 h-full">
<div className="flex-end flex justify-end space-x-4">
{isEditable && (
<Button
@ -49,10 +130,44 @@ export default function ProfileComments({
onClick={handleCopyPublicLink}
/>
</div>
<h2 className="mt-2 text-2xl font-bold">
Discussions feature coming soon
</h2>
{/* <TextArea label="Comment" placeholder="Type your comment here" /> */}
<h2 className="mt-2 mb-6 text-2xl font-bold">Discussions</h2>
<div>
<TextArea
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>
);
}

@ -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,
SENIOR = 3,
}
export type PaginationType = {
currentPage: number;
numOfItems: number;
numOfPages: number;
totalItems: number;
};

@ -158,3 +158,14 @@ export type BackgroundCard = {
specificYoes: Array<SpecificYoe>;
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 { formatDate } from '~/utils/offers/time';
import { trpc } from '~/utils/trpc';
import type { Profile, ProfileOffer } from '~/types/offers';
export default function OfferProfile() {
const ErrorPage = (
<Error statusCode={404} title="Requested profile does not exist." />
@ -29,7 +32,7 @@ export default function OfferProfile() {
],
{
enabled: typeof offerProfileId === 'string',
onSuccess: (data) => {
onSuccess: (data: Profile) => {
if (!data) {
router.push('/offers');
}
@ -42,7 +45,7 @@ export default function OfferProfile() {
if (data?.offers) {
const filteredOffers: Array<OfferEntity> = data
? data?.offers.map((res) => {
? data?.offers.map((res: ProfileOffer) => {
if (res.offersFullTime) {
const filteredOffer: OfferEntity = {
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 (
<>
{getProfileQuery.isError && ErrorPage}
@ -191,11 +181,12 @@ export default function OfferProfile() {
</div>
<div className="h-full w-1/3 bg-white">
<ProfileComments
handleCopyEditLink={handleCopyEditLink}
handleCopyPublicLink={handleCopyPublicLink}
isDisabled={deleteMutation.isLoading}
isEditable={isEditable}
isLoading={getProfileQuery.isLoading}
profileId={offerProfileId as string}
profileName={background?.profileName}
token={token as string}
/>
</div>
</div>

@ -2,6 +2,34 @@ import { getMonth, getYear } from 'date-fns';
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) {
const date = new Date(value);
// Const day = date.toLocaleString('default', { day: '2-digit' });

Loading…
Cancel
Save