mirror of
https://github.com/yangshun/tech-interview-handbook.git
synced 2025-07-28 20:52:00 +08:00
[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
This commit is contained in:
@ -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' });
|
||||||
|
Reference in New Issue
Block a user