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 { 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' });
|
||||
|
Reference in New Issue
Block a user