+
+
+
+ Viewing offers for
+
+
+
+ in
+
+
+
+
+
+
+
+
);
diff --git a/apps/portal/src/pages/offers/profile/[offerProfileId].tsx b/apps/portal/src/pages/offers/profile/[offerProfileId].tsx
new file mode 100644
index 00000000..b7baac84
--- /dev/null
+++ b/apps/portal/src/pages/offers/profile/[offerProfileId].tsx
@@ -0,0 +1,256 @@
+import { useState } from 'react';
+import {
+ AcademicCapIcon,
+ BookmarkSquareIcon,
+ BriefcaseIcon,
+ BuildingOffice2Icon,
+ CalendarDaysIcon,
+ ClipboardDocumentIcon,
+ PencilSquareIcon,
+ ShareIcon,
+ TrashIcon,
+} from '@heroicons/react/24/outline';
+import { Button, Dialog, Tabs } from '@tih/ui';
+
+import EducationCard from '~/components/offers/profile/EducationCard';
+import OfferCard from '~/components/offers/profile/OfferCard';
+import ProfilePhotoHolder from '~/components/offers/profile/ProfilePhotoHolder';
+import { EducationBackgroundType } from '~/components/offers/types';
+
+export default function OfferProfile() {
+ const [selectedTab, setSelectedTab] = useState('offers');
+ const [isDialogOpen, setIsDialogOpen] = useState(false);
+ function renderActionList() {
+ return (
+
+
+
+
+ );
+ }
+ function ProfileHeader() {
+ return (
+
+
+
+
+
+
anonymised-name
+
+ {renderActionList()}
+
+
+
+
+ Current:
+ Level 4 Google
+
+
+
+ YOE:
+ 4
+
+
+
+
+
+ setSelectedTab(value)}
+ />
+
+
+ );
+ }
+
+ function ProfileDetails() {
+ if (selectedTab === 'offers') {
+ return (
+ <>
+ {[
+ {
+ base: undefined,
+ bonus: undefined,
+ companyName: 'Meta',
+ id: 1,
+ jobLevel: 'G5',
+ jobTitle: 'Software Engineer',
+ location: 'Singapore',
+ monthlySalary: undefined,
+ negotiationStrategy:
+ 'Nostrud nulla aliqua deserunt commodo id aute.',
+ otherComment:
+ 'Pariatur ut est voluptate incididunt consequat do veniam quis irure adipisicing. Deserunt laborum dolor quis voluptate enim.',
+ receivedMonth: 'Jun 2022',
+ stocks: undefined,
+ totalCompensation: undefined,
+ },
+ {
+ companyName: 'Meta',
+ id: 2,
+ jobLevel: 'G5',
+ jobTitle: 'Software Engineer',
+ location: 'Singapore',
+ receivedMonth: 'Jun 2022',
+ },
+ {
+ companyName: 'Meta',
+ id: 3,
+ jobLevel: 'G5',
+ jobTitle: 'Software Engineer',
+ location: 'Singapore',
+ receivedMonth: 'Jun 2022',
+ },
+ ].map((offer) => (
+
+ ))}
+ >
+ );
+ }
+ if (selectedTab === 'background') {
+ return (
+ <>
+
+
+ Work Experience
+
+
+
+
+ >
+ );
+ }
+ return
Detail page for {selectedTab}
;
+ }
+
+ function ProfileComments() {
+ return (
+
+
+
+
+
+
+ Discussions feature coming soon
+
+ {/*
*/}
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/apps/portal/src/pages/offers/submit.tsx b/apps/portal/src/pages/offers/submit.tsx
new file mode 100644
index 00000000..b85e098c
--- /dev/null
+++ b/apps/portal/src/pages/offers/submit.tsx
@@ -0,0 +1,111 @@
+import { useState } from 'react';
+import type { SubmitHandler } from 'react-hook-form';
+import { FormProvider, useForm } from 'react-hook-form';
+import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/20/solid';
+import { Button } from '@tih/ui';
+
+import BackgroundForm from '~/components/offers/forms/BackgroundForm';
+import OfferAnalysis from '~/components/offers/forms/OfferAnalysis';
+import OfferDetailsForm from '~/components/offers/forms/OfferDetailsForm';
+import OfferProfileSave from '~/components/offers/forms/OfferProfileSave';
+import type { SubmitOfferFormData } from '~/components/offers/types';
+
+function Breadcrumbs() {
+ return (
+
+ {'Offer details > Background > Analysis > Save'}
+
+ );
+}
+
+const defaultOfferValues = {
+ offers: [
+ {
+ comments: '',
+ companyId: '',
+ job: {
+ base: {
+ currency: 'USD',
+ value: 0,
+ },
+ bonus: {
+ currency: 'USD',
+ value: 0,
+ },
+ level: '',
+ specialization: '',
+ stocks: {
+ currency: 'USD',
+ value: 0,
+ },
+ title: '',
+ totalCompensation: {
+ currency: 'USD',
+ value: 0,
+ },
+ },
+ jobType: 'FULLTIME',
+ location: '',
+ monthYearReceived: '',
+ negotiationStrategy: '',
+ },
+ ],
+};
+
+export default function OffersSubmissionPage() {
+ const [formStep, setFormStep] = useState(0);
+ const formMethods = useForm
({
+ defaultValues: defaultOfferValues,
+ });
+
+ const nextStep = () => setFormStep(formStep + 1);
+ const previousStep = () => setFormStep(formStep - 1);
+
+ const formComponents = [
+ ,
+ ,
+ ,
+ ,
+ ];
+
+ const onSubmit: SubmitHandler = async () => {
+ nextStep();
+ };
+
+ return (
+
+ );
+}
diff --git a/apps/portal/src/pages/questions/[questionId]/[questionSlug]/answer/[answerId]/[answerSlug]/index.tsx b/apps/portal/src/pages/questions/[questionId]/[questionSlug]/answer/[answerId]/[answerSlug]/index.tsx
new file mode 100644
index 00000000..aac0a7a2
--- /dev/null
+++ b/apps/portal/src/pages/questions/[questionId]/[questionSlug]/answer/[answerId]/[answerSlug]/index.tsx
@@ -0,0 +1,159 @@
+import { useRouter } from 'next/router';
+import { useForm } from 'react-hook-form';
+import { ArrowSmallLeftIcon } from '@heroicons/react/24/outline';
+import { Button, Select, TextArea } from '@tih/ui';
+
+import FullAnswerCard from '~/components/questions/card/FullAnswerCard';
+import CommentListItem from '~/components/questions/CommentListItem';
+import FullScreenSpinner from '~/components/questions/FullScreenSpinner';
+
+import {
+ SAMPLE_ANSWER,
+ SAMPLE_ANSWER_COMMENT,
+} from '~/utils/questions/constants';
+import { useFormRegister } from '~/utils/questions/useFormRegister';
+import { trpc } from '~/utils/trpc';
+
+export type AnswerCommentData = {
+ commentContent: string;
+};
+
+export default function QuestionPage() {
+ const router = useRouter();
+
+ const {
+ register: comRegister,
+ reset: resetComment,
+ handleSubmit: handleCommentSubmit,
+ formState: { isDirty: isCommentDirty, isValid: isCommentValid },
+ } = useForm({ mode: 'onChange' });
+ const commentRegister = useFormRegister(comRegister);
+
+ const { answerId } = router.query;
+
+ const utils = trpc.useContext();
+
+ const { data: answer } = trpc.useQuery([
+ 'questions.answers.getAnswerById',
+ { answerId: answerId as string },
+ ]);
+
+ const { data: comments } = trpc.useQuery([
+ 'questions.answers.comments.getAnswerComments',
+ { answerId: answerId as string },
+ ]);
+
+ const { mutate: addComment } = trpc.useMutation(
+ 'questions.answers.comments.create',
+ {
+ onSuccess: () => {
+ utils.invalidateQuery([
+ 'questions.answers.comments.getAnswerComments',
+ { answerId: answerId as string },
+ ]);
+ },
+ },
+ );
+
+ const handleBackNavigation = () => {
+ router.back();
+ };
+
+ const handleSubmitComment = (data: AnswerCommentData) => {
+ resetComment();
+ addComment({
+ answerId: answerId as string,
+ content: data.commentContent,
+ });
+ };
+
+ if (!answer) {
+ return ;
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {(comments ?? []).map((comment) => (
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/apps/portal/src/pages/questions/[questionId]/[questionSlug]/index.tsx b/apps/portal/src/pages/questions/[questionId]/[questionSlug]/index.tsx
new file mode 100644
index 00000000..92149e81
--- /dev/null
+++ b/apps/portal/src/pages/questions/[questionId]/[questionSlug]/index.tsx
@@ -0,0 +1,252 @@
+import { useRouter } from 'next/router';
+import { useForm } from 'react-hook-form';
+import { ArrowSmallLeftIcon } from '@heroicons/react/24/outline';
+import { Button, Collapsible, Select, TextArea } from '@tih/ui';
+
+import AnswerCard from '~/components/questions/card/AnswerCard';
+import FullQuestionCard from '~/components/questions/card/FullQuestionCard';
+import CommentListItem from '~/components/questions/CommentListItem';
+import FullScreenSpinner from '~/components/questions/FullScreenSpinner';
+
+import {
+ SAMPLE_ANSWER,
+ SAMPLE_QUESTION_COMMENT,
+} from '~/utils/questions/constants';
+import createSlug from '~/utils/questions/createSlug';
+import { useFormRegister } from '~/utils/questions/useFormRegister';
+import { trpc } from '~/utils/trpc';
+
+export type AnswerQuestionData = {
+ answerContent: string;
+};
+
+export type QuestionCommentData = {
+ commentContent: string;
+};
+
+export default function QuestionPage() {
+ const router = useRouter();
+ const {
+ register: ansRegister,
+ handleSubmit,
+ formState: { isDirty, isValid },
+ } = useForm({ mode: 'onChange' });
+ const answerRegister = useFormRegister(ansRegister);
+
+ const {
+ register: comRegister,
+ handleSubmit: handleCommentSubmit,
+ reset: resetComment,
+ formState: { isDirty: isCommentDirty, isValid: isCommentValid },
+ } = useForm({ mode: 'onChange' });
+ const commentRegister = useFormRegister(comRegister);
+
+ const { questionId } = router.query;
+
+ const { data: question } = trpc.useQuery([
+ 'questions.questions.getQuestionById',
+ { id: questionId as string },
+ ]);
+
+ const utils = trpc.useContext();
+
+ const { data: comments } = trpc.useQuery([
+ 'questions.questions.comments.getQuestionComments',
+ { questionId: questionId as string },
+ ]);
+
+ const { mutate: addComment } = trpc.useMutation(
+ 'questions.questions.comments.create',
+ {
+ onSuccess: () => {
+ utils.invalidateQueries(
+ 'questions.questions.comments.getQuestionComments',
+ );
+ },
+ },
+ );
+
+ const { data: answers } = trpc.useQuery([
+ 'questions.answers.getAnswers',
+ { questionId: questionId as string },
+ ]);
+
+ const { mutate: addAnswer } = trpc.useMutation('questions.answers.create', {
+ onSuccess: () => {
+ utils.invalidateQueries('questions.answers.getAnswers');
+ },
+ });
+
+ const handleBackNavigation = () => {
+ router.back();
+ };
+
+ const handleSubmitAnswer = (data: AnswerQuestionData) => {
+ addAnswer({
+ content: data.answerContent,
+ questionId: questionId as string,
+ });
+ };
+
+ const handleSubmitComment = (data: QuestionCommentData) => {
+ addComment({
+ content: data.commentContent,
+ questionId: questionId as string,
+ });
+ resetComment();
+ };
+
+ if (!question) {
+ return ;
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ {(comments ?? []).map((comment) => (
+
+ ))}
+
+
+
+ {(answers ?? []).map((answer) => (
+
+ ))}
+
+
+
+ );
+}
diff --git a/apps/portal/src/pages/questions/index.tsx b/apps/portal/src/pages/questions/index.tsx
index ee7b3512..826a6204 100644
--- a/apps/portal/src/pages/questions/index.tsx
+++ b/apps/portal/src/pages/questions/index.tsx
@@ -1,104 +1,164 @@
-import { useMemo, useState } from 'react';
+import { subMonths, subYears } from 'date-fns';
+import { useRouter } from 'next/router';
+import { useEffect, useMemo, useState } from 'react';
+import type { QuestionsQuestionType } from '@prisma/client';
+import QuestionOverviewCard from '~/components/questions/card/QuestionOverviewCard';
import ContributeQuestionCard from '~/components/questions/ContributeQuestionCard';
-import type { FilterOptions } from '~/components/questions/filter/FilterSection';
import FilterSection from '~/components/questions/filter/FilterSection';
-import QuestionOverviewCard from '~/components/questions/QuestionOverviewCard';
+import type { LandingQueryData } from '~/components/questions/LandingComponent';
+import LandingComponent from '~/components/questions/LandingComponent';
import QuestionSearchBar from '~/components/questions/QuestionSearchBar';
-type FilterChoices = Array>;
-
-const companies: FilterChoices = [
- {
- label: 'Google',
- value: 'Google',
- },
- {
- label: 'Meta',
- value: 'meta',
- },
-];
-
-// Code, design, behavioral
-const questionTypes: FilterChoices = [
- {
- label: 'Code',
- value: 'code',
- },
- {
- label: 'Design',
- value: 'design',
- },
- {
- label: 'Behavioral',
- value: 'behavioral',
- },
-];
-
-const questionAges: FilterChoices = [
- {
- label: 'Last month',
- value: 'last-month',
- },
- {
- label: 'Last 6 months',
- value: 'last-6-months',
- },
- {
- label: 'Last year',
- value: 'last-year',
- },
-];
-
-const locations: FilterChoices = [
- {
- label: 'Singapore',
- value: 'singapore',
- },
-];
+import type { QuestionAge } from '~/utils/questions/constants';
+import {
+ COMPANIES,
+ LOCATIONS,
+ QUESTION_AGES,
+ QUESTION_TYPES,
+} from '~/utils/questions/constants';
+import createSlug from '~/utils/questions/createSlug';
+import {
+ useSearchFilter,
+ useSearchFilterSingle,
+} from '~/utils/questions/useSearchFilter';
+import { trpc } from '~/utils/trpc';
export default function QuestionsHomePage() {
- const [selectedCompanies, setSelectedCompanies] = useState>([]);
- const [selectedQuestionTypes, setSelectedQuestionTypes] = useState<
- Array
- >([]);
- const [selectedQuestionAges, setSelectedQuestionAges] = useState<
- Array
- >([]);
- const [selectedLocations, setSelectedLocations] = useState>([]);
+ const router = useRouter();
+
+ const [selectedCompanies, setSelectedCompanies, areCompaniesInitialized] =
+ useSearchFilter('companies');
+ const [
+ selectedQuestionTypes,
+ setSelectedQuestionTypes,
+ areQuestionTypesInitialized,
+ ] = useSearchFilter('questionTypes', {
+ queryParamToValue: (param) => {
+ return param.toUpperCase() as QuestionsQuestionType;
+ },
+ });
+ const [
+ selectedQuestionAge,
+ setSelectedQuestionAge,
+ isQuestionAgeInitialized,
+ ] = useSearchFilterSingle('questionAge', {
+ defaultValue: 'all',
+ });
+ const [selectedLocations, setSelectedLocations, areLocationsInitialized] =
+ useSearchFilter('locations');
+
+ const today = useMemo(() => new Date(), []);
+ const startDate = useMemo(() => {
+ return selectedQuestionAge === 'last-year'
+ ? subYears(new Date(), 1)
+ : selectedQuestionAge === 'last-6-months'
+ ? subMonths(new Date(), 6)
+ : selectedQuestionAge === 'last-month'
+ ? subMonths(new Date(), 1)
+ : undefined;
+ }, [selectedQuestionAge]);
+
+ const { data: questions } = trpc.useQuery([
+ 'questions.questions.getQuestionsByFilter',
+ {
+ companies: selectedCompanies,
+ endDate: today,
+ locations: selectedLocations,
+ questionTypes: selectedQuestionTypes,
+ roles: [],
+ startDate,
+ },
+ ]);
+
+ const utils = trpc.useContext();
+ const { mutate: createQuestion } = trpc.useMutation(
+ 'questions.questions.create',
+ {
+ onSuccess: () => {
+ utils.invalidateQueries('questions.questions.getQuestionsByFilter');
+ },
+ },
+ );
+
+ const [hasLanded, setHasLanded] = useState(false);
+ const [loaded, setLoaded] = useState(false);
const companyFilterOptions = useMemo(() => {
- return companies.map((company) => ({
+ return COMPANIES.map((company) => ({
...company,
checked: selectedCompanies.includes(company.value),
}));
}, [selectedCompanies]);
const questionTypeFilterOptions = useMemo(() => {
- return questionTypes.map((questionType) => ({
+ return QUESTION_TYPES.map((questionType) => ({
...questionType,
checked: selectedQuestionTypes.includes(questionType.value),
}));
}, [selectedQuestionTypes]);
const questionAgeFilterOptions = useMemo(() => {
- return questionAges.map((questionAge) => ({
+ return QUESTION_AGES.map((questionAge) => ({
...questionAge,
- checked: selectedQuestionAges.includes(questionAge.value),
+ checked: selectedQuestionAge === questionAge.value,
}));
- }, [selectedQuestionAges]);
+ }, [selectedQuestionAge]);
const locationFilterOptions = useMemo(() => {
- return locations.map((location) => ({
+ return LOCATIONS.map((location) => ({
...location,
checked: selectedLocations.includes(location.value),
}));
}, [selectedLocations]);
- return (
+ const handleLandingQuery = (data: LandingQueryData) => {
+ const { company, location, questionType } = data;
+ setSelectedCompanies([company]);
+ setSelectedLocations([location]);
+ setSelectedQuestionTypes([questionType as QuestionsQuestionType]);
+ setHasLanded(true);
+ };
+
+ const areFiltersInitialized = useMemo(() => {
+ return (
+ areCompaniesInitialized &&
+ areQuestionTypesInitialized &&
+ isQuestionAgeInitialized &&
+ areLocationsInitialized
+ );
+ }, [
+ areCompaniesInitialized,
+ areQuestionTypesInitialized,
+ isQuestionAgeInitialized,
+ areLocationsInitialized,
+ ]);
+
+ useEffect(() => {
+ if (areFiltersInitialized) {
+ const hasFilter =
+ router.query.companies ||
+ router.query.questionTypes ||
+ router.query.questionAge ||
+ router.query.locations;
+ if (hasFilter) {
+ setHasLanded(true);
+ }
+ // Console.log('landed', hasLanded);
+ setLoaded(true);
+ }
+ }, [areFiltersInitialized, hasLanded, router.query]);
+
+ if (!loaded) {
+ return null;
+ }
+
+ return !hasLanded ? (
+
+ ) : (
-
+
-
-
-
+
+
+
+
{
- // eslint-disable-next-line no-console
- console.log(data);
+ createQuestion({
+ company: data.company,
+ content: data.questionContent,
+ location: data.location,
+ questionType: data.questionType,
+ role: data.role,
+ seenAt: data.date,
+ });
}}
/>
{
+ // eslint-disable-next-line no-console
+ console.log(value);
+ }}
/>
-
+ {(questions ?? []).map((question) => (
+
+ ))}
-
+
);
diff --git a/apps/portal/src/pages/resumes/submit.tsx b/apps/portal/src/pages/resumes/submit.tsx
index 1ca06031..97ea8f12 100644
--- a/apps/portal/src/pages/resumes/submit.tsx
+++ b/apps/portal/src/pages/resumes/submit.tsx
@@ -140,7 +140,7 @@ export default function SubmitResumeForm() {
setValue('isChecked', val)}
/>
{
+ const votes: number = data.votes.reduce(
+ (previousValue: number, currentValue) => {
+ let result: number = previousValue;
+
+ switch (currentValue.vote) {
+ case Vote.UPVOTE:
+ result += 1;
+ break;
+ case Vote.DOWNVOTE:
+ result -= 1;
+ break;
+ }
+ return result;
+ },
+ 0,
+ );
+
+ const answerComment: AnswerComment = {
+ content: data.content,
+ createdAt: data.createdAt,
+ id: data.id,
+ numVotes: votes,
+ updatedAt: data.updatedAt,
+ user: data.user?.name ?? '',
+ };
+ return answerComment;
+ });
+ },
+ })
+ .mutation('create', {
+ input: z.object({
+ answerId: z.string(),
+ content: z.string(),
+ }),
+ async resolve({ ctx, input }) {
+ const userId = ctx.session?.user?.id;
+
+ return await ctx.prisma.questionsAnswerComment.create({
+ data: {
+ ...input,
+ userId,
+ },
+ });
+ },
+ })
+ .mutation('update', {
+ input: z.object({
+ content: z.string().optional(),
+ id: z.string(),
+ }),
+ async resolve({ ctx, input }) {
+ const userId = ctx.session?.user?.id;
+
+ const answerCommentToUpdate =
+ await ctx.prisma.questionsAnswerComment.findUnique({
+ where: {
+ id: input.id,
+ },
+ });
+
+ if (answerCommentToUpdate?.id !== userId) {
+ throw new TRPCError({
+ code: 'UNAUTHORIZED',
+ message: 'User have no authorization to record.',
+ });
+ }
+
+ return await ctx.prisma.questionsAnswerComment.update({
+ data: {
+ ...input,
+ },
+ where: {
+ id: input.id,
+ },
+ });
+ },
+ })
+ .mutation('delete', {
+ input: z.object({
+ id: z.string(),
+ }),
+ async resolve({ ctx, input }) {
+ const userId = ctx.session?.user?.id;
+
+ const answerCommentToDelete =
+ await ctx.prisma.questionsAnswerComment.findUnique({
+ where: {
+ id: input.id,
+ },
+ });
+
+ if (answerCommentToDelete?.id !== userId) {
+ throw new TRPCError({
+ code: 'UNAUTHORIZED',
+ message: 'User have no authorization to record.',
+ });
+ }
+
+ return await ctx.prisma.questionsAnswerComment.delete({
+ where: {
+ id: input.id,
+ },
+ });
+ },
+ })
+ .query('getVote', {
+ input: z.object({
+ answerCommentId: z.string(),
+ }),
+ async resolve({ ctx, input }) {
+ const userId = ctx.session?.user?.id;
+ const { answerCommentId } = input;
+
+ return await ctx.prisma.questionsAnswerCommentVote.findUnique({
+ where: {
+ answerCommentId_userId: { answerCommentId, userId },
+ },
+ });
+ },
+ })
+ .mutation('createVote', {
+ input: z.object({
+ answerCommentId: z.string(),
+ vote: z.nativeEnum(Vote),
+ }),
+ async resolve({ ctx, input }) {
+ const userId = ctx.session?.user?.id;
+
+ return await ctx.prisma.questionsAnswerCommentVote.create({
+ data: {
+ ...input,
+ userId,
+ },
+ });
+ },
+ })
+ .mutation('updateVote', {
+ input: z.object({
+ id: z.string(),
+ vote: z.nativeEnum(Vote),
+ }),
+ async resolve({ ctx, input }) {
+ const userId = ctx.session?.user?.id;
+ const { id, vote } = input;
+
+ const voteToUpdate =
+ await ctx.prisma.questionsAnswerCommentVote.findUnique({
+ where: {
+ id: input.id,
+ },
+ });
+
+ if (voteToUpdate?.id !== userId) {
+ throw new TRPCError({
+ code: 'UNAUTHORIZED',
+ message: 'User have no authorization to record.',
+ });
+ }
+
+ return await ctx.prisma.questionsAnswerCommentVote.update({
+ data: {
+ vote,
+ },
+ where: {
+ id,
+ },
+ });
+ },
+ })
+ .mutation('deleteVote', {
+ input: z.object({
+ id: z.string(),
+ }),
+ async resolve({ ctx, input }) {
+ const userId = ctx.session?.user?.id;
+
+ const voteToDelete =
+ await ctx.prisma.questionsAnswerCommentVote.findUnique({
+ where: {
+ id: input.id,
+ },
+ });
+
+ if (voteToDelete?.id !== userId) {
+ throw new TRPCError({
+ code: 'UNAUTHORIZED',
+ message: 'User have no authorization to record.',
+ });
+ }
+
+ return await ctx.prisma.questionsAnswerCommentVote.delete({
+ where: {
+ id: input.id,
+ },
+ });
+ },
+ });
diff --git a/apps/portal/src/server/router/questions-answer-router.ts b/apps/portal/src/server/router/questions-answer-router.ts
new file mode 100644
index 00000000..21095bf7
--- /dev/null
+++ b/apps/portal/src/server/router/questions-answer-router.ts
@@ -0,0 +1,287 @@
+import { z } from 'zod';
+import { Vote } from '@prisma/client';
+import { TRPCError } from '@trpc/server';
+
+import { createProtectedRouter } from './context';
+
+import type { Answer } from '~/types/questions';
+
+export const questionsAnswerRouter = createProtectedRouter()
+ .query('getAnswers', {
+ input: z.object({
+ questionId: z.string(),
+ }),
+ async resolve({ ctx, input }) {
+ const answersData = await ctx.prisma.questionsAnswer.findMany({
+ include: {
+ _count: {
+ select: {
+ comments: true,
+ },
+ },
+ user: {
+ select: {
+ name: true,
+ },
+ },
+ votes: true,
+ },
+ orderBy: {
+ createdAt: 'desc',
+ },
+ where: {
+ ...input,
+ },
+ });
+ return answersData.map((data) => {
+ const votes: number = data.votes.reduce(
+ (previousValue: number, currentValue) => {
+ let result: number = previousValue;
+
+ switch (currentValue.vote) {
+ case Vote.UPVOTE:
+ result += 1;
+ break;
+ case Vote.DOWNVOTE:
+ result -= 1;
+ break;
+ }
+ return result;
+ },
+ 0,
+ );
+
+ const answer: Answer = {
+ content: data.content,
+ createdAt: data.createdAt,
+ id: data.id,
+ numComments: data._count.comments,
+ numVotes: votes,
+ user: data.user?.name ?? '',
+ };
+ return answer;
+ });
+ },
+ })
+ .query('getAnswerById', {
+ input: z.object({
+ answerId: z.string(),
+ }),
+ async resolve({ ctx, input }) {
+ const answerData = await ctx.prisma.questionsAnswer.findUnique({
+ include: {
+ _count: {
+ select: {
+ comments: true,
+ },
+ },
+ user: {
+ select: {
+ name: true,
+ },
+ },
+ votes: true,
+ },
+ where: {
+ id: input.answerId,
+ },
+ });
+ if (!answerData) {
+ throw new TRPCError({
+ code: 'NOT_FOUND',
+ message: 'Answer not found',
+ });
+ }
+ const votes: number = answerData.votes.reduce(
+ (previousValue: number, currentValue) => {
+ let result: number = previousValue;
+
+ switch (currentValue.vote) {
+ case Vote.UPVOTE:
+ result += 1;
+ break;
+ case Vote.DOWNVOTE:
+ result -= 1;
+ break;
+ }
+ return result;
+ },
+ 0,
+ );
+
+ const answer: Answer = {
+ content: answerData.content,
+ createdAt: answerData.createdAt,
+ id: answerData.id,
+ numComments: answerData._count.comments,
+ numVotes: votes,
+ user: answerData.user?.name ?? '',
+ };
+ return answer;
+ },
+ })
+ .mutation('create', {
+ input: z.object({
+ content: z.string(),
+ questionId: z.string(),
+ }),
+ async resolve({ ctx, input }) {
+ const userId = ctx.session?.user?.id;
+
+ return await ctx.prisma.questionsAnswer.create({
+ data: {
+ ...input,
+ userId,
+ },
+ });
+ },
+ })
+ .mutation('update', {
+ input: z.object({
+ content: z.string().optional(),
+ id: z.string(),
+ }),
+ async resolve({ ctx, input }) {
+ const userId = ctx.session?.user?.id;
+ const { content, id } = input;
+
+ const answerToUpdate = await ctx.prisma.questionsAnswer.findUnique({
+ where: {
+ id: input.id,
+ },
+ });
+
+ if (answerToUpdate?.id !== userId) {
+ throw new TRPCError({
+ code: 'UNAUTHORIZED',
+ message: 'User have no authorization to record.',
+ });
+ }
+
+ return await ctx.prisma.questionsAnswer.update({
+ data: {
+ content,
+ },
+ where: {
+ id,
+ },
+ });
+ },
+ })
+ .mutation('delete', {
+ input: z.object({
+ id: z.string(),
+ }),
+ async resolve({ ctx, input }) {
+ const userId = ctx.session?.user?.id;
+
+ const answerToDelete = await ctx.prisma.questionsAnswer.findUnique({
+ where: {
+ id: input.id,
+ },
+ });
+
+ if (answerToDelete?.id !== userId) {
+ throw new TRPCError({
+ code: 'UNAUTHORIZED',
+ message: 'User have no authorization to record.',
+ });
+ }
+
+ return await ctx.prisma.questionsAnswer.delete({
+ where: {
+ id: input.id,
+ },
+ });
+ },
+ })
+ .query('getVote', {
+ input: z.object({
+ answerId: z.string(),
+ }),
+ async resolve({ ctx, input }) {
+ const userId = ctx.session?.user?.id;
+ const { answerId } = input;
+
+ return await ctx.prisma.questionsAnswerVote.findUnique({
+ where: {
+ answerId_userId: { answerId, userId },
+ },
+ });
+ },
+ })
+ .mutation('createVote', {
+ input: z.object({
+ answerId: z.string(),
+ vote: z.nativeEnum(Vote),
+ }),
+ async resolve({ ctx, input }) {
+ const userId = ctx.session?.user?.id;
+
+ return await ctx.prisma.questionsAnswerVote.create({
+ data: {
+ ...input,
+ userId,
+ },
+ });
+ },
+ })
+ .mutation('updateVote', {
+ input: z.object({
+ id: z.string(),
+ vote: z.nativeEnum(Vote),
+ }),
+ async resolve({ ctx, input }) {
+ const userId = ctx.session?.user?.id;
+ const { id, vote } = input;
+
+ const voteToUpdate = await ctx.prisma.questionsAnswerVote.findUnique({
+ where: {
+ id: input.id,
+ },
+ });
+
+ if (voteToUpdate?.id !== userId) {
+ throw new TRPCError({
+ code: 'UNAUTHORIZED',
+ message: 'User have no authorization to record.',
+ });
+ }
+
+ return await ctx.prisma.questionsAnswerVote.update({
+ data: {
+ vote,
+ },
+ where: {
+ id,
+ },
+ });
+ },
+ })
+ .mutation('deleteVote', {
+ input: z.object({
+ id: z.string(),
+ }),
+ async resolve({ ctx, input }) {
+ const userId = ctx.session?.user?.id;
+
+ const voteToDelete = await ctx.prisma.questionsAnswerVote.findUnique({
+ where: {
+ id: input.id,
+ },
+ });
+
+ if (voteToDelete?.id !== userId) {
+ throw new TRPCError({
+ code: 'UNAUTHORIZED',
+ message: 'User have no authorization to record.',
+ });
+ }
+
+ return await ctx.prisma.questionsAnswerVote.delete({
+ where: {
+ id: input.id,
+ },
+ });
+ },
+ });
diff --git a/apps/portal/src/server/router/questions-question-comment-router.ts b/apps/portal/src/server/router/questions-question-comment-router.ts
new file mode 100644
index 00000000..82345f06
--- /dev/null
+++ b/apps/portal/src/server/router/questions-question-comment-router.ts
@@ -0,0 +1,228 @@
+import { z } from 'zod';
+import { Vote } from '@prisma/client';
+import { TRPCError } from '@trpc/server';
+
+import { createProtectedRouter } from './context';
+
+import type { QuestionComment } from '~/types/questions';
+
+export const questionsQuestionCommentRouter = createProtectedRouter()
+ .query('getQuestionComments', {
+ input: z.object({
+ questionId: z.string(),
+ }),
+ async resolve({ ctx, input }) {
+ const questionCommentsData =
+ await ctx.prisma.questionsQuestionComment.findMany({
+ include: {
+ user: {
+ select: {
+ name: true,
+ },
+ },
+ votes: true,
+ },
+ orderBy: {
+ createdAt: 'desc',
+ },
+ where: {
+ ...input,
+ },
+ });
+ return questionCommentsData.map((data) => {
+ const votes: number = data.votes.reduce(
+ (previousValue: number, currentValue) => {
+ let result: number = previousValue;
+
+ switch (currentValue.vote) {
+ case Vote.UPVOTE:
+ result += 1;
+ break;
+ case Vote.DOWNVOTE:
+ result -= 1;
+ break;
+ }
+ return result;
+ },
+ 0,
+ );
+
+ const questionComment: QuestionComment = {
+ content: data.content,
+ createdAt: data.createdAt,
+ id: data.id,
+ numVotes: votes,
+ user: data.user?.name ?? '',
+ };
+ return questionComment;
+ });
+ },
+ })
+ .mutation('create', {
+ input: z.object({
+ content: z.string(),
+ questionId: z.string(),
+ }),
+ async resolve({ ctx, input }) {
+ const userId = ctx.session?.user?.id;
+
+ return await ctx.prisma.questionsQuestionComment.create({
+ data: {
+ ...input,
+ userId,
+ },
+ });
+ },
+ })
+ .mutation('update', {
+ input: z.object({
+ content: z.string().optional(),
+ id: z.string(),
+ }),
+ async resolve({ ctx, input }) {
+ const userId = ctx.session?.user?.id;
+
+ const questionCommentToUpdate =
+ await ctx.prisma.questionsQuestionComment.findUnique({
+ where: {
+ id: input.id,
+ },
+ });
+
+ if (questionCommentToUpdate?.id !== userId) {
+ throw new TRPCError({
+ code: 'UNAUTHORIZED',
+ message: 'User have no authorization to record.',
+ });
+ }
+
+ return await ctx.prisma.questionsQuestionComment.update({
+ data: {
+ ...input,
+ },
+ where: {
+ id: input.id,
+ },
+ });
+ },
+ })
+ .mutation('delete', {
+ input: z.object({
+ id: z.string(),
+ }),
+ async resolve({ ctx, input }) {
+ const userId = ctx.session?.user?.id;
+
+ const questionCommentToDelete =
+ await ctx.prisma.questionsQuestionComment.findUnique({
+ where: {
+ id: input.id,
+ },
+ });
+
+ if (questionCommentToDelete?.id !== userId) {
+ throw new TRPCError({
+ code: 'UNAUTHORIZED',
+ message: 'User have no authorization to record.',
+ });
+ }
+
+ return await ctx.prisma.questionsQuestionComment.delete({
+ where: {
+ id: input.id,
+ },
+ });
+ },
+ })
+ .query('getVote', {
+ input: z.object({
+ questionCommentId: z.string(),
+ }),
+ async resolve({ ctx, input }) {
+ const userId = ctx.session?.user?.id;
+ const { questionCommentId } = input;
+
+ return await ctx.prisma.questionsQuestionCommentVote.findUnique({
+ where: {
+ questionCommentId_userId: { questionCommentId, userId },
+ },
+ });
+ },
+ })
+ .mutation('createVote', {
+ input: z.object({
+ questionCommentId: z.string(),
+ vote: z.nativeEnum(Vote),
+ }),
+ async resolve({ ctx, input }) {
+ const userId = ctx.session?.user?.id;
+
+ return await ctx.prisma.questionsQuestionCommentVote.create({
+ data: {
+ ...input,
+ userId,
+ },
+ });
+ },
+ })
+ .mutation('updateVote', {
+ input: z.object({
+ id: z.string(),
+ vote: z.nativeEnum(Vote),
+ }),
+ async resolve({ ctx, input }) {
+ const userId = ctx.session?.user?.id;
+ const { id, vote } = input;
+
+ const voteToUpdate =
+ await ctx.prisma.questionsQuestionCommentVote.findUnique({
+ where: {
+ id: input.id,
+ },
+ });
+
+ if (voteToUpdate?.id !== userId) {
+ throw new TRPCError({
+ code: 'UNAUTHORIZED',
+ message: 'User have no authorization to record.',
+ });
+ }
+
+ return await ctx.prisma.questionsQuestionCommentVote.update({
+ data: {
+ vote,
+ },
+ where: {
+ id,
+ },
+ });
+ },
+ })
+ .mutation('deleteVote', {
+ input: z.object({
+ id: z.string(),
+ }),
+ async resolve({ ctx, input }) {
+ const userId = ctx.session?.user?.id;
+
+ const voteToDelete =
+ await ctx.prisma.questionsQuestionCommentVote.findUnique({
+ where: {
+ id: input.id,
+ },
+ });
+
+ if (voteToDelete?.id !== userId) {
+ throw new TRPCError({
+ code: 'UNAUTHORIZED',
+ message: 'User have no authorization to record.',
+ });
+ }
+
+ return await ctx.prisma.questionsQuestionCommentVote.delete({
+ where: {
+ id: input.id,
+ },
+ });
+ },
+ });
diff --git a/apps/portal/src/server/router/questions-question-router.ts b/apps/portal/src/server/router/questions-question-router.ts
index 8918f036..39dafd98 100644
--- a/apps/portal/src/server/router/questions-question-router.ts
+++ b/apps/portal/src/server/router/questions-question-router.ts
@@ -1,5 +1,5 @@
import { z } from 'zod';
-import {QuestionsQuestionType, Vote } from '@prisma/client';
+import { QuestionsQuestionType, Vote } from '@prisma/client';
import { TRPCError } from '@trpc/server';
import { createProtectedRouter } from './context';
@@ -9,106 +9,223 @@ import type { Question } from '~/types/questions';
export const questionsQuestionRouter = createProtectedRouter()
.query('getQuestionsByFilter', {
input: z.object({
- company: z.string().optional(),
- location: z.string().optional(),
- questionType: z.nativeEnum(QuestionsQuestionType),
- role: z.string().optional(),
+ companies: z.string().array(),
+ endDate: z.date(),
+ locations: z.string().array(),
+ questionTypes: z.nativeEnum(QuestionsQuestionType).array(),
+ roles: z.string().array(),
+ startDate: z.date().optional(),
}),
async resolve({ ctx, input }) {
const questionsData = await ctx.prisma.questionsQuestion.findMany({
include: {
- _count: {
- select: {
- answers: true,
- comments: true,
+ _count: {
+ select: {
+ answers: true,
+ comments: true,
+ },
},
- },
- encounters: {
- select: {
- company: true,
- location: true,
- role: true,
+ encounters: {
+ select: {
+ company: true,
+ location: true,
+ role: true,
+ seenAt: true,
+ },
},
- },
- user: {
- select: {
- name: true,
+ user: {
+ select: {
+ name: true,
+ },
},
- },
- votes: true,
+ votes: true,
},
orderBy: {
- createdAt: 'desc',
+ createdAt: 'desc',
},
where: {
- questionType: input.questionType,
+ ...(input.questionTypes.length > 0
+ ? {
+ questionType: {
+ in: input.questionTypes,
+ },
+ }
+ : {}),
},
});
return questionsData
.filter((data) => {
for (let i = 0; i < data.encounters.length; i++) {
- const encounter = data.encounters[i]
- const matchCompany = (!input.company || (encounter.company === input.company));
- const matchLocation = (!input.location || (encounter.location === input.location));
- const matchRole = (!input.company || (encounter.role === input.role));
- if (matchCompany && matchLocation && matchRole) {return true};
+ const encounter = data.encounters[i];
+ const matchCompany =
+ input.companies.length === 0 ||
+ input.companies.includes(encounter.company);
+ const matchLocation =
+ input.locations.length === 0 ||
+ input.locations.includes(encounter.location);
+ const matchRole =
+ input.roles.length === 0 || input.roles.includes(encounter.role);
+ const matchDate =
+ (!input.startDate || encounter.seenAt >= input.startDate) &&
+ encounter.seenAt <= input.endDate;
+ if (matchCompany && matchLocation && matchRole && matchDate) {
+ return true;
+ }
}
return false;
})
.map((data) => {
- const votes:number = data.votes.reduce(
- (previousValue:number, currentValue) => {
- let result:number = previousValue;
+ const votes: number = data.votes.reduce(
+ (previousValue: number, currentValue) => {
+ let result: number = previousValue;
- switch(currentValue.vote) {
- case Vote.UPVOTE:
- result += 1
- break;
- case Vote.DOWNVOTE:
- result -= 1
- break;
+ switch (currentValue.vote) {
+ case Vote.UPVOTE:
+ result += 1;
+ break;
+ case Vote.DOWNVOTE:
+ result -= 1;
+ break;
}
return result;
},
- 0
- );
-
- let userName = "";
-
- if (data.user) {
- userName = data.user.name!;
- }
+ 0,
+ );
const question: Question = {
- company: "",
+ company: data.encounters[0].company,
content: data.content,
id: data.id,
- location: "",
+ location: data.encounters[0].location ?? 'Unknown location',
numAnswers: data._count.answers,
numComments: data._count.comments,
numVotes: votes,
- role: "",
+ role: data.encounters[0].role ?? 'Unknown role',
+ seenAt: data.encounters[0].seenAt,
+ type: data.questionType,
updatedAt: data.updatedAt,
- user: userName,
+ user: data.user?.name ?? '',
};
return question;
+ });
+ },
+ })
+ .query('getQuestionById', {
+ input: z.object({
+ id: z.string(),
+ }),
+ async resolve({ ctx, input }) {
+ const questionData = await ctx.prisma.questionsQuestion.findUnique({
+ include: {
+ _count: {
+ select: {
+ answers: true,
+ comments: true,
+ },
+ },
+ encounters: {
+ select: {
+ company: true,
+ location: true,
+ role: true,
+ seenAt: true,
+ },
+ },
+ user: {
+ select: {
+ name: true,
+ },
+ },
+ votes: true,
+ },
+ where: {
+ id: input.id,
+ },
});
- }
+ if (!questionData) {
+ throw new TRPCError({
+ code: 'NOT_FOUND',
+ message: 'Question not found',
+ });
+ }
+ const votes: number = questionData.votes.reduce(
+ (previousValue: number, currentValue) => {
+ let result: number = previousValue;
+
+ switch (currentValue.vote) {
+ case Vote.UPVOTE:
+ result += 1;
+ break;
+ case Vote.DOWNVOTE:
+ result -= 1;
+ break;
+ }
+ return result;
+ },
+ 0,
+ );
+
+ const question: Question = {
+ company: questionData.encounters[0].company,
+ content: questionData.content,
+ id: questionData.id,
+ location: questionData.encounters[0].location ?? 'Unknown location',
+ numAnswers: questionData._count.answers,
+ numComments: questionData._count.comments,
+ numVotes: votes,
+ role: questionData.encounters[0].role ?? 'Unknown role',
+ seenAt: questionData.encounters[0].seenAt,
+ type: questionData.questionType,
+ updatedAt: questionData.updatedAt,
+ user: questionData.user?.name ?? '',
+ };
+ return question;
+ },
})
.mutation('create', {
input: z.object({
+ company: z.string(),
content: z.string(),
+ location: z.string(),
questionType: z.nativeEnum(QuestionsQuestionType),
+ role: z.string(),
+ seenAt: z.date(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
- return await ctx.prisma.questionsQuestion.create({
+ const question = await ctx.prisma.questionsQuestion.create({
data: {
- ...input,
+ content: input.content,
+ encounters: {
+ create: [
+ {
+ company: input.company,
+ location: input.location,
+ role: input.role,
+ seenAt: input.seenAt,
+ userId,
+ },
+ ],
+ },
+ questionType: input.questionType,
+ userId,
+ },
+ });
+
+ // Create question encounter
+ await ctx.prisma.questionsQuestionEncounter.create({
+ data: {
+ company: input.company,
+ location: input.location,
+ questionId: question.id,
+ role: input.role,
+ seenAt: input.seenAt,
userId,
},
});
+
+ return question;
},
})
.mutation('update', {
@@ -116,7 +233,6 @@ export const questionsQuestionRouter = createProtectedRouter()
content: z.string().optional(),
id: z.string(),
questionType: z.nativeEnum(QuestionsQuestionType).optional(),
-
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
@@ -179,11 +295,11 @@ export const questionsQuestionRouter = createProtectedRouter()
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
- const {questionId} = input
+ const { questionId } = input;
return await ctx.prisma.questionsQuestionVote.findUnique({
where: {
- questionId_userId : {questionId,userId }
+ questionId_userId: { questionId, userId },
},
});
},
@@ -211,7 +327,7 @@ export const questionsQuestionRouter = createProtectedRouter()
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
- const {id, vote} = input
+ const { id, vote } = input;
const voteToUpdate = await ctx.prisma.questionsQuestionVote.findUnique({
where: {
@@ -246,7 +362,8 @@ export const questionsQuestionRouter = createProtectedRouter()
const voteToDelete = await ctx.prisma.questionsQuestionVote.findUnique({
where: {
id: input.id,
- },});
+ },
+ });
if (voteToDelete?.id !== userId) {
throw new TRPCError({
@@ -261,4 +378,4 @@ export const questionsQuestionRouter = createProtectedRouter()
},
});
},
- });
\ No newline at end of file
+ });
diff --git a/apps/portal/src/types/questions.d.ts b/apps/portal/src/types/questions.d.ts
index 2bcdfee7..43ed837e 100644
--- a/apps/portal/src/types/questions.d.ts
+++ b/apps/portal/src/types/questions.d.ts
@@ -8,6 +8,8 @@ export type Question = {
numComments: number;
numVotes: number;
role: string;
+ seenAt: Date;
+ type: stringl;
updatedAt: Date;
user: string;
};
@@ -16,4 +18,30 @@ export type AggregatedQuestionEncounter = {
companyCount: Record;
locationCount: Record;
roleCount:Record;
-}
\ No newline at end of file
+}
+
+export type AnswerComment = {
+ content: string;
+ createdAt: Date;
+ id: string;
+ numVotes: number;
+ updatedAt: Date;
+ user: string;
+};
+
+export type Answer = {
+ content: string;
+ createdAt: Date;
+ id: string;
+ numComments: number;
+ numVotes: number;
+ user: string;
+};
+
+export type QuestionComment = {
+ content: string;
+ createdAt: Date;
+ id: string;
+ numVotes: number;
+ user: string;
+};
diff --git a/apps/portal/src/utils/questions/constants.ts b/apps/portal/src/utils/questions/constants.ts
new file mode 100644
index 00000000..357b6f55
--- /dev/null
+++ b/apps/portal/src/utils/questions/constants.ts
@@ -0,0 +1,112 @@
+import type { QuestionsQuestionType } from '@prisma/client';
+
+import type { FilterChoices } from '~/components/questions/filter/FilterSection';
+
+export const COMPANIES: FilterChoices = [
+ {
+ label: 'Google',
+ value: 'Google',
+ },
+ {
+ label: 'Meta',
+ value: 'Meta',
+ },
+] as const;
+
+// Code, design, behavioral
+export const QUESTION_TYPES: FilterChoices = [
+ {
+ label: 'Coding',
+ value: 'CODING',
+ },
+ {
+ label: 'Design',
+ value: 'SYSTEM_DESIGN',
+ },
+ {
+ label: 'Behavioral',
+ value: 'BEHAVIORAL',
+ },
+] as const;
+
+export type QuestionAge = 'all' | 'last-6-months' | 'last-month' | 'last-year';
+
+export const QUESTION_AGES: FilterChoices = [
+ {
+ label: 'Last month',
+ value: 'last-month',
+ },
+ {
+ label: 'Last 6 months',
+ value: 'last-6-months',
+ },
+ {
+ label: 'Last year',
+ value: 'last-year',
+ },
+ {
+ label: 'All',
+ value: 'all',
+ },
+] as const;
+
+export const LOCATIONS: FilterChoices = [
+ {
+ label: 'Singapore',
+ value: 'Singapore',
+ },
+ {
+ label: 'Menlo Park',
+ value: 'Menlo Park',
+ },
+ {
+ label: 'California',
+ value: 'california',
+ },
+ {
+ label: 'Hong Kong',
+ value: 'Hong Kong',
+ },
+ {
+ label: 'Taiwan',
+ value: 'Taiwan',
+ },
+] as const;
+
+export const SAMPLE_QUESTION = {
+ answerCount: 10,
+ commentCount: 10,
+ company: 'Google',
+ content:
+ 'Given an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums andiven an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums andiven an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums andiven an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums andiven an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums andiven an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums andiven an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums andiven an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums and',
+ location: 'Menlo Park, CA',
+ receivedCount: 12,
+ role: 'Software Engineer',
+ timestamp: 'Last month',
+ upvoteCount: 5,
+};
+
+export const SAMPLE_ANSWER = {
+ authorImageUrl: 'https://avatars.githubusercontent.com/u/66356390?v=4',
+ authorName: 'Jeff Sieu',
+ commentCount: 10,
+ content: 'This is a sample answer',
+ createdAt: new Date(2014, 8, 1, 11, 30, 40),
+ upvoteCount: 10,
+};
+
+export const SAMPLE_QUESTION_COMMENT = {
+ authorImageUrl: 'https://avatars.githubusercontent.com/u/66356390?v=4',
+ authorName: 'Jeff Sieu',
+ content: 'This is a sample question comment',
+ createdAt: new Date(2014, 8, 1, 11, 30, 40),
+ upvoteCount: 10,
+};
+
+export const SAMPLE_ANSWER_COMMENT = {
+ authorImageUrl: 'https://avatars.githubusercontent.com/u/66356390?v=4',
+ authorName: 'Jeff Sieu',
+ content: 'This is an sample answer comment',
+ createdAt: new Date(2014, 8, 1, 11, 30, 40),
+ upvoteCount: 10,
+};
diff --git a/apps/portal/src/utils/questions/createSlug.ts b/apps/portal/src/utils/questions/createSlug.ts
new file mode 100644
index 00000000..9c8a81ae
--- /dev/null
+++ b/apps/portal/src/utils/questions/createSlug.ts
@@ -0,0 +1,7 @@
+export default function createSlug(content: string) {
+ return content
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/(^-|-$)+/g, '')
+ .substring(0, 100);
+}
diff --git a/apps/portal/src/utils/questions/useFormRegister.ts b/apps/portal/src/utils/questions/useFormRegister.ts
new file mode 100644
index 00000000..42ab2fc5
--- /dev/null
+++ b/apps/portal/src/utils/questions/useFormRegister.ts
@@ -0,0 +1,43 @@
+import type { ChangeEvent } from 'react';
+import { useCallback } from 'react';
+import type { FieldValues, UseFormRegister } from 'react-hook-form';
+
+export const useFormRegister = (
+ register: UseFormRegister,
+) => {
+ const formRegister = useCallback(
+ (...args: Parameters) => {
+ const { onChange, ...rest } = register(...args);
+ return {
+ ...rest,
+ onChange: (value: string, event: ChangeEvent) => {
+ onChange(event);
+ },
+ };
+ },
+ [register],
+ );
+ return formRegister;
+};
+
+export const useSelectRegister = (
+ register: UseFormRegister,
+) => {
+ const formRegister = useCallback(
+ (...args: Parameters) => {
+ const { onChange, ...rest } = register(...args);
+ return {
+ ...rest,
+ onChange: (value: string) => {
+ onChange({
+ target: {
+ value,
+ },
+ });
+ },
+ };
+ },
+ [register],
+ );
+ return formRegister;
+};
diff --git a/apps/portal/src/utils/questions/useSearchFilter.ts b/apps/portal/src/utils/questions/useSearchFilter.ts
new file mode 100644
index 00000000..1a7bd199
--- /dev/null
+++ b/apps/portal/src/utils/questions/useSearchFilter.ts
@@ -0,0 +1,81 @@
+import { useRouter } from 'next/router';
+import { useCallback, useEffect, useState } from 'react';
+
+export const useSearchFilter = (
+ name: string,
+ opts: {
+ defaultValues?: Array;
+ queryParamToValue?: (param: string) => Value;
+ } = {},
+) => {
+ const { defaultValues, queryParamToValue = (param) => param } = opts;
+ const [isInitialized, setIsInitialized] = useState(false);
+ const router = useRouter();
+
+ const [filters, setFilters] = useState>(defaultValues || []);
+
+ useEffect(() => {
+ if (router.isReady && !isInitialized) {
+ // Initialize from query params
+ const query = router.query[name];
+ if (query) {
+ const queryValues = Array.isArray(query) ? query : [query];
+ setFilters(queryValues.map(queryParamToValue) as Array);
+ } else {
+ // Try to load from local storage
+ const localStorageValue = localStorage.getItem(name);
+ if (localStorageValue !== null) {
+ const loadedFilters = JSON.parse(localStorageValue);
+ setFilters(loadedFilters);
+ router.replace({
+ pathname: router.pathname,
+ query: {
+ ...router.query,
+ [name]: loadedFilters,
+ },
+ });
+ }
+ }
+ setIsInitialized(true);
+ }
+ }, [isInitialized, name, queryParamToValue, router]);
+
+ const setFiltersCallback = useCallback(
+ (newFilters: Array) => {
+ setFilters(newFilters);
+ localStorage.setItem(name, JSON.stringify(newFilters));
+ router.replace({
+ pathname: router.pathname,
+ query: {
+ ...router.query,
+ [name]: newFilters,
+ },
+ });
+ },
+ [name, router],
+ );
+
+ return [filters, setFiltersCallback, isInitialized] as const;
+};
+
+export const useSearchFilterSingle = (
+ name: string,
+ opts: {
+ defaultValue?: Value;
+ queryParamToValue?: (param: string) => Value;
+ } = {},
+) => {
+ const { defaultValue, queryParamToValue } = opts;
+ const [filters, setFilters, isInitialized] = useSearchFilter(name, {
+ defaultValues: defaultValue !== undefined ? [defaultValue] : undefined,
+ queryParamToValue,
+ });
+
+ return [
+ filters[0],
+ (value: Value) => {
+ setFilters([value]);
+ },
+ isInitialized,
+ ] as const;
+};
diff --git a/apps/portal/src/utils/questions/withHref.tsx b/apps/portal/src/utils/questions/withHref.tsx
new file mode 100644
index 00000000..681f7122
--- /dev/null
+++ b/apps/portal/src/utils/questions/withHref.tsx
@@ -0,0 +1,21 @@
+const withHref = >(
+ Component: React.ComponentType,
+) => {
+ return (
+ props: Props & {
+ href: string;
+ },
+ ) => {
+ const { href, ...others } = props;
+
+ return (
+
+
+
+ );
+ };
+};
+
+export default withHref;
diff --git a/apps/storybook/stories/button.stories.tsx b/apps/storybook/stories/button.stories.tsx
index e7b8af01..f48d3fcb 100644
--- a/apps/storybook/stories/button.stories.tsx
+++ b/apps/storybook/stories/button.stories.tsx
@@ -23,6 +23,9 @@ const buttonVariants: ReadonlyArray = [
'tertiary',
'special',
'success',
+ 'danger',
+ 'warning',
+ 'info',
];
export default {
diff --git a/packages/ui/src/Button/Button.tsx b/packages/ui/src/Button/Button.tsx
index cec2dcec..9988c1aa 100644
--- a/packages/ui/src/Button/Button.tsx
+++ b/packages/ui/src/Button/Button.tsx
@@ -9,11 +9,14 @@ export type ButtonDisplay = 'block' | 'inline';
export type ButtonSize = 'lg' | 'md' | 'sm';
export type ButtonType = 'button' | 'reset' | 'submit';
export type ButtonVariant =
+ | 'danger'
+ | 'info'
| 'primary'
| 'secondary'
| 'special'
| 'success'
- | 'tertiary';
+ | 'tertiary'
+ | 'warning';
type Props = Readonly<{
addonPosition?: ButtonAddOnPosition;
@@ -69,20 +72,32 @@ const sizeIconClasses: Record = {
};
const variantClasses: Record = {
- primary: 'border-transparent text-white bg-primary-600 hover:bg-primary-500',
+ danger:
+ 'border-transparent text-white bg-danger-600 hover:bg-danger-500 focus:ring-danger-500',
+ info: 'border-transparent text-white bg-info-600 hover:bg-info-500 focus:ring-info-500',
+ primary:
+ 'border-transparent text-white bg-primary-600 hover:bg-primary-500 focus:ring-primary-500',
secondary:
- 'border-transparent text-primary-700 bg-primary-100 hover:bg-primary-200',
- special: 'border-slate-900 text-white bg-slate-900 hover:bg-slate-700',
- success: 'border-transparent text-white bg-success-600 hover:bg-success-500',
- tertiary: 'border-slate-300 text-slate-700 bg-white hover:bg-slate-50',
+ 'border-transparent text-primary-700 bg-primary-100 hover:bg-primary-200 focus:ring-primary-500',
+ special:
+ 'border-slate-900 text-white bg-slate-900 hover:bg-slate-700 focus:ring-slate-900',
+ success:
+ 'border-transparent text-white bg-success-600 hover:bg-success-500 focus:ring-success-500',
+ tertiary:
+ 'border-slate-300 text-slate-700 bg-white hover:bg-slate-50 focus:ring-slate-600',
+ warning:
+ 'border-transparent text-white bg-warning-600 hover:bg-warning-500 focus:ring-warning-500',
};
const variantDisabledClasses: Record = {
+ danger: 'border-transparent text-slate-500 bg-slate-300',
+ info: 'border-transparent text-slate-500 bg-slate-300',
primary: 'border-transparent text-slate-500 bg-slate-300',
secondary: 'border-transparent text-slate-400 bg-slate-200',
special: 'border-transparent text-slate-500 bg-slate-300',
success: 'border-transparent text-slate-500 bg-slate-300',
tertiary: 'border-slate-300 text-slate-400 bg-slate-100',
+ warning: 'border-transparent text-slate-500 bg-slate-300',
};
export default function Button({
@@ -132,7 +147,7 @@ export default function Button({
children,
className: clsx(
display === 'block' ? 'flex w-full justify-center' : 'inline-flex',
- 'whitespace-nowrap items-center border font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500',
+ 'whitespace-nowrap items-center border font-medium focus:outline-none focus:ring-2 focus:ring-offset-2',
disabled ? variantDisabledClasses[variant] : variantClasses[variant],
disabled && 'pointer-events-none',
isLabelHidden ? iconOnlySizeClasses[size] : sizeClasses[size],
diff --git a/turbo.json b/turbo.json
index 0f5e546e..a116a1b7 100644
--- a/turbo.json
+++ b/turbo.json
@@ -7,7 +7,8 @@
".next/**",
"build/**",
"api/**",
- "public/build/**"
+ "public/build/**",
+ "storybook-static/**"
],
"dependsOn": ["^build"]
},