Merge branch 'main' into hongpo/encounter-sort

pull/458/head
hpkoh 3 years ago
commit e483419773

@ -5,4 +5,7 @@ module.exports = {
tsconfigRootDir: __dirname,
project: ['./tsconfig.json'],
},
rules: {
'@typescript-eslint/ban-ts-comment': 0,
},
};

@ -9,7 +9,8 @@
"lint": "next lint",
"tsc": "tsc",
"postinstall": "prisma generate",
"seed": "ts-node prisma/seed.ts"
"seed": "ts-node prisma/seed.ts",
"seed-questions": "ts-node prisma/seed-questions.ts"
},
"dependencies": {
"@headlessui/react": "^1.7.3",

File diff suppressed because it is too large Load Diff

@ -0,0 +1,410 @@
{
"data": [
{ "country_id": "1", "sortname": "AF", "country_name": "Afghanistan" },
{ "country_id": "2", "sortname": "AL", "country_name": "Albania" },
{ "country_id": "3", "sortname": "DZ", "country_name": "Algeria" },
{ "country_id": "4", "sortname": "AS", "country_name": "American Samoa" },
{ "country_id": "5", "sortname": "AD", "country_name": "Andorra" },
{ "country_id": "6", "sortname": "AO", "country_name": "Angola" },
{ "country_id": "7", "sortname": "AI", "country_name": "Anguilla" },
{ "country_id": "8", "sortname": "AQ", "country_name": "Antarctica" },
{
"country_id": "9",
"sortname": "AG",
"country_name": "Antigua And Barbuda"
},
{ "country_id": "10", "sortname": "AR", "country_name": "Argentina" },
{ "country_id": "11", "sortname": "AM", "country_name": "Armenia" },
{ "country_id": "12", "sortname": "AW", "country_name": "Aruba" },
{ "country_id": "13", "sortname": "AU", "country_name": "Australia" },
{ "country_id": "14", "sortname": "AT", "country_name": "Austria" },
{ "country_id": "15", "sortname": "AZ", "country_name": "Azerbaijan" },
{ "country_id": "16", "sortname": "BS", "country_name": "The Bahamas" },
{ "country_id": "17", "sortname": "BH", "country_name": "Bahrain" },
{ "country_id": "18", "sortname": "BD", "country_name": "Bangladesh" },
{ "country_id": "19", "sortname": "BB", "country_name": "Barbados" },
{ "country_id": "20", "sortname": "BY", "country_name": "Belarus" },
{ "country_id": "21", "sortname": "BE", "country_name": "Belgium" },
{ "country_id": "22", "sortname": "BZ", "country_name": "Belize" },
{ "country_id": "23", "sortname": "BJ", "country_name": "Benin" },
{ "country_id": "24", "sortname": "BM", "country_name": "Bermuda" },
{ "country_id": "25", "sortname": "BT", "country_name": "Bhutan" },
{ "country_id": "26", "sortname": "BO", "country_name": "Bolivia" },
{
"country_id": "27",
"sortname": "BA",
"country_name": "Bosnia and Herzegovina"
},
{ "country_id": "28", "sortname": "BW", "country_name": "Botswana" },
{ "country_id": "29", "sortname": "BV", "country_name": "Bouvet Island" },
{ "country_id": "30", "sortname": "BR", "country_name": "Brazil" },
{
"country_id": "31",
"sortname": "IO",
"country_name": "British Indian Ocean Territory"
},
{ "country_id": "32", "sortname": "BN", "country_name": "Brunei" },
{ "country_id": "33", "sortname": "BG", "country_name": "Bulgaria" },
{ "country_id": "34", "sortname": "BF", "country_name": "Burkina Faso" },
{ "country_id": "35", "sortname": "BI", "country_name": "Burundi" },
{ "country_id": "36", "sortname": "KH", "country_name": "Cambodia" },
{ "country_id": "37", "sortname": "CM", "country_name": "Cameroon" },
{ "country_id": "38", "sortname": "CA", "country_name": "Canada" },
{ "country_id": "39", "sortname": "CV", "country_name": "Cape Verde" },
{ "country_id": "40", "sortname": "KY", "country_name": "Cayman Islands" },
{
"country_id": "41",
"sortname": "CF",
"country_name": "Central African Republic"
},
{ "country_id": "42", "sortname": "TD", "country_name": "Chad" },
{ "country_id": "43", "sortname": "CL", "country_name": "Chile" },
{ "country_id": "44", "sortname": "CN", "country_name": "China" },
{
"country_id": "45",
"sortname": "CX",
"country_name": "Christmas Island"
},
{
"country_id": "46",
"sortname": "CC",
"country_name": "Cocos (Keeling) Islands"
},
{ "country_id": "47", "sortname": "CO", "country_name": "Colombia" },
{ "country_id": "48", "sortname": "KM", "country_name": "Comoros" },
{ "country_id": "49", "sortname": "CG", "country_name": "Congo" },
{
"country_id": "50",
"sortname": "CD",
"country_name": "Democratic Republic of The Congo"
},
{ "country_id": "51", "sortname": "CK", "country_name": "Cook Islands" },
{ "country_id": "52", "sortname": "CR", "country_name": "Costa Rica" },
{
"country_id": "53",
"sortname": "CI",
"country_name": "Cote D'Ivoire (Ivory Coast)"
},
{
"country_id": "54",
"sortname": "HR",
"country_name": "Croatia (Hrvatska)"
},
{ "country_id": "55", "sortname": "CU", "country_name": "Cuba" },
{ "country_id": "56", "sortname": "CY", "country_name": "Cyprus" },
{ "country_id": "57", "sortname": "CZ", "country_name": "Czech Republic" },
{ "country_id": "58", "sortname": "DK", "country_name": "Denmark" },
{ "country_id": "59", "sortname": "DJ", "country_name": "Djibouti" },
{ "country_id": "60", "sortname": "DM", "country_name": "Dominica" },
{
"country_id": "61",
"sortname": "DO",
"country_name": "Dominican Republic"
},
{ "country_id": "62", "sortname": "TP", "country_name": "East Timor" },
{ "country_id": "63", "sortname": "EC", "country_name": "Ecuador" },
{ "country_id": "64", "sortname": "EG", "country_name": "Egypt" },
{ "country_id": "65", "sortname": "SV", "country_name": "El Salvador" },
{
"country_id": "66",
"sortname": "GQ",
"country_name": "Equatorial Guinea"
},
{ "country_id": "67", "sortname": "ER", "country_name": "Eritrea" },
{ "country_id": "68", "sortname": "EE", "country_name": "Estonia" },
{ "country_id": "69", "sortname": "ET", "country_name": "Ethiopia" },
{
"country_id": "70",
"sortname": "XA",
"country_name": "External Territories of Australia"
},
{
"country_id": "71",
"sortname": "FK",
"country_name": "Falkland Islands"
},
{ "country_id": "72", "sortname": "FO", "country_name": "Faroe Islands" },
{ "country_id": "73", "sortname": "FJ", "country_name": "Fiji Islands" },
{ "country_id": "74", "sortname": "FI", "country_name": "Finland" },
{ "country_id": "75", "sortname": "FR", "country_name": "France" },
{ "country_id": "76", "sortname": "GF", "country_name": "French Guiana" },
{
"country_id": "77",
"sortname": "PF",
"country_name": "French Polynesia"
},
{
"country_id": "78",
"sortname": "TF",
"country_name": "French Southern Territories"
},
{ "country_id": "79", "sortname": "GA", "country_name": "Gabon" },
{ "country_id": "80", "sortname": "GM", "country_name": "The Gambia" },
{ "country_id": "81", "sortname": "GE", "country_name": "Georgia" },
{ "country_id": "82", "sortname": "DE", "country_name": "Germany" },
{ "country_id": "83", "sortname": "GH", "country_name": "Ghana" },
{ "country_id": "84", "sortname": "GI", "country_name": "Gibraltar" },
{ "country_id": "85", "sortname": "GR", "country_name": "Greece" },
{ "country_id": "86", "sortname": "GL", "country_name": "Greenland" },
{ "country_id": "87", "sortname": "GD", "country_name": "Grenada" },
{ "country_id": "88", "sortname": "GP", "country_name": "Guadeloupe" },
{ "country_id": "89", "sortname": "GU", "country_name": "Guam" },
{ "country_id": "90", "sortname": "GT", "country_name": "Guatemala" },
{
"country_id": "91",
"sortname": "XU",
"country_name": "Guernsey and Alderney"
},
{ "country_id": "92", "sortname": "GN", "country_name": "Guinea" },
{ "country_id": "93", "sortname": "GW", "country_name": "Guinea-Bissau" },
{ "country_id": "94", "sortname": "GY", "country_name": "Guyana" },
{ "country_id": "95", "sortname": "HT", "country_name": "Haiti" },
{
"country_id": "96",
"sortname": "HM",
"country_name": "Heard and McDonald Islands"
},
{ "country_id": "97", "sortname": "HN", "country_name": "Honduras" },
{
"country_id": "98",
"sortname": "HK",
"country_name": "Hong Kong"
},
{ "country_id": "99", "sortname": "HU", "country_name": "Hungary" },
{ "country_id": "100", "sortname": "IS", "country_name": "Iceland" },
{ "country_id": "101", "sortname": "IN", "country_name": "India" },
{ "country_id": "102", "sortname": "ID", "country_name": "Indonesia" },
{ "country_id": "103", "sortname": "IR", "country_name": "Iran" },
{ "country_id": "104", "sortname": "IQ", "country_name": "Iraq" },
{ "country_id": "105", "sortname": "IE", "country_name": "Ireland" },
{ "country_id": "106", "sortname": "IL", "country_name": "Israel" },
{ "country_id": "107", "sortname": "IT", "country_name": "Italy" },
{ "country_id": "108", "sortname": "JM", "country_name": "Jamaica" },
{ "country_id": "109", "sortname": "JP", "country_name": "Japan" },
{ "country_id": "110", "sortname": "XJ", "country_name": "Jersey" },
{ "country_id": "111", "sortname": "JO", "country_name": "Jordan" },
{ "country_id": "112", "sortname": "KZ", "country_name": "Kazakhstan" },
{ "country_id": "113", "sortname": "KE", "country_name": "Kenya" },
{ "country_id": "114", "sortname": "KI", "country_name": "Kiribati" },
{ "country_id": "115", "sortname": "KP", "country_name": "North Korea" },
{ "country_id": "116", "sortname": "KR", "country_name": "South Korea" },
{ "country_id": "117", "sortname": "KW", "country_name": "Kuwait" },
{ "country_id": "118", "sortname": "KG", "country_name": "Kyrgyzstan" },
{ "country_id": "119", "sortname": "LA", "country_name": "Laos" },
{ "country_id": "120", "sortname": "LV", "country_name": "Latvia" },
{ "country_id": "121", "sortname": "LB", "country_name": "Lebanon" },
{ "country_id": "122", "sortname": "LS", "country_name": "Lesotho" },
{ "country_id": "123", "sortname": "LR", "country_name": "Liberia" },
{ "country_id": "124", "sortname": "LY", "country_name": "Libya" },
{ "country_id": "125", "sortname": "LI", "country_name": "Liechtenstein" },
{ "country_id": "126", "sortname": "LT", "country_name": "Lithuania" },
{ "country_id": "127", "sortname": "LU", "country_name": "Luxembourg" },
{ "country_id": "128", "sortname": "MO", "country_name": "Macau" },
{ "country_id": "129", "sortname": "MK", "country_name": "Macedonia" },
{ "country_id": "130", "sortname": "MG", "country_name": "Madagascar" },
{ "country_id": "131", "sortname": "MW", "country_name": "Malawi" },
{ "country_id": "132", "sortname": "MY", "country_name": "Malaysia" },
{ "country_id": "133", "sortname": "MV", "country_name": "Maldives" },
{ "country_id": "134", "sortname": "ML", "country_name": "Mali" },
{ "country_id": "135", "sortname": "MT", "country_name": "Malta" },
{ "country_id": "136", "sortname": "XM", "country_name": "Isle of Man" },
{
"country_id": "137",
"sortname": "MH",
"country_name": "Marshall Islands"
},
{ "country_id": "138", "sortname": "MQ", "country_name": "Martinique" },
{ "country_id": "139", "sortname": "MR", "country_name": "Mauritania" },
{ "country_id": "140", "sortname": "MU", "country_name": "Mauritius" },
{ "country_id": "141", "sortname": "YT", "country_name": "Mayotte" },
{ "country_id": "142", "sortname": "MX", "country_name": "Mexico" },
{ "country_id": "143", "sortname": "FM", "country_name": "Micronesia" },
{ "country_id": "144", "sortname": "MD", "country_name": "Moldova" },
{ "country_id": "145", "sortname": "MC", "country_name": "Monaco" },
{ "country_id": "146", "sortname": "MN", "country_name": "Mongolia" },
{ "country_id": "147", "sortname": "MS", "country_name": "Montserrat" },
{ "country_id": "148", "sortname": "MA", "country_name": "Morocco" },
{ "country_id": "149", "sortname": "MZ", "country_name": "Mozambique" },
{ "country_id": "150", "sortname": "MM", "country_name": "Myanmar" },
{ "country_id": "151", "sortname": "NA", "country_name": "Namibia" },
{ "country_id": "152", "sortname": "NR", "country_name": "Nauru" },
{ "country_id": "153", "sortname": "NP", "country_name": "Nepal" },
{
"country_id": "154",
"sortname": "AN",
"country_name": "Netherlands Antilles"
},
{
"country_id": "155",
"sortname": "NL",
"country_name": "Netherlands The"
},
{ "country_id": "156", "sortname": "NC", "country_name": "New Caledonia" },
{ "country_id": "157", "sortname": "NZ", "country_name": "New Zealand" },
{ "country_id": "158", "sortname": "NI", "country_name": "Nicaragua" },
{ "country_id": "159", "sortname": "NE", "country_name": "Niger" },
{ "country_id": "160", "sortname": "NG", "country_name": "Nigeria" },
{ "country_id": "161", "sortname": "NU", "country_name": "Niue" },
{ "country_id": "162", "sortname": "NF", "country_name": "Norfolk Island" },
{
"country_id": "163",
"sortname": "MP",
"country_name": "Northern Mariana Islands"
},
{ "country_id": "164", "sortname": "NO", "country_name": "Norway" },
{ "country_id": "165", "sortname": "OM", "country_name": "Oman" },
{ "country_id": "166", "sortname": "PK", "country_name": "Pakistan" },
{ "country_id": "167", "sortname": "PW", "country_name": "Palau" },
{
"country_id": "168",
"sortname": "PS",
"country_name": "Palestinian Territory Occupied"
},
{ "country_id": "169", "sortname": "PA", "country_name": "Panama" },
{
"country_id": "170",
"sortname": "PG",
"country_name": "Papua new Guinea"
},
{ "country_id": "171", "sortname": "PY", "country_name": "Paraguay" },
{ "country_id": "172", "sortname": "PE", "country_name": "Peru" },
{ "country_id": "173", "sortname": "PH", "country_name": "Philippines" },
{
"country_id": "174",
"sortname": "PN",
"country_name": "Pitcairn Island"
},
{ "country_id": "175", "sortname": "PL", "country_name": "Poland" },
{ "country_id": "176", "sortname": "PT", "country_name": "Portugal" },
{ "country_id": "177", "sortname": "PR", "country_name": "Puerto Rico" },
{ "country_id": "178", "sortname": "QA", "country_name": "Qatar" },
{ "country_id": "179", "sortname": "RE", "country_name": "Reunion" },
{ "country_id": "180", "sortname": "RO", "country_name": "Romania" },
{ "country_id": "181", "sortname": "RU", "country_name": "Russia" },
{ "country_id": "182", "sortname": "RW", "country_name": "Rwanda" },
{ "country_id": "183", "sortname": "SH", "country_name": "Saint Helena" },
{
"country_id": "184",
"sortname": "KN",
"country_name": "Saint Kitts And Nevis"
},
{ "country_id": "185", "sortname": "LC", "country_name": "Saint Lucia" },
{
"country_id": "186",
"sortname": "PM",
"country_name": "Saint Pierre and Miquelon"
},
{
"country_id": "187",
"sortname": "VC",
"country_name": "Saint Vincent and The Grenadines"
},
{ "country_id": "188", "sortname": "WS", "country_name": "Samoa" },
{ "country_id": "189", "sortname": "SM", "country_name": "San Marino" },
{
"country_id": "190",
"sortname": "ST",
"country_name": "Sao Tome and Principe"
},
{ "country_id": "191", "sortname": "SA", "country_name": "Saudi Arabia" },
{ "country_id": "192", "sortname": "SN", "country_name": "Senegal" },
{ "country_id": "193", "sortname": "RS", "country_name": "Serbia" },
{ "country_id": "194", "sortname": "SC", "country_name": "Seychelles" },
{ "country_id": "195", "sortname": "SL", "country_name": "Sierra Leone" },
{ "country_id": "196", "sortname": "SG", "country_name": "Singapore" },
{ "country_id": "197", "sortname": "SK", "country_name": "Slovakia" },
{ "country_id": "198", "sortname": "SI", "country_name": "Slovenia" },
{
"country_id": "199",
"sortname": "XG",
"country_name": "Smaller Territories of the UK"
},
{
"country_id": "200",
"sortname": "SB",
"country_name": "Solomon Islands"
},
{ "country_id": "201", "sortname": "SO", "country_name": "Somalia" },
{ "country_id": "202", "sortname": "ZA", "country_name": "South Africa" },
{ "country_id": "203", "sortname": "GS", "country_name": "South Georgia" },
{ "country_id": "204", "sortname": "SS", "country_name": "South Sudan" },
{ "country_id": "205", "sortname": "ES", "country_name": "Spain" },
{ "country_id": "206", "sortname": "LK", "country_name": "Sri Lanka" },
{ "country_id": "207", "sortname": "SD", "country_name": "Sudan" },
{ "country_id": "208", "sortname": "SR", "country_name": "Suriname" },
{
"country_id": "209",
"sortname": "SJ",
"country_name": "Svalbard And Jan Mayen Islands"
},
{ "country_id": "210", "sortname": "SZ", "country_name": "Swaziland" },
{ "country_id": "211", "sortname": "SE", "country_name": "Sweden" },
{ "country_id": "212", "sortname": "CH", "country_name": "Switzerland" },
{ "country_id": "213", "sortname": "SY", "country_name": "Syria" },
{ "country_id": "214", "sortname": "TW", "country_name": "Taiwan" },
{ "country_id": "215", "sortname": "TJ", "country_name": "Tajikistan" },
{ "country_id": "216", "sortname": "TZ", "country_name": "Tanzania" },
{ "country_id": "217", "sortname": "TH", "country_name": "Thailand" },
{ "country_id": "218", "sortname": "TG", "country_name": "Togo" },
{ "country_id": "219", "sortname": "TK", "country_name": "Tokelau" },
{ "country_id": "220", "sortname": "TO", "country_name": "Tonga" },
{
"country_id": "221",
"sortname": "TT",
"country_name": "Trinidad And Tobago"
},
{ "country_id": "222", "sortname": "TN", "country_name": "Tunisia" },
{ "country_id": "223", "sortname": "TR", "country_name": "Turkey" },
{ "country_id": "224", "sortname": "TM", "country_name": "Turkmenistan" },
{
"country_id": "225",
"sortname": "TC",
"country_name": "Turks And Caicos Islands"
},
{ "country_id": "226", "sortname": "TV", "country_name": "Tuvalu" },
{ "country_id": "227", "sortname": "UG", "country_name": "Uganda" },
{ "country_id": "228", "sortname": "UA", "country_name": "Ukraine" },
{
"country_id": "229",
"sortname": "AE",
"country_name": "United Arab Emirates"
},
{ "country_id": "230", "sortname": "GB", "country_name": "United Kingdom" },
{ "country_id": "231", "sortname": "US", "country_name": "United States" },
{
"country_id": "232",
"sortname": "UM",
"country_name": "United States Minor Outlying Islands"
},
{ "country_id": "233", "sortname": "UY", "country_name": "Uruguay" },
{ "country_id": "234", "sortname": "UZ", "country_name": "Uzbekistan" },
{ "country_id": "235", "sortname": "VU", "country_name": "Vanuatu" },
{
"country_id": "236",
"sortname": "VA",
"country_name": "Vatican City State (Holy See)"
},
{ "country_id": "237", "sortname": "VE", "country_name": "Venezuela" },
{ "country_id": "238", "sortname": "VN", "country_name": "Vietnam" },
{
"country_id": "239",
"sortname": "VG",
"country_name": "Virgin Islands (British)"
},
{
"country_id": "240",
"sortname": "VI",
"country_name": "Virgin Islands (US)"
},
{
"country_id": "241",
"sortname": "WF",
"country_name": "Wallis And Futuna Islands"
},
{ "country_id": "242", "sortname": "EH", "country_name": "Western Sahara" },
{ "country_id": "243", "sortname": "YE", "country_name": "Yemen" },
{ "country_id": "244", "sortname": "YU", "country_name": "Yugoslavia" },
{ "country_id": "245", "sortname": "ZM", "country_name": "Zambia" },
{ "country_id": "246", "sortname": "ZW", "country_name": "Zimbabwe" }
]
}

File diff suppressed because it is too large Load Diff

@ -0,0 +1,44 @@
-- CreateTable
CREATE TABLE "Country" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"code" TEXT NOT NULL,
CONSTRAINT "Country_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "State" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"countryId" TEXT NOT NULL,
CONSTRAINT "State_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "City" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"stateId" TEXT NOT NULL,
CONSTRAINT "City_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Country_name_key" ON "Country"("name");
-- CreateIndex
CREATE UNIQUE INDEX "Country_code_key" ON "Country"("code");
-- CreateIndex
CREATE UNIQUE INDEX "State_name_countryId_key" ON "State"("name", "countryId");
-- CreateIndex
CREATE UNIQUE INDEX "City_name_stateId_key" ON "City"("name", "stateId");
-- AddForeignKey
ALTER TABLE "State" ADD CONSTRAINT "State_countryId_fkey" FOREIGN KEY ("countryId") REFERENCES "Country"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "City" ADD CONSTRAINT "City_stateId_fkey" FOREIGN KEY ("stateId") REFERENCES "State"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

@ -0,0 +1,39 @@
/*
Warnings:
- You are about to drop the column `location` on the `QuestionsQuestionEncounter` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "QuestionsQuestionEncounter" DROP COLUMN "location",
ADD COLUMN "cityId" TEXT,
ADD COLUMN "countryId" TEXT,
ADD COLUMN "stateId" TEXT,
ALTER COLUMN "companyId" DROP NOT NULL;
-- CreateIndex
CREATE INDEX "QuestionsAnswer_updatedAt_id_idx" ON "QuestionsAnswer"("updatedAt", "id");
-- CreateIndex
CREATE INDEX "QuestionsAnswer_upvotes_id_idx" ON "QuestionsAnswer"("upvotes", "id");
-- CreateIndex
CREATE INDEX "QuestionsAnswerComment_updatedAt_id_idx" ON "QuestionsAnswerComment"("updatedAt", "id");
-- CreateIndex
CREATE INDEX "QuestionsAnswerComment_upvotes_id_idx" ON "QuestionsAnswerComment"("upvotes", "id");
-- CreateIndex
CREATE INDEX "QuestionsQuestionComment_updatedAt_id_idx" ON "QuestionsQuestionComment"("updatedAt", "id");
-- CreateIndex
CREATE INDEX "QuestionsQuestionComment_upvotes_id_idx" ON "QuestionsQuestionComment"("upvotes", "id");
-- AddForeignKey
ALTER TABLE "QuestionsQuestionEncounter" ADD CONSTRAINT "QuestionsQuestionEncounter_countryId_fkey" FOREIGN KEY ("countryId") REFERENCES "Country"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "QuestionsQuestionEncounter" ADD CONSTRAINT "QuestionsQuestionEncounter_stateId_fkey" FOREIGN KEY ("stateId") REFERENCES "State"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "QuestionsQuestionEncounter" ADD CONSTRAINT "QuestionsQuestionEncounter_cityId_fkey" FOREIGN KEY ("cityId") REFERENCES "City"("id") ON DELETE SET NULL ON UPDATE CASCADE;

@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "QuestionsQuestionType" ADD VALUE 'THEORY';

@ -0,0 +1,113 @@
/*
Warnings:
- You are about to drop the column `companyPercentile` on the `OffersAnalysis` table. All the data in the column will be lost.
- You are about to drop the column `noOfSimilarCompanyOffers` on the `OffersAnalysis` table. All the data in the column will be lost.
- You are about to drop the column `noOfSimilarOffers` on the `OffersAnalysis` table. All the data in the column will be lost.
- You are about to drop the column `overallPercentile` on the `OffersAnalysis` table. All the data in the column will be lost.
- You are about to drop the column `userId` on the `OffersProfile` table. All the data in the column will be lost.
- You are about to drop the `_TopCompanyOffers` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `_TopOverallOffers` table. If the table is not empty, all the data it contains will be lost.
- Added the required column `overallAnalysisUnitId` to the `OffersAnalysis` table without a default value. This is not possible if the table is not empty.
- Added the required column `updatedAt` to the `OffersAnalysis` table without a default value. This is not possible if the table is not empty.
*/
-- DropForeignKey
ALTER TABLE "OffersProfile" DROP CONSTRAINT "OffersProfile_userId_fkey";
-- DropForeignKey
ALTER TABLE "_TopCompanyOffers" DROP CONSTRAINT "_TopCompanyOffers_A_fkey";
-- DropForeignKey
ALTER TABLE "_TopCompanyOffers" DROP CONSTRAINT "_TopCompanyOffers_B_fkey";
-- DropForeignKey
ALTER TABLE "_TopOverallOffers" DROP CONSTRAINT "_TopOverallOffers_A_fkey";
-- DropForeignKey
ALTER TABLE "_TopOverallOffers" DROP CONSTRAINT "_TopOverallOffers_B_fkey";
-- AlterTable
ALTER TABLE "OffersAnalysis" DROP COLUMN "companyPercentile",
DROP COLUMN "noOfSimilarCompanyOffers",
DROP COLUMN "noOfSimilarOffers",
DROP COLUMN "overallPercentile",
ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "overallAnalysisUnitId" TEXT NOT NULL,
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL;
-- AlterTable
ALTER TABLE "OffersProfile" DROP COLUMN "userId";
-- DropTable
DROP TABLE "_TopCompanyOffers";
-- DropTable
DROP TABLE "_TopOverallOffers";
-- CreateTable
CREATE TABLE "OffersAnalysisUnit" (
"id" TEXT NOT NULL,
"companyName" TEXT NOT NULL,
"percentile" DOUBLE PRECISION NOT NULL,
"noOfSimilarOffers" INTEGER NOT NULL,
CONSTRAINT "OffersAnalysisUnit_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "_OffersProfileToUser" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL
);
-- CreateTable
CREATE TABLE "_CompanyAnalysis" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL
);
-- CreateTable
CREATE TABLE "_OffersAnalysisUnitToOffersOffer" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX "_OffersProfileToUser_AB_unique" ON "_OffersProfileToUser"("A", "B");
-- CreateIndex
CREATE INDEX "_OffersProfileToUser_B_index" ON "_OffersProfileToUser"("B");
-- CreateIndex
CREATE UNIQUE INDEX "_CompanyAnalysis_AB_unique" ON "_CompanyAnalysis"("A", "B");
-- CreateIndex
CREATE INDEX "_CompanyAnalysis_B_index" ON "_CompanyAnalysis"("B");
-- CreateIndex
CREATE UNIQUE INDEX "_OffersAnalysisUnitToOffersOffer_AB_unique" ON "_OffersAnalysisUnitToOffersOffer"("A", "B");
-- CreateIndex
CREATE INDEX "_OffersAnalysisUnitToOffersOffer_B_index" ON "_OffersAnalysisUnitToOffersOffer"("B");
-- AddForeignKey
ALTER TABLE "OffersAnalysis" ADD CONSTRAINT "OffersAnalysis_overallAnalysisUnitId_fkey" FOREIGN KEY ("overallAnalysisUnitId") REFERENCES "OffersAnalysisUnit"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_OffersProfileToUser" ADD CONSTRAINT "_OffersProfileToUser_A_fkey" FOREIGN KEY ("A") REFERENCES "OffersProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_OffersProfileToUser" ADD CONSTRAINT "_OffersProfileToUser_B_fkey" FOREIGN KEY ("B") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_CompanyAnalysis" ADD CONSTRAINT "_CompanyAnalysis_A_fkey" FOREIGN KEY ("A") REFERENCES "OffersAnalysis"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_CompanyAnalysis" ADD CONSTRAINT "_CompanyAnalysis_B_fkey" FOREIGN KEY ("B") REFERENCES "OffersAnalysisUnit"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_OffersAnalysisUnitToOffersOffer" ADD CONSTRAINT "_OffersAnalysisUnitToOffersOffer_A_fkey" FOREIGN KEY ("A") REFERENCES "OffersAnalysisUnit"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_OffersAnalysisUnitToOffersOffer" ADD CONSTRAINT "_OffersAnalysisUnitToOffersOffer_B_fkey" FOREIGN KEY ("B") REFERENCES "OffersOffer"("id") ON DELETE CASCADE ON UPDATE CASCADE;

@ -106,6 +106,35 @@ model Company {
OffersOffer OffersOffer[]
}
model Country {
id String @id
name String @unique
code String @unique
states State[]
questionsQuestionEncounters QuestionsQuestionEncounter[]
}
model State {
id String @id
name String
countryId String
cities City[]
country Country @relation(fields: [countryId], references: [id])
questionsQuestionEncounters QuestionsQuestionEncounter[]
@@unique([name, countryId])
}
model City {
id String @id
name String
stateId String
state State @relation(fields: [stateId], references: [id])
questionsQuestionEncounters QuestionsQuestionEncounter[]
@@unique([name, stateId])
}
// Start of Resumes project models.
// Add Resumes project models here, prefix all models with "Resumes",
// use camelCase for field names, and try to name them consistently
@ -196,8 +225,7 @@ model OffersProfile {
offers OffersOffer[]
user User? @relation(fields: [userId], references: [id])
userId String?
users User[]
analysis OffersAnalysis?
}
@ -333,9 +361,8 @@ model OffersOffer {
offersFullTime OffersFullTime? @relation(fields: [offersFullTimeId], references: [id], onDelete: Cascade)
offersFullTimeId String? @unique
OffersAnalysis OffersAnalysis? @relation("HighestOverallOffer")
OffersAnalysisTopOverallOffers OffersAnalysis[] @relation("TopOverallOffers")
OffersAnalysisTopCompanyOffers OffersAnalysis[] @relation("TopCompanyOffers")
offersAnalysis OffersAnalysis? @relation("HighestOverallOffer")
offersAnalysisUnit OffersAnalysisUnit[]
}
model OffersIntern {
@ -368,6 +395,8 @@ model OffersFullTime {
model OffersAnalysis {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
profile OffersProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
profileId String @unique
@ -376,14 +405,22 @@ model OffersAnalysis {
offerId String @unique
// OVERALL
overallPercentile Float
overallAnalysis OffersAnalysisUnit @relation("OverallAnalysis", fields: [overallAnalysisUnitId], references: [id])
overallAnalysisUnitId String
companyAnalysis OffersAnalysisUnit[] @relation("CompanyAnalysis")
}
model OffersAnalysisUnit {
id String @id @default(cuid())
companyName String
percentile Float
noOfSimilarOffers Int
topOverallOffers OffersOffer[] @relation("TopOverallOffers")
topSimilarOffers OffersOffer[]
// Company
companyPercentile Float
noOfSimilarCompanyOffers Int
topCompanyOffers OffersOffer[] @relation("TopCompanyOffers")
offersAnalysisOverall OffersAnalysis[] @relation("OverallAnalysis")
offersAnalysisCompany OffersAnalysis[] @relation("CompanyAnalysis")
}
// End of Offers project models.
@ -397,6 +434,7 @@ enum QuestionsQuestionType {
CODING
SYSTEM_DESIGN
BEHAVIORAL
THEORY
}
model QuestionsQuestion {
@ -415,7 +453,7 @@ model QuestionsQuestion {
votes QuestionsQuestionVote[]
comments QuestionsQuestionComment[]
answers QuestionsAnswer[]
QuestionsListQuestionEntry QuestionsListQuestionEntry[]
questionsListQuestionEntries QuestionsListQuestionEntry[]
@@index([lastSeenAt, id])
@@index([numEncounters, id])
@ -426,14 +464,18 @@ model QuestionsQuestionEncounter {
id String @id @default(cuid())
questionId String
userId String?
// TODO: sync with models (location, role)
companyId String
location String @db.Text
companyId String?
countryId String?
stateId String?
cityId String?
role String @db.Text
seenAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
country Country? @relation(fields: [countryId], references: [id], onDelete: SetNull)
state State? @relation(fields: [stateId], references: [id], onDelete: SetNull)
city City? @relation(fields: [cityId], references: [id], onDelete: SetNull)
company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull)
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
question QuestionsQuestion @relation(fields: [questionId], references: [id], onDelete: Cascade)
@ -465,6 +507,9 @@ model QuestionsQuestionComment {
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
question QuestionsQuestion @relation(fields: [questionId], references: [id], onDelete: Cascade)
votes QuestionsQuestionCommentVote[]
@@index([updatedAt, id])
@@index([upvotes, id])
}
model QuestionsQuestionCommentVote {
@ -494,6 +539,9 @@ model QuestionsAnswer {
question QuestionsQuestion @relation(fields: [questionId], references: [id], onDelete: Cascade)
votes QuestionsAnswerVote[]
comments QuestionsAnswerComment[]
@@index([updatedAt, id])
@@index([upvotes, id])
}
model QuestionsAnswerVote {
@ -522,6 +570,9 @@ model QuestionsAnswerComment {
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
answer QuestionsAnswer @relation(fields: [answerId], references: [id], onDelete: Cascade)
votes QuestionsAnswerCommentVote[]
@@index([updatedAt, id])
@@index([upvotes, id])
}
model QuestionsAnswerCommentVote {

@ -0,0 +1,217 @@
import { PrismaClient } from '@prisma/client';
import { JobTitleLabels } from '../src/components/shared/JobTitles';
const prisma = new PrismaClient();
type QuestionCreateData = Parameters<
typeof prisma.questionsQuestion.create
>[0]['data'];
function selectRandomRole() {
const roles = Object.keys(JobTitleLabels);
const randomIndex = Math.floor(Math.random() * roles.length);
return roles[randomIndex];
}
function generateRandomDate() {
// Return a date between 2020 and 2022.
const start = new Date(2020, 0, 1);
const end = new Date(2022, 0, 1);
return new Date(
start.getTime() + Math.random() * (end.getTime() - start.getTime()),
);
}
function generateRandomCodingAnswer() {
return CODING_ANSWER_CONTENT[
Math.floor(Math.random() * CODING_ANSWER_CONTENT.length)
];
}
function generateRandomBehavioralAnswer() {
return BEHAVIORAL_ANSWER_CONTENT[
Math.floor(Math.random() * BEHAVIORAL_ANSWER_CONTENT.length)
];
}
const CODING_QUESTION_CONTENT = [
'Given a string, find the length of the longest substring without repeating characters.',
'Given an array of integers, return indices of the two numbers such that they add up to a specific target.',
'Given a contiguous sequence of numbers in which each number repeats thrice, there is exactly one missing number. Find the missing number.',
'Find the contiguous subarray within an array (containing at least one number) which has the largest product.',
'Find a contiguous subarray which has the largest sum.',
];
const BEHAVIORAL_QUESTION_CONTENT = [
'Tell me about a time you had to work with a difficult person.',
'Rate your communication skills on a scale of 1 to 10.',
'Are you a team player?',
'What is your greatest weakness?',
'What is your greatest strength?',
'What is your biggest accomplishment?',
'What is your biggest failure?',
'Be honest, how would your friends describe you?',
'How do you handle stress?',
'Lets say you have a deadline to meet. How do you prioritize your work?',
];
const CODING_ANSWER_CONTENT = [
'This question is easy. Just use a hash map.',
'This question is hard. I have no idea how to solve it.',
'This question is medium. I can solve it in 30 minutes.',
'Can be done with a simple for loop.',
'Simple recursion can solve this.',
'Please explain the question again.',
'Question is not clear.',
'Brute force solution is the best.',
];
const BEHAVIORAL_ANSWER_CONTENT = [
'This is a very common question. I have a lot of experience with this.',
"I don't think this is a good question to ask. However, I can answer it.",
'Most companies ask this question. I think you should ask something else.',
'I try to take a step back and assess the situation. I figure out what is the most important thing to do and what can wait. I also try to delegate or ask for help when needed.',
'I try to have a discussion with my manager or the person who I feel is not valuing my work. I try to explain how I feel and what I would like to see change.',
'I try to have a discussion with the coworker. I try to understand their perspective and see if there is a way to resolve the issue.',
];
const CODING_QUESTIONS: Array<QuestionCreateData> = CODING_QUESTION_CONTENT.map(
(content) => ({
content,
questionType: 'CODING',
userId: null,
encounters: {
create: {
role: selectRandomRole(),
seenAt: generateRandomDate(),
},
},
}),
);
const BEHAVIORAL_QUESTIONS: Array<QuestionCreateData> =
BEHAVIORAL_QUESTION_CONTENT.map((content) => ({
content,
questionType: 'BEHAVIORAL',
userId: null,
encounters: {
create: {
role: selectRandomRole(),
seenAt: generateRandomDate(),
},
},
}));
const QUESTIONS: Array<QuestionCreateData> = [
...CODING_QUESTIONS,
...BEHAVIORAL_QUESTIONS,
];
async function main() {
console.log('Performing preliminary checks...');
const firstCompany = await prisma.company.findFirst();
if (!firstCompany) {
throw new Error(
'No company found. Please seed db with some companies first.',
);
}
const firstCity = await prisma.city.findFirst({
include: {
state: true,
},
});
if (!firstCity) {
throw new Error('No city found. Please seed db with some cities first.');
}
// Generate random answers to the questions
const users = await prisma.user.findMany();
if (users.length === 0) {
throw new Error('No users found. Please seed db with some users first.');
}
console.log('Seeding started...');
console.log('Creating coding and behavioral questions...');
await Promise.all([
QUESTIONS.map(async (question) => {
await prisma.questionsQuestion.create({
data: {
...question,
encounters: {
create: {
...question.encounters!.create,
companyId: firstCompany.id,
stateId: firstCity.stateId,
cityId: firstCity.id,
countryId: firstCity.state.countryId,
} as any,
},
},
});
}),
]);
console.log('Creating answers to coding questions...');
const codingQuestions = await prisma.questionsQuestion.findMany({
where: {
questionType: 'CODING',
},
});
await Promise.all(
codingQuestions.map(async (question) => {
const answers = Array.from(
{ length: Math.floor(Math.random() * 5) },
() => ({
content: generateRandomCodingAnswer(),
userId: users[Math.floor(Math.random() * users.length)].id,
questionId: question.id,
}),
);
await prisma.questionsAnswer.createMany({
data: answers,
});
}),
);
console.log('Creating answers to behavioral questions...');
const behavioralQuestions = await prisma.questionsQuestion.findMany({
where: {
questionType: 'BEHAVIORAL',
},
});
await Promise.all(
behavioralQuestions.map(async (question) => {
const answers = Array.from(
{ length: Math.floor(Math.random() * 5) },
() => ({
content: generateRandomBehavioralAnswer(),
userId: users[Math.floor(Math.random() * users.length)].id,
questionId: question.id,
}),
);
await prisma.questionsAnswer.createMany({
data: answers,
});
}),
);
console.log('Seeding completed.');
}
main()
.then(async () => {
await prisma.$disconnect();
})
.catch(async (e) => {
console.error(e);
await prisma.$disconnect();
process.exit(1);
});

@ -1,5 +1,9 @@
const { PrismaClient } = require('@prisma/client');
const cities = require('./data/cities.json');
const countries = require('./data/countries.json');
const states = require('./data/states.json');
const prisma = new PrismaClient();
const COMPANIES = [
@ -33,19 +37,58 @@ const COMPANIES = [
description: `Microsoft Corporation is an American multinational technology corporation which produces computer software, consumer electronics, personal computers, and related services headquartered at the Microsoft Redmond campus located in Redmond, Washington, United States.`,
logoUrl: 'https://logo.clearbit.com/microsoft.com',
},
{
name: 'Netflix',
slug: 'netflix',
description: null,
logoUrl: 'https://logo.clearbit.com/netflix.com',
},
];
async function main() {
console.log('Seeding started...');
await Promise.all([
COMPANIES.map(async (company) => {
await prisma.company.upsert({
where: { slug: company.slug },
update: company,
create: company,
console.info('Seeding companies');
await prisma.company.createMany({
data: COMPANIES.map((company) => ({
name: company.name,
slug: company.slug,
description: company.description,
logoUrl: company.logoUrl,
})),
skipDuplicates: true,
});
console.info('Seeding countries');
await prisma.country.createMany({
data: countries.data.map((country) => ({
id: country.country_id,
code: country.sortname,
name: country.country_name,
})),
skipDuplicates: true,
});
}),
]);
console.info('Seeding states');
await prisma.state.createMany({
data: states.data.map((state) => ({
id: state.state_id,
countryId: state.country_id,
name: state.state_name,
})),
skipDuplicates: true,
});
console.info('Seeding cities');
await prisma.city.createMany({
data: cities.data.map((city) => ({
id: city.city_id,
stateId: city.state_id,
name: city.city_name,
})),
skipDuplicates: true,
});
console.log('Seeding completed.');
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 351 KiB

@ -0,0 +1,67 @@
<svg width="421" height="333" viewBox="0 0 421 333" fill="none" xmlns="http://www.w3.org/2000/svg">
<ellipse cx="197.5" cy="292" rx="197.5" ry="41" fill="#EFF2F5"/>
<path d="M328 252.5C328 288.122 269.573 317 197.5 317C125.427 317 67 288.122 67 252.5C67 216.878 125.427 188 197.5 188C269.573 188 328 216.878 328 252.5Z" fill="#D29403"/>
<path d="M328 252.5C328 288.122 269.573 317 197.5 317C125.427 317 67 288.122 67 252.5C67 216.878 125.427 188 197.5 188C269.573 188 328 216.878 328 252.5Z" fill="#D29403"/>
<path d="M67 227C67 221.477 71.4772 217 77 217H318C323.523 217 328 221.477 328 227V253H67V227Z" fill="#D29403"/>
<path d="M328.257 205.316C328.257 240.857 269.825 269.669 197.745 269.669C125.666 269.669 67.2334 240.857 67.2334 205.316C67.2334 169.775 125.666 140.963 197.745 140.963C269.825 140.963 328.257 169.775 328.257 205.316Z" fill="white"/>
<path d="M328.257 205.316C328.257 240.857 269.825 269.669 197.745 269.669C125.666 269.669 67.2334 240.857 67.2334 205.316C67.2334 169.775 125.666 140.963 197.745 140.963C269.825 140.963 328.257 169.775 328.257 205.316Z" fill="white"/>
<path d="M255 292.5C255 297.747 251.194 302 246.5 302C241.806 302 238 297.747 238 292.5C238 287.254 241.806 283 246.5 283C251.194 283 255 287.254 255 292.5Z" fill="#A47811"/>
<path d="M290 280.5C290 285.747 286.194 290 281.5 290C276.806 290 273 285.747 273 280.5C273 275.254 276.806 271 281.5 271C286.194 271 290 275.254 290 280.5Z" fill="#A47811"/>
<path d="M317 263.5C317 268.747 313.418 273 309 273C304.582 273 301 268.747 301 263.5C301 258.254 304.582 254 309 254C313.418 254 317 258.254 317 263.5Z" fill="#A47811"/>
<path d="M328 208.5C328 244.122 269.573 273 197.5 273C125.427 273 67 244.122 67 208.5C67 172.878 125.427 144 197.5 144C269.573 144 328 172.878 328 208.5Z" fill="#E8A301"/>
<path d="M328 208.5C328 244.122 269.573 273 197.5 273C125.427 273 67 244.122 67 208.5C67 172.878 125.427 144 197.5 144C269.573 144 328 172.878 328 208.5Z" fill="#E8A301"/>
<path d="M67 183C67 177.477 71.4772 173 77 173H318C323.523 173 328 177.477 328 183V207H67V183Z" fill="#E8A301"/>
<path d="M328.257 155.328C328.257 190.869 269.825 219.681 197.745 219.681C125.666 219.681 67.2334 190.869 67.2334 155.328C67.2334 119.787 125.666 90.9756 197.745 90.9756C269.825 90.9756 328.257 119.787 328.257 155.328Z" fill="white"/>
<path d="M328.257 155.328C328.257 190.869 269.825 219.681 197.745 219.681C125.666 219.681 67.2334 190.869 67.2334 155.328C67.2334 119.787 125.666 90.9756 197.745 90.9756C269.825 90.9756 328.257 119.787 328.257 155.328Z" fill="white"/>
<path d="M328.257 164.328C328.257 199.869 269.825 228.681 197.745 228.681C125.666 228.681 67.2334 199.869 67.2334 164.328C67.2334 128.787 125.666 99.9756 197.745 99.9756C269.825 99.9756 328.257 128.787 328.257 164.328Z" fill="#FEB915"/>
<path d="M328.257 164.328C328.257 199.869 269.825 228.681 197.745 228.681C125.666 228.681 67.2334 199.869 67.2334 164.328C67.2334 128.787 125.666 99.9756 197.745 99.9756C269.825 99.9756 328.257 128.787 328.257 164.328Z" fill="#FEB915"/>
<path d="M67.2334 128.577H328.257V163.307H67.2334V128.577Z" fill="#FEB915"/>
<rect x="83" y="157" width="8" height="39" rx="4" fill="#D29403"/>
<rect x="108" y="171" width="8" height="41" rx="4" fill="#D29403"/>
<rect x="135" y="180" width="8" height="41" rx="4" fill="#D29403"/>
<path d="M328.257 122.384C328.257 157.925 269.825 186.736 197.745 186.736C125.665 186.736 67.2334 157.925 67.2334 122.384C67.2334 86.8425 125.665 58.0308 197.745 58.0308C269.825 58.0308 328.257 86.8425 328.257 122.384Z" fill="url(#paint0_linear_464_451)"/>
<path d="M328.257 122.384C328.257 157.925 269.825 186.736 197.745 186.736C125.665 186.736 67.2334 157.925 67.2334 122.384C67.2334 86.8425 125.665 58.0308 197.745 58.0308C269.825 58.0308 328.257 86.8425 328.257 122.384Z" fill="url(#paint1_linear_464_451)"/>
<path d="M310.937 121.362C310.937 127.927 307.956 134.293 302.337 140.209C296.71 146.133 288.497 151.536 278.235 156.106C257.713 165.245 229.257 170.936 197.745 170.936C166.234 170.936 137.778 165.245 117.256 156.106C106.993 151.536 98.7803 146.133 93.1537 140.209C87.5344 134.293 84.553 127.927 84.553 121.362C84.553 114.797 87.5344 108.431 93.1537 102.515C98.7803 96.5909 106.993 91.1879 117.256 86.6177C137.778 77.4791 166.234 71.7883 197.745 71.7883C229.257 71.7883 257.713 77.4791 278.235 86.6177C288.497 91.1879 296.71 96.5909 302.337 102.515C307.956 108.431 310.937 114.797 310.937 121.362Z" stroke="#B38519" stroke-width="3"/>
<path d="M312 123C312 150.614 260.737 173 197.5 173C134.263 173 83 150.614 83 123C83 95.3858 134.263 73 197.5 73C260.737 73 312 95.3858 312 123Z" fill="url(#paint2_linear_464_451)"/>
<path d="M312 123C312 150.614 260.737 173 197.5 173C134.263 173 83 150.614 83 123C83 95.3858 134.263 73 197.5 73C260.737 73 312 95.3858 312 123Z" fill="url(#paint3_linear_464_451)"/>
<rect width="62.7775" height="40.8114" rx="5" transform="matrix(0.859385 0.486424 -0.512512 0.872543 356.912 105.104)" fill="#EA5585"/>
<path d="M358.482 165.616C357.162 166.339 355.581 165.445 355.558 163.961L355.339 150.064C355.314 148.501 357.019 147.467 358.365 148.229L370.945 155.35C372.291 156.112 372.234 158.078 370.844 158.84L358.482 165.616Z" fill="#EA5585"/>
<ellipse rx="4.32081" ry="4.47376" transform="matrix(0.487833 0.872937 -0.858827 0.512266 360.684 132.021)" fill="white"/>
<ellipse rx="4.32081" ry="4.47376" transform="matrix(0.487833 0.872937 -0.858827 0.512266 372.733 138.916)" fill="white"/>
<ellipse rx="4.32081" ry="4.47376" transform="matrix(0.487833 0.872937 -0.858827 0.512266 384.783 145.81)" fill="white"/>
<path d="M188.07 2.34887C188.404 1.50629 189.596 1.50629 189.93 2.34888L195.043 15.2661C195.15 15.5371 195.371 15.7479 195.646 15.8431L209.268 20.5549C210.165 20.8654 210.165 22.1346 209.268 22.4451L195.646 27.1569C195.371 27.2521 195.15 27.4629 195.043 27.7339L189.93 40.6511C189.596 41.4937 188.404 41.4937 188.07 40.6511L182.957 27.7339C182.85 27.4629 182.629 27.2521 182.354 27.1569L168.732 22.4451C167.835 22.1346 167.835 20.8654 168.732 20.5549L182.354 15.8431C182.629 15.7479 182.85 15.5371 182.957 15.2661L188.07 2.34887Z" fill="#FBE54D"/>
<path d="M33.557 127.673C33.8709 126.783 35.1291 126.783 35.443 127.673L39.884 140.26C39.981 140.535 40.1933 140.754 40.4652 140.859L52.5978 145.568C53.4487 145.898 53.4487 147.102 52.5978 147.432L40.4652 152.141C40.1933 152.246 39.981 152.465 39.884 152.74L35.443 165.327C35.1291 166.217 33.8709 166.217 33.557 165.327L29.116 152.74C29.019 152.465 28.8067 152.246 28.5348 152.141L16.4022 147.432C15.5513 147.102 15.5513 145.898 16.4022 145.568L28.5348 140.859C28.8067 140.754 29.019 140.535 29.116 140.26L33.557 127.673Z" fill="#FBE54D"/>
<path d="M148.553 266.786C148.86 265.882 150.14 265.882 150.447 266.786L155.429 281.448C155.523 281.724 155.732 281.945 156.003 282.054L169.698 287.572C170.533 287.909 170.533 289.091 169.698 289.428L156.003 294.946C155.732 295.055 155.523 295.276 155.429 295.552L150.447 310.214C150.14 311.118 148.86 311.118 148.553 310.214L143.571 295.552C143.477 295.276 143.268 295.055 142.997 294.946L129.302 289.428C128.467 289.091 128.467 287.909 129.302 287.572L142.997 282.054C143.268 281.945 143.477 281.724 143.571 281.448L148.553 266.786Z" fill="#FBE54D"/>
<path d="M340.065 188.473C340.391 187.611 341.609 187.611 341.935 188.473L347.049 201.993C347.153 202.266 347.37 202.48 347.644 202.579L361.402 207.56C362.282 207.878 362.282 209.122 361.402 209.44L347.644 214.421C347.37 214.52 347.153 214.734 347.049 215.007L341.935 228.527C341.609 229.389 340.391 229.389 340.065 228.527L334.951 215.007C334.847 214.734 334.63 214.52 334.356 214.421L320.598 209.44C319.718 209.122 319.718 207.878 320.598 207.56L334.356 202.579C334.63 202.48 334.847 202.266 334.951 201.993L340.065 188.473Z" fill="#FBE54D"/>
<rect width="68.9835" height="44.8202" rx="5" transform="matrix(0.858588 -0.512666 0.48744 0.873157 25.707 37.4084)" fill="#67E2A5"/>
<path d="M85.4411 70.007C85.4858 71.629 83.7437 72.6692 82.4078 71.8182L67.2894 62.187C66.0329 61.3864 66.0791 59.4893 67.374 58.7161L81.9867 49.9908C83.2816 49.2176 84.8933 50.1248 84.9354 51.6505L85.4411 70.007Z" fill="#67E2A5"/>
<ellipse cx="60.8525" cy="32.4967" rx="6.64913" ry="6.87663" fill="white"/>
<path d="M59.0778 56.9464C56.3857 52.1241 58.0474 45.9196 62.7892 43.0882L70.296 38.6059C75.0378 35.7745 81.0642 37.3885 83.7563 42.2108L85.2766 44.9343L60.5981 59.6699L59.0778 56.9464Z" fill="white"/>
<rect width="8" height="35.872" rx="4" transform="matrix(-0.533901 -0.845547 0.884338 -0.466847 146 128)" fill="#B38519"/>
<rect width="8" height="64.5197" rx="4" transform="matrix(-0.948835 -0.315772 0.369837 -0.929097 190.885 155)" fill="#B38519"/>
<rect width="8" height="35.872" rx="4" transform="matrix(-0.533901 0.845547 -0.884338 -0.466847 178 134)" fill="#B38519"/>
<path d="M225.537 139.133C223.584 140.164 221.044 139.486 219.864 137.618V137.618C218.685 135.75 219.313 133.4 221.266 132.368L245.914 119.356C247.868 118.325 250.408 119.003 251.587 120.871V120.871C252.767 122.739 252.139 125.089 250.186 126.121L225.537 139.133Z" fill="#B38519"/>
<rect width="8" height="35.872" rx="4" transform="matrix(-0.533901 0.845547 -0.884338 -0.466847 254 121)" fill="#B38519"/>
<defs>
<linearGradient id="paint0_linear_464_451" x1="189.835" y1="186.736" x2="362.561" y2="-21.2541" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFCC53"/>
<stop offset="0.418898" stop-color="#FFDC8A"/>
<stop offset="1" stop-color="#FFF3D7"/>
</linearGradient>
<linearGradient id="paint1_linear_464_451" x1="189.835" y1="186.736" x2="362.561" y2="-21.2541" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFCC53"/>
<stop offset="0.418898" stop-color="#FFDC8A"/>
<stop offset="1" stop-color="#FFF3D7"/>
</linearGradient>
<linearGradient id="paint2_linear_464_451" x1="190.561" y1="173" x2="320.885" y2="-4.20097" gradientUnits="userSpaceOnUse">
<stop stop-color="#FEB915"/>
<stop offset="0.418898" stop-color="#FFDC8A"/>
<stop offset="1" stop-color="#FFF3D7"/>
</linearGradient>
<linearGradient id="paint3_linear_464_451" x1="190.561" y1="173" x2="320.885" y2="-4.20097" gradientUnits="userSpaceOnUse">
<stop stop-color="#FEB915"/>
<stop offset="0.418898" stop-color="#FFDC8A"/>
<stop offset="1" stop-color="#FFF3D7"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 10 KiB

@ -9,7 +9,9 @@ import { Bars3BottomLeftIcon } from '@heroicons/react/24/outline';
import GlobalNavigation from '~/components/global/GlobalNavigation';
import HomeNavigation from '~/components/global/HomeNavigation';
import OffersNavigation from '~/components/offers/OffersNavigation';
import OffersNavigation, {
OffersNavigationAuthenticated,
} from '~/components/offers/OffersNavigation';
import QuestionsNavigation from '~/components/questions/QuestionsNavigation';
import ResumesNavigation from '~/components/resumes/ResumesNavigation';
@ -105,9 +107,11 @@ function ProfileJewel() {
export default function AppShell({ children }: Props) {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const router = useRouter();
const { data: session } = useSession();
const currentProductNavigation: Readonly<{
googleAnalyticsMeasurementID: string;
logo?: React.ReactNode;
navigation: ProductNavigationItems;
showGlobalNav: boolean;
title: string;
@ -119,8 +123,11 @@ export default function AppShell({ children }: Props) {
}
if (path.startsWith('/offers')) {
if (session == null) {
return OffersNavigation;
}
return OffersNavigationAuthenticated;
}
if (path.startsWith('/questions')) {
return QuestionsNavigation;
@ -173,6 +180,7 @@ export default function AppShell({ children }: Props) {
<MobileNavigation
globalNavigationItems={GlobalNavigation}
isShown={mobileMenuOpen}
logo={currentProductNavigation.logo}
productNavigationItems={currentProductNavigation.navigation}
productTitle={currentProductNavigation.title}
setIsShown={setMobileMenuOpen}
@ -192,6 +200,7 @@ export default function AppShell({ children }: Props) {
<div className="flex flex-1 items-center">
<ProductNavigation
items={currentProductNavigation.navigation}
logo={currentProductNavigation.logo}
title={currentProductNavigation.title}
titleHref={currentProductNavigation.titleHref}
/>

@ -1,5 +1,5 @@
import Head from 'next/head';
import { useRouter } from 'next/router';
import Script from 'next/script';
import { createContext, useContext, useEffect } from 'react';
type Context = Readonly<{
@ -78,25 +78,26 @@ export default function GoogleAnalytics({ children, measurementID }: Props) {
return (
<GoogleAnalyticsContext.Provider value={{ event }}>
{children}
<Head>
{/* TODO(yangshun): Change back to next/script in future. */}
{/* Global Site Tag (gtag.js) - Google Analytics */}
<Script
<script
async={true}
src={`https://www.googletagmanager.com/gtag/js?id=${measurementID}`}
strategy="afterInteractive"
/>
<Script
<script
dangerouslySetInnerHTML={{
__html: `
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
window.gtag = function(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '${measurementID}', {
page_path: window.location.pathname,
});
`,
}}
id="gtag-init"
strategy="afterInteractive"
/>
</Head>
</GoogleAnalyticsContext.Provider>
);
}

@ -11,6 +11,7 @@ import type { ProductNavigationItems } from './ProductNavigation';
type Props = Readonly<{
globalNavigationItems: GlobalNavigationItems;
isShown?: boolean;
logo?: React.ReactNode;
productNavigationItems: ProductNavigationItems;
productTitle: string;
setIsShown: (isShown: boolean) => void;
@ -19,6 +20,7 @@ type Props = Readonly<{
export default function MobileNavigation({
globalNavigationItems,
isShown,
logo,
productNavigationItems,
productTitle,
setIsShown,
@ -69,11 +71,13 @@ export default function MobileNavigation({
</Transition.Child>
<div className="flex flex-shrink-0 items-center px-4">
<Link href="/">
{logo ?? (
<img
alt="Tech Interview Handbook"
className="h-8 w-auto"
src="/logo.svg"
/>
)}
</Link>
</div>
<div className="mt-5 h-0 flex-1 overflow-y-auto px-2">

@ -17,11 +17,17 @@ export type ProductNavigationItems = ReadonlyArray<NavigationItem>;
type Props = Readonly<{
items: ProductNavigationItems;
logo?: React.ReactNode;
title: string;
titleHref: string;
}>;
export default function ProductNavigation({ items, title, titleHref }: Props) {
export default function ProductNavigation({
items,
logo,
title,
titleHref,
}: Props) {
const router = useRouter();
return (
@ -29,13 +35,14 @@ export default function ProductNavigation({ items, title, titleHref }: Props) {
<Link
className="hover:text-primary-700 flex items-center gap-2 text-base font-medium"
href={titleHref}>
{titleHref !== '/' && (
{titleHref !== '/' &&
(logo ?? (
<img
alt="Tech Interview Handbook"
className="h-8 w-auto"
src="/logo.svg"
/>
)}
))}
{title}
</Link>
<div className="hidden h-full items-center space-x-8 md:flex">

@ -1,17 +0,0 @@
import type { ProductNavigationItems } from '~/components/global/ProductNavigation';
const navigation: ProductNavigationItems = [
{ href: '/offers/submit', name: 'Analyze your offers' },
{ href: '/offers/features', name: 'Features' },
];
const config = {
// TODO: Change this to your own GA4 measurement ID.
googleAnalyticsMeasurementID: 'G-DBLZDQ2ZZN',
navigation,
showGlobalNav: false,
title: 'Tech Offers Repo',
titleHref: '/offers',
};
export default config;

@ -0,0 +1,30 @@
import type { ProductNavigationItems } from '~/components/global/ProductNavigation';
const navigation: ProductNavigationItems = [
{ href: '/offers/submit', name: 'Analyze your offers' },
{ href: '/offers/features', name: 'Features' },
];
const navigationAuthenticated: ProductNavigationItems = [
{ href: '/offers/submit', name: 'Analyze your offers' },
{ href: '/offers/dashboard', name: 'Your dashboard' },
{ href: '/offers/features', name: 'Features' },
];
const config = {
googleAnalyticsMeasurementID: 'G-34XRGLEVCF',
logo: (
<img alt="Tech Offers Repo" className="h-8 w-auto" src="/offers-logo.svg" />
),
navigation,
showGlobalNav: false,
title: 'Tech Offers Repo',
titleHref: '/offers',
};
export const OffersNavigationAuthenticated = {
...config,
navigation: navigationAuthenticated,
};
export default config;

@ -0,0 +1,55 @@
import { JobType } from '@prisma/client';
import { HorizontalDivider } from '@tih/ui';
import type { JobTitleType } from '~/components/shared/JobTitles';
import { getLabelForJobTitleType } from '~/components/shared/JobTitles';
import { convertMoneyToString } from '~/utils/offers/currency';
import { formatDate } from '~/utils/offers/time';
import type { UserProfileOffer } from '~/types/offers';
type Props = Readonly<{
disableTopDivider?: boolean;
offer: UserProfileOffer;
}>;
export default function DashboardProfileCard({
disableTopDivider,
offer: {
company,
income,
jobType,
level,
location,
monthYearReceived,
title,
},
}: Props) {
return (
<>
{!disableTopDivider && <HorizontalDivider />}
<div className="flex items-end justify-between">
<div className="col-span-1 row-span-3">
<p className="font-bold">
{getLabelForJobTitleType(title as JobTitleType)}
</p>
<p>
{location
? `Company: ${company.name}, ${location}`
: `Company: ${company.name}`}
</p>
{level && <p>Level: {level}</p>}
</div>
<div className="col-span-1 row-span-3">
<p className="text-end">{formatDate(monthYearReceived)}</p>
<p className="text-end text-xl">
{jobType === JobType.FULLTIME
? `${convertMoneyToString(income)} / year`
: `${convertMoneyToString(income)} / month`}
</p>
</div>
</div>
</>
);
}

@ -0,0 +1,113 @@
import { useRouter } from 'next/router';
import { ArrowRightIcon, XMarkIcon } from '@heroicons/react/24/outline';
import { Button, useToast } from '@tih/ui';
import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics';
import DashboardOfferCard from '~/components/offers/dashboard/DashboardOfferCard';
import { formatDate } from '~/utils/offers/time';
import { trpc } from '~/utils/trpc';
import ProfilePhotoHolder from '../profile/ProfilePhotoHolder';
import type { UserProfile, UserProfileOffer } from '~/types/offers';
type Props = Readonly<{
profile: UserProfile;
}>;
export default function DashboardProfileCard({
profile: { createdAt, id, offers, profileName, token },
}: Props) {
const { showToast } = useToast();
const router = useRouter();
const trpcContext = trpc.useContext();
const PROFILE_URL = `/offers/profile/${id}?token=${token}`;
const { event: gaEvent } = useGoogleAnalytics();
const removeSavedProfileMutation = trpc.useMutation(
'offers.user.profile.removeFromUserProfile',
{
onError: () => {
showToast({
title: `Server error.`,
variant: 'failure',
});
},
onSuccess: () => {
trpcContext.invalidateQueries(['offers.user.profile.getUserProfiles']);
showToast({
title: `Profile removed from your dashboard successfully!`,
variant: 'success',
});
},
},
);
function handleRemoveProfile() {
removeSavedProfileMutation.mutate({ profileId: id });
}
return (
<div className="space-y-4 bg-white px-4 pt-5 sm:px-4">
{/* Header */}
<div className="-ml-4 -mt-2 flex flex-wrap items-center justify-between border-b border-gray-300 pb-4 sm:flex-nowrap">
<div className="flex items-center gap-x-5">
<div>
<ProfilePhotoHolder size="sm" />
</div>
<div className="col-span-10">
<p className="text-xl font-bold">{profileName}</p>
<div className="flex flex-row">
<span>Created at {formatDate(createdAt)}</span>
</div>
</div>
</div>
<div className="flex self-start">
<Button
disabled={removeSavedProfileMutation.isLoading}
icon={XMarkIcon}
isLabelHidden={true}
label="Remove Profile"
size="md"
variant="tertiary"
onClick={handleRemoveProfile}
/>
</div>
</div>
{/* Offers */}
<div>
{offers.map((offer: UserProfileOffer, index) =>
index === 0 ? (
<DashboardOfferCard
key={offer.id}
disableTopDivider={true}
offer={offer}
/>
) : (
<DashboardOfferCard key={offer.id} offer={offer} />
),
)}
</div>
<div className="flex justify-end pt-1">
<Button
disabled={removeSavedProfileMutation.isLoading}
icon={ArrowRightIcon}
isLabelHidden={false}
label="Read full profile"
size="md"
variant="secondary"
onClick={() => {
gaEvent({
action: 'offers.view_profile_from_dashboard',
category: 'engagement',
label: 'View profile from dashboard',
});
router.push(PROFILE_URL);
}}
/>
</div>
</div>
);
}

@ -6,27 +6,20 @@ import OfferPercentileAnalysisText from './OfferPercentileAnalysisText';
import OfferProfileCard from './OfferProfileCard';
import { OVERALL_TAB } from '../constants';
import type {
Analysis,
AnalysisHighestOffer,
ProfileAnalysis,
} from '~/types/offers';
type OfferAnalysisData = {
offer?: AnalysisHighestOffer;
offerAnalysis?: Analysis;
};
import type { AnalysisUnit, ProfileAnalysis } from '~/types/offers';
type OfferAnalysisContentProps = Readonly<{
analysis: OfferAnalysisData;
analysis: AnalysisUnit;
isSubmission: boolean;
tab: string;
}>;
function OfferAnalysisContent({
analysis: { offer, offerAnalysis },
analysis,
tab,
isSubmission,
}: OfferAnalysisContentProps) {
if (!offerAnalysis || !offer || offerAnalysis.noOfOffers === 0) {
if (!analysis || analysis.noOfOffers === 0) {
if (tab === OVERALL_TAB) {
return (
<p className="m-10">
@ -45,62 +38,74 @@ function OfferAnalysisContent({
return (
<>
<OfferPercentileAnalysisText
companyName={offer.company.name}
offerAnalysis={offerAnalysis}
analysis={analysis}
isSubmission={isSubmission}
tab={tab}
/>
<p className="mt-5">Here are some of the top offers relevant to you:</p>
{offerAnalysis.topPercentileOffers.map((topPercentileOffer) => (
<p className="mt-5">
{isSubmission
? 'Here are some of the top offers relevant to you:'
: 'Relevant top offers:'}
</p>
{analysis.topPercentileOffers.map((topPercentileOffer) => (
<OfferProfileCard
key={topPercentileOffer.id}
offerProfile={topPercentileOffer}
/>
))}
{/* {offerAnalysis.topPercentileOffers.length > 0 && (
<div className="mb-4 flex justify-end">
<Button
icon={EllipsisHorizontalIcon}
label="View more offers"
variant="tertiary"
/>
</div>
)} */}
</>
);
}
type OfferAnalysisProps = Readonly<{
allAnalysis?: ProfileAnalysis | null;
allAnalysis: ProfileAnalysis;
isError: boolean;
isLoading: boolean;
isSubmission?: boolean;
}>;
export default function OfferAnalysis({
allAnalysis,
isError,
isLoading,
isSubmission = false,
}: OfferAnalysisProps) {
const [tab, setTab] = useState(OVERALL_TAB);
const [analysis, setAnalysis] = useState<OfferAnalysisData | null>(null);
const [analysis, setAnalysis] = useState<AnalysisUnit>(
allAnalysis.overallAnalysis,
);
useEffect(() => {
if (tab === OVERALL_TAB) {
setAnalysis({
offer: allAnalysis?.overallHighestOffer,
offerAnalysis: allAnalysis?.overallAnalysis,
});
setAnalysis(allAnalysis.overallAnalysis);
} else {
setAnalysis({
offer: allAnalysis?.overallHighestOffer,
offerAnalysis: allAnalysis?.companyAnalysis[0],
});
setAnalysis(allAnalysis.companyAnalysis[parseInt(tab, 10)]);
}
}, [tab, allAnalysis]);
const tabOptions = [
const companyTabs = allAnalysis.companyAnalysis.map((value, index) => ({
label: value.companyName,
value: `${index}`,
}));
let tabOptions = [
{
label: OVERALL_TAB,
value: OVERALL_TAB,
},
{
label: allAnalysis?.overallHighestOffer.company.name || '',
value: allAnalysis?.overallHighestOffer.company.id || '',
},
];
tabOptions = tabOptions.concat(companyTabs);
return (
analysis && (
<div>
{isError && (
<p className="m-10 text-center">
@ -117,10 +122,13 @@ export default function OfferAnalysis({
onChange={setTab}
/>
<HorizontalDivider className="mb-5" />
<OfferAnalysisContent analysis={analysis} tab={tab} />
<OfferAnalysisContent
analysis={analysis}
isSubmission={isSubmission}
tab={tab}
/>
</div>
)}
</div>
)
);
}

@ -1,29 +1,31 @@
import { OVERALL_TAB } from '../constants';
import type { Analysis } from '~/types/offers';
import type { AnalysisUnit } from '~/types/offers';
type OfferPercentileAnalysisTextProps = Readonly<{
companyName: string;
offerAnalysis: Analysis;
analysis: AnalysisUnit;
isSubmission: boolean;
tab: string;
}>;
export default function OfferPercentileAnalysisText({
tab,
companyName,
offerAnalysis: { noOfOffers, percentile },
analysis: { noOfOffers, percentile, companyName },
isSubmission,
}: OfferPercentileAnalysisTextProps) {
return tab === OVERALL_TAB ? (
<p>
Your highest offer is from <b>{companyName}</b>, which is{' '}
<b>{percentile.toFixed(1)}</b> percentile out of <b>{noOfOffers}</b>{' '}
offers received for the same job title and YOE(±1) in the last year.
{isSubmission ? 'Your' : "This profile's"} highest offer is from{' '}
<b>{companyName}</b>, which is <b>{percentile.toFixed(1)}</b> percentile
out of <b>{noOfOffers}</b> offers received for the same job title and
YOE(±1) in the last year.
</p>
) : (
<p>
Your offer from <b>{companyName}</b> is <b>{percentile.toFixed(1)}</b>{' '}
percentile out of <b>{noOfOffers}</b> offers received in {companyName} for
the same job title and YOE(±1) in the last year.
{isSubmission ? 'Your' : 'The'} offer from <b>{companyName}</b> is{' '}
<b>{percentile.toFixed(1)}</b> percentile out of <b>{noOfOffers}</b>{' '}
offers received in {companyName} for the same job title and YOE(±1) in the
last year.
</p>
);
}

@ -12,6 +12,7 @@ import { convertMoneyToString } from '~/utils/offers/currency';
import { formatDate } from '~/utils/offers/time';
import ProfilePhotoHolder from '../profile/ProfilePhotoHolder';
import { JobTypeLabel } from '../types';
import type { AnalysisOffer } from '~/types/offers';
@ -34,18 +35,25 @@ export default function OfferProfileCard({
},
}: OfferProfileCardProps) {
return (
<div className="my-5 block rounded-lg bg-white p-4 px-8 shadow-md">
// <a
// className="my-5 block rounded-lg bg-white p-4 px-8 shadow-md"
// href={`/offers/profile/${id}`}
// rel="noreferrer"
// target="_blank">
<div className="my-5 block rounded-lg bg-white p-4 px-8 shadow-lg">
<div className="flex items-center gap-x-5">
<div>
<ProfilePhotoHolder size="sm" />
</div>
<div className="col-span-10">
<p className="font-bold">{profileName}</p>
{previousCompanies.length > 0 && (
<div className="flex flex-row">
<BuildingOffice2Icon className="mr-2 h-5" />
<span className="mr-2 font-bold">Current:</span>
<span>{previousCompanies[0]}</span>
</div>
)}
<div className="flex flex-row">
<CalendarDaysIcon className="mr-2 h-5" />
<span className="mr-2 font-bold">YOE:</span>
@ -58,12 +66,13 @@ export default function OfferProfileCard({
<div className="flex items-end justify-between">
<div className="col-span-1 row-span-3">
<p className="font-bold">
{getLabelForJobTitleType(title as JobTitleType)}
{getLabelForJobTitleType(title as JobTitleType)}{' '}
{`(${JobTypeLabel[jobType]})`}
</p>
<p>
Company: {company.name}, {location}
</p>
<p>Level: {level}</p>
{level && <p>Level: {level}</p>}
</div>
<div className="col-span-1 row-span-3">
<p className="text-end">{formatDate(monthYearReceived)}</p>

@ -1,9 +1,14 @@
// Import { useState } from 'react';
// import { setTimeout } from 'timers';
import { useState } from 'react';
import { DocumentDuplicateIcon } from '@heroicons/react/20/solid';
import { BookmarkSquareIcon, CheckIcon } from '@heroicons/react/24/outline';
import { Button, TextInput, useToast } from '@tih/ui';
import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics';
import { copyProfileLink, getProfileLink } from '~/utils/offers/link';
import { trpc } from '~/utils/trpc';
type OfferProfileSaveProps = Readonly<{
profileId: string;
@ -15,16 +20,39 @@ export default function OffersProfileSave({
token,
}: OfferProfileSaveProps) {
const { showToast } = useToast();
// Const [isSaving, setSaving] = useState(false);
// const [isSaved, setSaved] = useState(false);
const { event: gaEvent } = useGoogleAnalytics();
const [isSaved, setSaved] = useState(false);
const saveMutation = trpc.useMutation(
['offers.user.profile.addToUserProfile'],
{
onError: () => {
showToast({
title: `Failed to saved to dashboard!`,
variant: 'failure',
});
},
onSuccess: () => {
showToast({
title: `Saved to your dashboard!`,
variant: 'success',
});
},
},
);
// Const saveProfile = () => {
// setSaving(true);
// setTimeout(() => {
// setSaving(false);
// setSaved(true);
// }, 5);
// };
const handleSave = () => {
saveMutation.mutate({
profileId,
token: token as string,
});
setSaved(true);
gaEvent({
action: 'offers.profile_submission_save_to_profile',
category: 'engagement',
label: 'Save to profile in profile submission',
});
};
return (
<div className="flex w-full justify-center">
@ -57,24 +85,29 @@ export default function OffersProfileSave({
title: `Profile edit link copied to clipboard!`,
variant: 'success',
});
gaEvent({
action: 'offers.profile_submission_copy_edit_profile_link',
category: 'engagement',
label: 'Copy Edit Profile Link in Profile Submission',
});
}}
/>
</div>
{/* <p className="mb-5 text-slate-900">
<p className="mb-5 text-slate-900">
If you do not want to keep the edit link, you can opt to save this
profile under your user account. It will still only be editable by
you.
profile under your account's dashboard. It will still only be editable
by you.
</p>
<div className="mb-20">
<Button
disabled={isSaved}
icon={isSaved ? CheckIcon : BookmarkSquareIcon}
isLoading={isSaving}
isLoading={saveMutation.isLoading}
label={isSaved ? 'Saved to user profile' : 'Save to user profile'}
variant="primary"
onClick={saveProfile}
onClick={handleSave}
/>
</div> */}
</div>
</div>
</div>
);

@ -17,12 +17,17 @@ export default function OffersSubmissionAnalysis({
<h5 className="mb-8 text-center text-4xl font-bold text-slate-900">
Result
</h5>
{!analysis && (
<p className="mb-8 text-center">Error generating analysis.</p>
)}
{analysis && (
<OfferAnalysis
key={3}
allAnalysis={analysis}
isError={isError}
isLoading={isLoading}
/>
)}
</div>
);
}

@ -6,6 +6,7 @@ import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/20/solid';
import { JobType } from '@prisma/client';
import { Button } from '@tih/ui';
import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics';
import type { BreadcrumbStep } from '~/components/offers/Breadcrumb';
import { Breadcrumbs } from '~/components/offers/Breadcrumb';
import BackgroundForm from '~/components/offers/offersSubmission/submissionForm/BackgroundForm';
@ -27,6 +28,7 @@ import { trpc } from '~/utils/trpc';
const defaultOfferValues = {
comments: '',
companyId: '',
jobTitle: '',
jobType: JobType.FULLTIME,
location: '',
monthYearReceived: {
@ -39,11 +41,38 @@ const defaultOfferValues = {
export const defaultFullTimeOfferValues = {
...defaultOfferValues,
jobType: JobType.FULLTIME,
offersFullTime: {
baseSalary: {
currency: 'SGD',
value: null,
},
bonus: {
currency: 'SGD',
value: null,
},
level: '',
stocks: {
currency: 'SGD',
value: null,
},
totalCompensation: {
currency: 'SGD',
value: null,
},
},
};
export const defaultInternshipOfferValues = {
...defaultOfferValues,
jobType: JobType.INTERN,
offersIntern: {
internshipCycle: null,
monthlySalary: {
currency: 'SGD',
value: null,
},
startYear: null,
},
};
const defaultOfferProfileValues = {
@ -73,6 +102,7 @@ export default function OffersSubmissionForm({
token: editToken,
});
const [isSubmitted, setIsSubmitted] = useState(false);
const { event: gaEvent } = useGoogleAnalytics();
const router = useRouter();
const pageRef = useRef<HTMLDivElement>(null);
@ -82,7 +112,11 @@ export default function OffersSubmissionForm({
defaultValues: initialOfferProfileValues,
mode: 'all',
});
const { handleSubmit, trigger } = formMethods;
const {
handleSubmit,
trigger,
formState: { isSubmitting, isSubmitSuccessful },
} = formMethods;
const generateAnalysisMutation = trpc.useMutation(
['offers.analysis.generate'],
@ -150,7 +184,7 @@ export default function OffersSubmissionForm({
const onSubmit: SubmitHandler<OffersProfileFormData> = async (data) => {
const result = await trigger();
if (!result) {
if (!result || isSubmitting || isSubmitSuccessful) {
return;
}
@ -183,6 +217,11 @@ export default function OffersSubmissionForm({
} else {
createOrUpdateMutation.mutate({ background, offers });
}
gaEvent({
action: 'offers.submit_profile',
category: 'submission',
label: 'Submit profile',
});
};
useEffect(() => {
@ -198,6 +237,32 @@ export default function OffersSubmissionForm({
scrollToTop();
}, [step]);
useEffect(() => {
const warningText =
'Leave this page? Changes that you made will not be saved.';
const handleWindowClose = (e: BeforeUnloadEvent) => {
e.preventDefault();
return (e.returnValue = warningText);
};
const handleRouteChange = (url: string) => {
if (url.includes('/offers/submit/result')) {
return;
}
if (window.confirm(warningText)) {
return;
}
router.events.emit('routeChangeError');
throw 'routeChange aborted.';
};
window.addEventListener('beforeunload', handleWindowClose);
router.events.on('routeChangeStart', handleRouteChange);
return () => {
window.removeEventListener('beforeunload', handleWindowClose);
router.events.off('routeChangeStart', handleRouteChange);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div ref={pageRef} className="fixed h-full w-full overflow-y-scroll">
<div className="mb-20 flex justify-center">
@ -210,7 +275,7 @@ export default function OffersSubmissionForm({
/>
</div>
<FormProvider {...formMethods}>
<form onSubmit={handleSubmit(onSubmit)}>
<form className="text-sm" onSubmit={handleSubmit(onSubmit)}>
{steps[step]}
{/* <pre>{JSON.stringify(formMethods.watch(), null, 2)}</pre> */}
{step === 0 && (
@ -220,7 +285,14 @@ export default function OffersSubmissionForm({
icon={ArrowRightIcon}
label="Next"
variant="secondary"
onClick={() => goToNextStep(step)}
onClick={() => {
goToNextStep(step);
gaEvent({
action: 'offers.profile_submission_navigate_next',
category: 'submission',
label: 'Navigate next',
});
}}
/>
</div>
)}
@ -230,9 +302,22 @@ export default function OffersSubmissionForm({
icon={ArrowLeftIcon}
label="Previous"
variant="secondary"
onClick={() => setStep(step - 1)}
onClick={() => {
setStep(step - 1);
gaEvent({
action: 'offers.profile_submission_navigation_back',
category: 'submission',
label: 'Navigate back',
});
}}
/>
<Button
disabled={isSubmitting || isSubmitSuccessful}
isLoading={isSubmitting || isSubmitSuccessful}
label="Submit"
type="submit"
variant="primary"
/>
<Button label="Submit" type="submit" variant="primary" />{' '}
</div>
)}
</form>

@ -11,6 +11,8 @@ import {
} from '~/components/offers/constants';
import type { BackgroundPostData } from '~/components/offers/types';
import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead';
import type { JobTitleType } from '~/components/shared/JobTitles';
import { getLabelForJobTitleType } from '~/components/shared/JobTitles';
import JobTitlesTypeahead from '~/components/shared/JobTitlesTypahead';
import {
@ -92,21 +94,47 @@ function FullTimeJobFields() {
background: BackgroundPostData;
}>();
const experiencesField = formState.errors.background?.experiences?.[0];
const watchJobTitle = useWatch({
name: 'background.experiences.0.title',
});
const watchCompanyId = useWatch({
name: 'background.experiences.0.companyId',
});
const watchCompanyName = useWatch({
name: 'background.experiences.0.companyName',
});
return (
<>
<div className="mb-5 grid grid-cols-2 space-x-3">
<div>
<JobTitlesTypeahead
onSelect={({ value }) =>
setValue(`background.experiences.0.title`, value)
value={{
id: watchJobTitle,
label: getLabelForJobTitleType(watchJobTitle as JobTitleType),
value: watchJobTitle,
}}
onSelect={(option) => {
if (option) {
setValue('background.experiences.0.title', option.value);
}
}}
/>
</div>
<div>
<CompaniesTypeahead
onSelect={({ value }) =>
setValue(`background.experiences.0.companyId`, value)
value={{
id: watchCompanyId,
label: watchCompanyName,
value: watchCompanyId,
}}
onSelect={(option) => {
if (option) {
setValue('background.experiences.0.companyId', option.value);
setValue('background.experiences.0.companyName', option.label);
}
}}
/>
</div>
</div>
@ -173,21 +201,46 @@ function InternshipJobFields() {
}>();
const experiencesField = formState.errors.background?.experiences?.[0];
const watchJobTitle = useWatch({
name: 'background.experiences.0.title',
});
const watchCompanyId = useWatch({
name: 'background.experiences.0.companyId',
});
const watchCompanyName = useWatch({
name: 'background.experiences.0.companyName',
});
return (
<>
<div className="mb-5 grid grid-cols-2 space-x-3">
<div>
<JobTitlesTypeahead
onSelect={({ value }) =>
setValue(`background.experiences.0.title`, value)
value={{
id: watchJobTitle,
label: getLabelForJobTitleType(watchJobTitle as JobTitleType),
value: watchJobTitle,
}}
onSelect={(option) => {
if (option) {
setValue('background.experiences.0.title', option.value);
}
}}
/>
</div>
<div>
<CompaniesTypeahead
onSelect={({ value }) =>
setValue(`background.experiences.0.companyId`, value)
value={{
id: watchCompanyId,
label: watchCompanyName,
value: watchCompanyId,
}}
onSelect={(option) => {
if (option) {
setValue('background.experiences.0.companyId', option.value);
setValue('background.experiences.0.companyName', option.label);
}
}}
/>
</div>
</div>

@ -13,6 +13,8 @@ import { JobType } from '@prisma/client';
import { Button, Dialog } from '@tih/ui';
import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead';
import type { JobTitleType } from '~/components/shared/JobTitles';
import { getLabelForJobTitleType } from '~/components/shared/JobTitles';
import JobTitlesTypeahead from '~/components/shared/JobTitlesTypahead';
import {
@ -51,6 +53,15 @@ function FullTimeOfferDetailsForm({
}>();
const offerFields = formState.errors.offers?.[index];
const watchJobTitle = useWatch({
name: `offers.${index}.offersFullTime.title`,
});
const watchCompanyId = useWatch({
name: `offers.${index}.companyId`,
});
const watchCompanyName = useWatch({
name: `offers.${index}.companyName`,
});
const watchCurrency = useWatch({
name: `offers.${index}.offersFullTime.totalCompensation.currency`,
});
@ -70,9 +81,16 @@ function FullTimeOfferDetailsForm({
<div>
<JobTitlesTypeahead
required={true}
onSelect={({ value }) =>
setValue(`offers.${index}.offersFullTime.title`, value)
value={{
id: watchJobTitle,
label: getLabelForJobTitleType(watchJobTitle as JobTitleType),
value: watchJobTitle,
}}
onSelect={(option) => {
if (option) {
setValue(`offers.${index}.offersFullTime.title`, option.value);
}
}}
/>
</div>
<FormTextInput
@ -89,9 +107,17 @@ function FullTimeOfferDetailsForm({
<div>
<CompaniesTypeahead
required={true}
onSelect={({ value }) =>
setValue(`offers.${index}.companyId`, value)
value={{
id: watchCompanyId,
label: watchCompanyName,
value: watchCompanyId,
}}
onSelect={(option) => {
if (option) {
setValue(`offers.${index}.companyId`, option.value);
setValue(`offers.${index}.companyName`, option.label);
}
}}
/>
</div>
<FormSelect
@ -268,18 +294,34 @@ function InternshipOfferDetailsForm({
const { register, formState, setValue } = useFormContext<{
offers: Array<OfferFormData>;
}>();
const offerFields = formState.errors.offers?.[index];
const watchJobTitle = useWatch({
name: `offers.${index}.offersIntern.title`,
});
const watchCompanyId = useWatch({
name: `offers.${index}.companyId`,
});
const watchCompanyName = useWatch({
name: `offers.${index}.companyName`,
});
return (
<div className="my-5 rounded-lg border border-slate-200 px-10 py-5">
<div className="mb-5 grid grid-cols-2 space-x-3">
<div>
<JobTitlesTypeahead
required={true}
onSelect={({ value }) =>
setValue(`offers.${index}.offersIntern.title`, value)
value={{
id: watchJobTitle,
label: getLabelForJobTitleType(watchJobTitle as JobTitleType),
value: watchJobTitle,
}}
onSelect={(option) => {
if (option) {
setValue(`offers.${index}.offersIntern.title`, option.value);
}
}}
/>
</div>
</div>
@ -287,9 +329,17 @@ function InternshipOfferDetailsForm({
<div>
<CompaniesTypeahead
required={true}
onSelect={({ value }) =>
setValue(`offers.${index}.companyId`, value)
value={{
id: watchCompanyId,
label: watchCompanyName,
value: watchCompanyId,
}}
onSelect={(option) => {
if (option) {
setValue(`offers.${index}.companyId`, option.value);
setValue(`offers.${index}.companyName`, option.label);
}
}}
/>
</div>
<FormSelect

@ -7,6 +7,7 @@ import {
import { HorizontalDivider } from '@tih/ui';
import type { OfferDisplayData } from '~/components/offers/types';
import { JobTypeLabel } from '~/components/offers/types';
type Props = Readonly<{
offer: OfferDisplayData;
@ -20,6 +21,7 @@ export default function OfferCard({
duration,
jobTitle,
jobLevel,
jobType,
location,
receivedMonth,
totalCompensation,
@ -34,13 +36,18 @@ export default function OfferCard({
<div className="flex justify-between px-8">
<div className="flex flex-col">
<div className="flex flex-row">
<BuildingOffice2Icon className="mr-1 h-5" />
<span>
<BuildingOffice2Icon className="mr-3 h-5" />
</span>
<span className="font-bold">
{location ? `${companyName}, ${location}` : companyName}
</span>
</div>
<div className="ml-6 flex flex-row">
<p>{jobLevel ? `${jobTitle}, ${jobLevel}` : jobTitle}</p>
<div className="ml-8 flex flex-row">
<p>
{jobLevel ? `${jobTitle}, ${jobLevel}` : jobTitle}{' '}
{jobType && `(${JobTypeLabel[jobType]})`}
</p>
</div>
</div>
{!duration && receivedMonth && (
@ -74,15 +81,19 @@ export default function OfferCard({
<div className="flex flex-col py-2">
{(totalCompensation || monthlySalary) && (
<div className="flex flex-row">
<CurrencyDollarIcon className="mr-1 h-5" />
<span>
<CurrencyDollarIcon className="mr-3 h-5" />
</span>
<span>
<p>
{totalCompensation && `TC: ${totalCompensation}`}
{monthlySalary && `Monthly Salary: ${monthlySalary}`}
</p>
</span>
</div>
)}
{totalCompensation && (
<div className="ml-6 flex flex-row font-light">
<div className="ml-8 flex flex-row font-light">
<p>
Base / year: {base} Stocks / year: {stocks} Bonus / year:{' '}
{bonus}
@ -93,7 +104,9 @@ export default function OfferCard({
{negotiationStrategy && (
<div className="flex flex-col py-2">
<div className="flex flex-row">
<span>
<ScaleIcon className="h-5 w-5" />
</span>
<span className="overflow-wrap ml-2">
"{negotiationStrategy}"
</span>
@ -103,7 +116,9 @@ export default function OfferCard({
{otherComment && (
<div className="flex flex-col py-2">
<div className="flex flex-row">
<span>
<ChatBubbleBottomCenterTextIcon className="h-5 w-5" />
</span>
<span className="overflow-wrap ml-2">"{otherComment}"</span>
</div>
</div>

@ -9,13 +9,17 @@ import {
useToast,
} from '@tih/ui';
import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics';
import ExpandableCommentCard from '~/components/offers/profile/comments/ExpandableCommentCard';
import Tooltip from '~/components/offers/util/Tooltip';
import { copyProfileLink } from '~/utils/offers/link';
import { trpc } from '~/utils/trpc';
import type { OffersDiscussion, Reply } from '~/types/offers';
import 'react-popper-tooltip/dist/styles.css';
type ProfileHeaderProps = Readonly<{
isDisabled: boolean;
isEditable: boolean;
@ -37,6 +41,7 @@ export default function ProfileComments({
const [currentReply, setCurrentReply] = useState<string>('');
const [replies, setReplies] = useState<Array<Reply>>();
const { showToast } = useToast();
const { event: gaEvent } = useGoogleAnalytics();
const commentsQuery = trpc.useQuery(
['offers.comments.getComments', { profileId }],
@ -107,6 +112,7 @@ export default function ProfileComments({
<div className="m-4 h-full">
<div className="flex-end flex justify-end space-x-4">
{isEditable && (
<Tooltip tooltipContent="Copy this link to edit your profile later">
<Button
addonPosition="start"
disabled={isDisabled}
@ -117,13 +123,20 @@ export default function ProfileComments({
variant="secondary"
onClick={() => {
copyProfileLink(profileId, token);
gaEvent({
action: 'offers.copy_profile_edit_link',
category: 'engagement',
label: 'Copy Profile Edit Link',
});
showToast({
title: `Profile edit link copied to clipboard!`,
variant: 'success',
});
}}
/>
</Tooltip>
)}
<Tooltip tooltipContent="Share this profile with your friends">
<Button
addonPosition="start"
disabled={isDisabled}
@ -134,12 +147,18 @@ export default function ProfileComments({
variant="secondary"
onClick={() => {
copyProfileLink(profileId);
gaEvent({
action: 'offers.copy_profile_public_link',
category: 'engagement',
label: 'Copy Profile Public Link',
});
showToast({
title: `Public profile link copied to clipboard!`,
variant: 'success',
});
}}
/>
</Tooltip>
</div>
<h2 className="mt-2 mb-6 text-2xl font-bold">Discussions</h2>
{isEditable || session?.user?.name ? (
@ -173,7 +192,13 @@ export default function ProfileComments({
<HorizontalDivider />
</div>
) : (
<div>Please log in before commenting on this profile.</div>
<Button
className="mb-5"
display="block"
href="/api/auth/signin"
label="Sign in to join discussion"
variant="tertiary"
/>
)}
<div className="h-full overflow-y-auto">
<div className="h-content mb-96 w-full">

@ -115,7 +115,15 @@ function ProfileAnalysis({
return (
<div className="mx-8 my-4">
<OfferAnalysis allAnalysis={analysis} isError={false} isLoading={false} />
{!analysis ? (
<p>No analysis available.</p>
) : (
<OfferAnalysis
allAnalysis={analysis}
isError={false}
isLoading={false}
/>
)}
{isEditable && (
<div className="flex justify-end">
<Button

@ -1,26 +1,32 @@
import { useRouter } from 'next/router';
import { useState } from 'react';
import {
BookmarkIcon as BookmarkIconOutline,
BuildingOffice2Icon,
CalendarDaysIcon,
PencilSquareIcon,
TrashIcon,
} from '@heroicons/react/24/outline';
import { Button, Dialog, Spinner, Tabs } from '@tih/ui';
import { BookmarkIcon as BookmarkIconSolid } from '@heroicons/react/24/solid';
import { Button, Dialog, Spinner, Tabs, useToast } from '@tih/ui';
import ProfilePhotoHolder from '~/components/offers/profile/ProfilePhotoHolder';
import type { BackgroundDisplayData } from '~/components/offers/types';
import { JobTypeLabel } from '~/components/offers/types';
import { getProfileEditPath } from '~/utils/offers/link';
import { trpc } from '~/utils/trpc';
import type { ProfileDetailTab } from '../constants';
import { profileDetailTabs } from '../constants';
import Tooltip from '../util/Tooltip';
type ProfileHeaderProps = Readonly<{
background?: BackgroundDisplayData;
handleDelete: () => void;
isEditable: boolean;
isLoading: boolean;
isSaved?: boolean;
selectedTab: ProfileDetailTab;
setSelectedTab: (tab: ProfileDetailTab) => void;
}>;
@ -30,28 +36,91 @@ export default function ProfileHeader({
handleDelete,
isEditable,
isLoading,
isSaved = false,
selectedTab,
setSelectedTab,
}: ProfileHeaderProps) {
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [saved, setSaved] = useState(isSaved);
const router = useRouter();
const trpcContext = trpc.useContext();
const { offerProfileId = '', token = '' } = router.query;
const { showToast } = useToast();
const handleEditClick = () => {
router.push(getProfileEditPath(offerProfileId as string, token as string));
};
const saveMutation = trpc.useMutation(
['offers.user.profile.addToUserProfile'],
{
onError: () => {
showToast({
title: `Failed to saved to dashboard!`,
variant: 'failure',
});
},
onSuccess: () => {
setSaved(true);
showToast({
title: `Saved to dashboard!`,
variant: 'success',
});
},
},
);
const unsaveMutation = trpc.useMutation(
['offers.user.profile.removeFromUserProfile'],
{
onError: () => {
showToast({
title: `Failed to saved to dashboard!`,
variant: 'failure',
});
},
onSuccess: () => {
setSaved(false);
showToast({
title: `Removed from dashboard!`,
variant: 'success',
});
trpcContext.invalidateQueries(['offers.profile.listOne']);
},
},
);
const toggleSaved = () => {
if (saved) {
unsaveMutation.mutate({ profileId: offerProfileId as string });
} else {
saveMutation.mutate({
profileId: offerProfileId as string,
token: token as string,
});
}
};
function renderActionList() {
return (
<div className="space-x-2">
{/* <Button
disabled={isLoading}
icon={BookmarkSquareIcon}
<div className="flex justify-center space-x-2">
<Tooltip
tooltipContent={
isSaved ? 'Remove from account' : 'Save to your account'
}>
<Button
disabled={
isLoading || saveMutation.isLoading || unsaveMutation.isLoading
}
icon={saved ? BookmarkIconSolid : BookmarkIconOutline}
isLabelHidden={true}
label="Save to user account"
isLoading={saveMutation.isLoading || unsaveMutation.isLoading}
label={saved ? 'Remove from account' : 'Save to your account'}
size="md"
variant="tertiary"
/> */}
onClick={toggleSaved}
/>
</Tooltip>
<Tooltip tooltipContent="Edit profile">
<Button
disabled={isLoading}
icon={PencilSquareIcon}
@ -61,6 +130,8 @@ export default function ProfileHeader({
variant="tertiary"
onClick={handleEditClick}
/>
</Tooltip>
<Tooltip tooltipContent="Delete profile">
<Button
disabled={isLoading}
icon={TrashIcon}
@ -70,6 +141,7 @@ export default function ProfileHeader({
variant="tertiary"
onClick={() => setIsDialogOpen(true)}
/>
</Tooltip>
{isDialogOpen && (
<Dialog
isShown={isDialogOpen}
@ -95,8 +167,8 @@ export default function ProfileHeader({
title="Are you sure you want to delete this offer profile?"
onClose={() => setIsDialogOpen(false)}>
<div>
All comments will be gone. You will not be able to access or
recover it.
All information and comments in this offer profile will be
deleted. You will not be able to access or recover them.
</div>
</Dialog>
)}
@ -144,7 +216,11 @@ export default function ProfileHeader({
<span>
{`${experiences[0].companyName || ''} ${
experiences[0].jobLevel || ''
} ${experiences[0].jobTitle || ''}`}
} ${experiences[0].jobTitle || ''} ${
experiences[0].jobType
? `(${JobTypeLabel[experiences[0].jobType]})`
: ''
}`}
</span>
</div>
)}

@ -1,7 +1,10 @@
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 {
ChatBubbleBottomCenterIcon,
TrashIcon,
} from '@heroicons/react/24/outline';
import { Button, Dialog, HorizontalDivider, TextArea, useToast } from '@tih/ui';
import { timeSinceNow } from '~/utils/offers/time';
@ -25,12 +28,15 @@ export default function CommentCard({
handleExpanded,
isExpanded,
profileId,
token = '',
replyLength = 0,
token = '',
}: Props) {
const { data: session, status } = useSession();
const [isReplying, setIsReplying] = useState(false);
const [currentReply, setCurrentReply] = useState<string>('');
const [isDialogOpen, setIsDialogOpen] = useState(false);
const { showToast } = useToast();
const deletable: boolean = token.length > 0 || user?.id === session?.user?.id;
const trpcContext = trpc.useContext();
const createCommentMutation = trpc.useMutation(['offers.comments.create'], {
@ -91,6 +97,33 @@ export default function CommentCard({
}
}
const deleteCommentMutation = trpc.useMutation(['offers.comments.delete'], {
onSuccess() {
trpcContext.invalidateQueries([
'offers.comments.getComments',
{ profileId },
]);
},
});
function handleDelete() {
deleteCommentMutation.mutate(
{
id,
profileId,
token,
userId: session?.user?.id,
},
{
onError: () => {
showToast({ title: `Server Error`, variant: 'failure' });
},
onSuccess: () => {
showToast({ title: `Deleted comment`, variant: 'success' });
},
},
);
}
return (
<>
<div className="flex pl-2">
@ -122,6 +155,47 @@ export default function CommentCard({
/>
</div>
)}
{deletable && (
<>
<Button
disabled={deleteCommentMutation.isLoading}
icon={TrashIcon}
isLabelHidden={true}
isLoading={deleteCommentMutation.isLoading}
label="Delete"
size="sm"
variant="tertiary"
onClick={() => setIsDialogOpen(true)}
/>
{isDialogOpen && (
<Dialog
isShown={isDialogOpen}
primaryButton={
<Button
display="block"
label="Delete"
variant="primary"
onClick={() => {
setIsDialogOpen(false);
handleDelete();
}}
/>
}
secondaryButton={
<Button
display="block"
label="Cancel"
variant="tertiary"
onClick={() => setIsDialogOpen(false)}
/>
}
title="Are you sure you want to delete this comment?"
onClose={() => setIsDialogOpen(false)}>
<div>You cannot undo this operation.</div>
</Dialog>
)}
</>
)}
</div>
{!disableReply && isReplying && (
<div className="mt-2 mr-2">
@ -137,7 +211,9 @@ export default function CommentCard({
<div className="w-fit">
<Button
disabled={
!currentReply.length || createCommentMutation.isLoading
!currentReply.length ||
createCommentMutation.isLoading ||
deleteCommentMutation.isLoading
}
display="block"
isLabelHidden={false}

@ -35,6 +35,7 @@ export default function ExpandableCommentCard({
comment={reply}
disableReply={true}
profileId={profileId}
token={token}
/>
))}
</div>

@ -27,7 +27,7 @@ export default function OfferTableRow({
<td className="py-4 px-6">{formatDate(monthYearReceived)}</td>
<td
className={clsx(
'sticky right-0 bg-white py-4 px-6 drop-shadow md:drop-shadow-none',
'sticky right-0 py-4 px-6 drop-shadow md:drop-shadow-none',
)}>
<Link
className="text-primary-600 dark:text-primary-500 font-medium hover:underline"

@ -2,6 +2,7 @@ import clsx from 'clsx';
import { useEffect, useState } from 'react';
import { DropdownMenu, Spinner } from '@tih/ui';
import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics';
import OffersTablePagination from '~/components/offers/table/OffersTablePagination';
import {
OfferTableFilterOptions,
@ -39,6 +40,7 @@ export default function OffersTable({
const [selectedFilter, setSelectedFilter] = useState(
OfferTableFilterOptions[0].value,
);
const { event: gaEvent } = useGoogleAnalytics();
useEffect(() => {
setPagination({
currentPage: 0,
@ -90,13 +92,18 @@ export default function OffersTable({
label={itemLabel}
onClick={() => {
setSelectedTab(value);
gaEvent({
action: `offers.table_filter_yoe_category_${value}`,
category: 'engagement',
label: 'Filter by YOE category',
});
}}
/>
))}
</DropdownMenu>
<div className="divide-x-slate-200 flex items-center space-x-4 divide-x">
<div className="justify-left flex items-center space-x-2">
<span>All offers in</span>
<span>View all offers in</span>
<CurrencySelector
handleCurrencyChange={(value: string) => setCurrency(value)}
selectedCurrency={currency}
@ -159,7 +166,7 @@ export default function OffersTable({
}
const handlePageChange = (currPage: number) => {
if (0 < currPage && currPage < pagination.numOfPages) {
if (0 <= currPage && currPage < pagination.numOfPages) {
setPagination({ ...pagination, currentPage: currPage });
}
};

@ -45,6 +45,7 @@ export type BackgroundPostData = {
type ExperiencePostData = {
companyId?: string | null;
companyName?: string | null;
durationInMonths?: number | null;
id?: string;
jobType?: string | null;
@ -76,6 +77,7 @@ type SpecificYoe = SpecificYoePostData;
export type OfferPostData = {
comments: string;
companyId: string;
companyName?: string;
id?: string;
jobType: JobType;
location: string;
@ -129,6 +131,7 @@ export type OfferDisplayData = {
id?: string;
jobLevel?: string | null;
jobTitle?: string | null;
jobType?: JobType;
location?: string | null;
monthlySalary?: string | null;
negotiationStrategy?: string | null;

@ -0,0 +1,42 @@
import type { ReactNode } from 'react';
import { usePopperTooltip } from 'react-popper-tooltip';
import type { Placement } from '@popperjs/core';
type TooltipProps = Readonly<{
children: ReactNode;
placement?: Placement;
tooltipContent: ReactNode;
}>;
export default function Tooltip({
children,
tooltipContent,
placement = 'bottom-start',
}: TooltipProps) {
const {
getTooltipProps,
getArrowProps,
setTooltipRef,
setTriggerRef,
visible,
} = usePopperTooltip({
interactive: true,
placement,
trigger: ['focus', 'hover'],
});
return (
<>
<div ref={setTriggerRef}>{children}</div>
{visible && (
<div
ref={setTooltipRef}
{...getTooltipProps({
className: 'tooltip-container ',
})}>
{tooltipContent}
<div {...getArrowProps({ className: 'tooltip-arrow' })} />
</div>
)}
</>
);
}

@ -28,7 +28,7 @@ export default function ContributeQuestionCard({
};
return (
<div>
<div className="w-full">
<button
className="flex flex-col items-stretch justify-center gap-2 rounded-md border border-slate-300 bg-white p-4 text-left hover:bg-slate-100"
type="button"
@ -72,12 +72,12 @@ export default function ContributeQuestionCard({
Contribute
</h1>
</div>
</button>
<ContributeQuestionDialog
show={showDraftDialog}
onCancel={handleDraftDialogCancel}
onSubmit={onSubmit}
/>
</button>
</div>
);
}

@ -1,6 +1,6 @@
import { Fragment, useState } from 'react';
import { Dialog, Transition } from '@headlessui/react';
import { HorizontalDivider } from '@tih/ui';
import { HorizontalDivider, useToast } from '@tih/ui';
import DiscardDraftDialog from './DiscardDraftDialog';
import type { ContributeQuestionFormProps } from './forms/ContributeQuestionForm';
@ -21,6 +21,8 @@ export default function ContributeQuestionDialog({
}: ContributeQuestionDialogProps) {
const [showDiscardDialog, setShowDiscardDialog] = useState(false);
const { showToast } = useToast();
const handleDraftDiscard = () => {
setShowDiscardDialog(false);
onCancel();
@ -33,13 +35,7 @@ export default function ContributeQuestionDialog({
return (
<div>
<Transition.Root as={Fragment} show={show}>
<Dialog
as="div"
className="relative z-10"
onClose={() => {
// Todo: save state
onCancel();
}}>
<Dialog as="div" className="relative z-10" onClose={onCancel}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
@ -75,6 +71,14 @@ export default function ContributeQuestionDialog({
<div className="mt-2">
<ContributeQuestionForm
onDiscard={() => setShowDiscardDialog(true)}
onSimilarQuestionFound={() => {
onCancel();
showToast({
title:
'Your response has been recorded. Draft discarded.',
variant: 'success',
});
}}
onSubmit={(data) => {
onSubmit(data);
onCancel();

@ -64,12 +64,16 @@ export default function LandingComponent({
return (
<main className="flex flex-1 flex-col items-center overflow-y-auto bg-white">
<div className="flex flex-1 flex-col items-start justify-center gap-12 px-4">
<header className="flex flex-col items-start gap-4">
<div className="flex items-center justify-center">
<h1 className="text-3xl font-semibold text-slate-900">
<header className="flex flex-col items-start gap-16">
<div className="flex flex-col items-center self-stretch">
<img
alt="Questions Bank"
className="h-40 w-40"
src="/bank-logo.png"
/>
<h1 className="text-4xl font-bold text-slate-900 text-center">
Tech Interview Question Bank
</h1>
<img alt="app logo" className="h-20 w-20" src="/logo.svg"></img>
</div>
<p className="mb-2 max-w-lg text-5xl font-semibold text-slate-900 sm:max-w-3xl">
Know the{' '}
@ -97,6 +101,7 @@ export default function LandingComponent({
isLabelHidden={true}
value={company}
onSelect={(value) => {
// @ts-ignore TODO(questions): handle potentially null value.
handleChangeCompany(value);
}}
/>
@ -105,6 +110,7 @@ export default function LandingComponent({
isLabelHidden={true}
value={location}
onSelect={(value) => {
// @ts-ignore TODO(questions): handle potentially null value.
handleChangeLocation(value);
}}
/>

@ -0,0 +1,25 @@
import type { UseInfiniteQueryResult } from 'react-query';
import { Button } from '@tih/ui';
export type PaginationLoadMoreButtonProps = {
query: UseInfiniteQueryResult;
};
export default function PaginationLoadMoreButton(
props: PaginationLoadMoreButtonProps,
) {
const {
query: { hasNextPage, isFetchingNextPage, fetchNextPage },
} = props;
return (
<Button
disabled={!hasNextPage || isFetchingNextPage}
isLoading={isFetchingNextPage}
label={hasNextPage ? 'Load more' : 'Nothing more to load'}
variant="tertiary"
onClick={() => {
fetchNextPage();
}}
/>
);
}

@ -2,40 +2,19 @@ import {
AdjustmentsHorizontalIcon,
MagnifyingGlassIcon,
} from '@heroicons/react/24/outline';
import { Button, Select, TextInput } from '@tih/ui';
import { Button, TextInput } from '@tih/ui';
export type SortOption<Value> = {
label: string;
value: Value;
};
type SortOrderProps<SortOrder> = {
onSortOrderChange?: (sortValue: SortOrder) => void;
sortOrderOptions: ReadonlyArray<SortOption<SortOrder>>;
sortOrderValue: SortOrder;
};
import type { SortOptionsSelectProps } from './SortOptionsSelect';
import SortOptionsSelect from './SortOptionsSelect';
type SortTypeProps<SortType> = {
onSortTypeChange?: (sortType: SortType) => void;
sortTypeOptions: ReadonlyArray<SortOption<SortType>>;
sortTypeValue: SortType;
};
export type QuestionSearchBarProps<SortType, SortOrder> =
SortOrderProps<SortOrder> &
SortTypeProps<SortType> & {
export type QuestionSearchBarProps = SortOptionsSelectProps & {
onFilterOptionsToggle: () => void;
};
};
export default function QuestionSearchBar<SortType, SortOrder>({
onSortOrderChange,
sortOrderOptions,
sortOrderValue,
onSortTypeChange,
sortTypeOptions,
sortTypeValue,
export default function QuestionSearchBar({
onFilterOptionsToggle,
}: QuestionSearchBarProps<SortType, SortOrder>) {
...sortOptionsSelectProps
}: QuestionSearchBarProps) {
return (
<div className="flex flex-col items-stretch gap-x-4 gap-y-2 lg:flex-row lg:items-end">
<div className="flex-1 ">
@ -48,38 +27,7 @@ export default function QuestionSearchBar<SortType, SortOrder>({
/>
</div>
<div className="flex items-end justify-end gap-4">
<div className="flex items-center gap-2">
<Select
display="inline"
label="Sort by"
options={sortTypeOptions}
value={sortTypeValue}
onChange={(value) => {
const chosenOption = sortTypeOptions.find(
(option) => String(option.value) === value,
);
if (chosenOption) {
onSortTypeChange?.(chosenOption.value);
}
}}
/>
</div>
<div className="flex items-center gap-2">
<Select
display="inline"
label="Order by"
options={sortOrderOptions}
value={sortOrderValue}
onChange={(value) => {
const chosenOption = sortOrderOptions.find(
(option) => String(option.value) === value,
);
if (chosenOption) {
onSortOrderChange?.(chosenOption.value);
}
}}
/>
</div>
<SortOptionsSelect {...sortOptionsSelectProps} />
<div className="lg:hidden">
<Button
addonPosition="start"

@ -10,6 +10,9 @@ const navigation: ProductNavigationItems = [
const config = {
// TODO: Change this to your own GA4 measurement ID.
googleAnalyticsMeasurementID: 'G-DBLZDQ2ZZN',
logo: (
<img alt="Questions Bank" className="h-8 w-auto" src="/bank-logo.png" />
),
navigation,
showGlobalNav: false,
title: 'Questions Bank',

@ -0,0 +1,69 @@
import { Select } from '~/../../../packages/ui/dist';
import { SORT_ORDERS, SORT_TYPES } from '~/utils/questions/constants';
import type { SortOrder, SortType } from '~/types/questions.d';
export type SortOption<Value> = {
label: string;
value: Value;
};
const sortTypeOptions = SORT_TYPES;
const sortOrderOptions = SORT_ORDERS;
type SortOrderProps<Order> = {
onSortOrderChange?: (sortValue: Order) => void;
sortOrderValue: Order;
};
type SortTypeProps<Type> = {
onSortTypeChange?: (sortType: Type) => void;
sortTypeValue: Type;
};
export type SortOptionsSelectProps = SortOrderProps<SortOrder> &
SortTypeProps<SortType>;
export default function SortOptionsSelect({
onSortOrderChange,
sortOrderValue,
onSortTypeChange,
sortTypeValue,
}: SortOptionsSelectProps) {
return (
<div className="flex items-end justify-end gap-4">
<div className="flex items-center gap-2">
<Select
display="inline"
label="Sort by"
options={sortTypeOptions}
value={sortTypeValue}
onChange={(value) => {
const chosenOption = sortTypeOptions.find(
(option) => String(option.value) === value,
);
if (chosenOption) {
onSortTypeChange?.(chosenOption.value);
}
}}
/>
</div>
<div className="flex items-center gap-2">
<Select
display="inline"
label="Order by"
options={sortOrderOptions}
value={sortOrderValue}
onChange={(value) => {
const chosenOption = sortOrderOptions.find(
(option) => String(option.value) === value,
);
if (chosenOption) {
onSortOrderChange?.(chosenOption.value);
}
}}
/>
</div>
</div>
);
}

@ -1,5 +1,5 @@
import clsx from 'clsx';
import { useState } from 'react';
import { useMemo, useState } from 'react';
import {
ChatBubbleBottomCenterTextIcon,
CheckIcon,
@ -18,6 +18,8 @@ import QuestionAggregateBadge from '../../QuestionAggregateBadge';
import QuestionTypeBadge from '../../QuestionTypeBadge';
import VotingButtons from '../../VotingButtons';
import type { CountryInfo } from '~/types/questions';
type UpvoteProps =
| {
showVoteButtons: true;
@ -51,13 +53,13 @@ type AnswerStatisticsProps =
type AggregateStatisticsProps =
| {
companies: Record<string, number>;
locations: Record<string, number>;
countries: Record<string, CountryInfo>;
roles: Record<string, number>;
showAggregateStatistics: true;
}
| {
companies?: never;
locations?: never;
countries?: never;
roles?: never;
showAggregateStatistics?: false;
};
@ -86,10 +88,12 @@ type ReceivedStatisticsProps =
type CreateEncounterProps =
| {
createEncounterButtonText: string;
onReceivedSubmit: (data: CreateQuestionEncounterData) => void;
showCreateEncounterButton: true;
}
| {
createEncounterButtonText?: never;
onReceivedSubmit?: never;
showCreateEncounterButton?: false;
};
@ -130,13 +134,14 @@ export default function BaseQuestionCard({
showAnswerStatistics,
showReceivedStatistics,
showCreateEncounterButton,
createEncounterButtonText,
showActionButton,
actionButtonLabel,
onActionButtonClick,
upvoteCount,
timestamp,
roles,
locations,
countries,
showHover,
onReceivedSubmit,
showDeleteButton,
@ -147,6 +152,22 @@ export default function BaseQuestionCard({
const [showReceivedForm, setShowReceivedForm] = useState(false);
const { handleDownvote, handleUpvote, vote } = useQuestionVote(questionId);
const hoverClass = showHover ? 'hover:bg-slate-50' : '';
const locations = useMemo(() => {
if (countries === undefined) {
return undefined;
}
const countryCount: Record<string, number> = {};
// Decompose countries
for (const country of Object.keys(countries)) {
const { total } = countries[country];
countryCount[country] = total;
}
return countryCount;
}, [countries]);
const cardContent = (
<>
{showVoteButtons && (
@ -168,7 +189,7 @@ export default function BaseQuestionCard({
variant="primary"
/>
<QuestionAggregateBadge
statistics={locations}
statistics={locations!}
variant="success"
/>
<QuestionAggregateBadge statistics={roles} variant="danger" />
@ -220,7 +241,7 @@ export default function BaseQuestionCard({
<Button
addonPosition="start"
icon={CheckIcon}
label="I received this too"
label={createEncounterButtonText}
size="sm"
variant="tertiary"
onClick={(event) => {

@ -3,10 +3,10 @@ import BaseQuestionCard from './BaseQuestionCard';
export type SimilarQuestionCardProps = Omit<
BaseQuestionCardProps & {
showActionButton: true;
showAggregateStatistics: false;
showActionButton: false;
showAggregateStatistics: true;
showAnswerStatistics: false;
showCreateEncounterButton: false;
showCreateEncounterButton: true;
showDeleteButton: false;
showHover: true;
showReceivedStatistics: false;
@ -22,26 +22,20 @@ export type SimilarQuestionCardProps = Omit<
| 'showHover'
| 'showReceivedStatistics'
| 'showVoteButtons'
> & {
onSimilarQuestionClick: () => void;
};
>;
export default function SimilarQuestionCard(props: SimilarQuestionCardProps) {
const { onSimilarQuestionClick, ...rest } = props;
return (
<BaseQuestionCard
actionButtonLabel="Yes, this is my question"
showActionButton={true}
showAggregateStatistics={false}
showActionButton={false}
showAggregateStatistics={true}
showAnswerStatistics={false}
showCreateEncounterButton={false}
showCreateEncounterButton={true}
showDeleteButton={false}
showHover={true}
showReceivedStatistics={false}
showVoteButtons={false}
onActionButtonClick={onSimilarQuestionClick}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{...(rest as any)}
{...props}
/>
);
}

@ -1,52 +1,99 @@
import { startOfMonth } from 'date-fns';
import { useEffect, useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { ArrowPathIcon } from '@heroicons/react/20/solid';
import type { QuestionsQuestionType } from '@prisma/client';
import type { TypeaheadOption } from '@tih/ui';
import { CheckboxInput } from '@tih/ui';
import { Button, HorizontalDivider, Select, TextArea } from '@tih/ui';
import { LOCATIONS, QUESTION_TYPES, ROLES } from '~/utils/questions/constants';
import { QUESTION_TYPES } from '~/utils/questions/constants';
import relabelQuestionAggregates from '~/utils/questions/relabelQuestionAggregates';
import {
useFormRegister,
useSelectRegister,
} from '~/utils/questions/useFormRegister';
import { trpc } from '~/utils/trpc';
import SimilarQuestionCard from '../card/question/SimilarQuestionCard';
import CompanyTypeahead from '../typeahead/CompanyTypeahead';
import LocationTypeahead from '../typeahead/LocationTypeahead';
import RoleTypeahead from '../typeahead/RoleTypeahead';
import type { Month } from '../../shared/MonthYearPicker';
import MonthYearPicker from '../../shared/MonthYearPicker';
import type { Location } from '~/types/questions';
export type ContributeQuestionData = {
company: string;
date: Date;
location: string;
location: Location & TypeaheadOption;
position: string;
questionContent: string;
questionType: QuestionsQuestionType;
role: string;
role: TypeaheadOption;
};
export type ContributeQuestionFormProps = {
onDiscard: () => void;
onSimilarQuestionFound: () => void;
onSubmit: (data: ContributeQuestionData) => void;
};
export default function ContributeQuestionForm({
onSubmit,
onDiscard,
onSimilarQuestionFound,
onSubmit,
}: ContributeQuestionFormProps) {
const {
control,
register: formRegister,
handleSubmit,
watch,
} = useForm<ContributeQuestionData>({
defaultValues: {
date: startOfMonth(new Date()),
},
});
const [contentToCheck, setContentToCheck] = useState('');
const { data: similarQuestions } = trpc.useQuery(
['questions.questions.getRelatedQuestions', { content: contentToCheck }],
{
keepPreviousData: true,
},
);
const utils = trpc.useContext();
const { mutateAsync: addEncounterAsync } = trpc.useMutation(
'questions.questions.encounters.user.create',
{
onSuccess: () => {
utils.invalidateQueries(
'questions.questions.encounters.getAggregatedEncounters',
);
utils.invalidateQueries('questions.questions.getQuestionById');
},
},
);
const questionContent = watch('questionContent');
const register = useFormRegister(formRegister);
const selectRegister = useSelectRegister(formRegister);
const [checkedSimilar, setCheckedSimilar] = useState<boolean>(false);
const handleCheckSimilarQuestions = (checked: boolean) => {
setCheckedSimilar(checked);
};
useEffect(() => {
if (questionContent !== contentToCheck) {
setCheckedSimilar(false);
}
}, [questionContent, contentToCheck]);
return (
<div className="flex flex-col justify-between gap-4">
<form
@ -68,7 +115,6 @@ export default function ContributeQuestionForm({
rows={5}
{...register('questionContent')}
/>
<HorizontalDivider />
<h2 className="text-md text-primary-800 font-semibold">
Additional information
</h2>
@ -79,14 +125,12 @@ export default function ContributeQuestionForm({
name="location"
render={({ field }) => (
<LocationTypeahead
{...field}
required={true}
onSelect={(option) => {
field.onChange(option.value);
// @ts-ignore TODO(questions): handle potentially null value.
field.onChange(option);
}}
{...field}
value={LOCATIONS.find(
(location) => location.value === field.value,
)}
/>
)}
/>
@ -116,9 +160,11 @@ export default function ContributeQuestionForm({
<Controller
control={control}
name="company"
render={({ field }) => (
render={({ field: { value: _, ...field } }) => (
<CompanyTypeahead
{...field}
required={true}
// @ts-ignore TODO(questions): handle potentially null value.
onSelect={({ id }) => {
field.onChange(id);
}}
@ -132,12 +178,12 @@ export default function ContributeQuestionForm({
name="role"
render={({ field }) => (
<RoleTypeahead
{...field}
required={true}
onSelect={(option) => {
field.onChange(option.value);
// @ts-ignore TODO(questions): handle potentially null value.
field.onChange(option);
}}
{...field}
value={ROLES.find((role) => role.value === field.value)}
/>
)}
/>
@ -146,12 +192,83 @@ export default function ContributeQuestionForm({
<div className="w-full">
<HorizontalDivider />
</div>
<h2
className="text-primary-900 mb-3
text-lg font-semibold
">
Are these questions the same as yours?
</h2>
<Button
addonPosition="start"
disabled={questionContent === contentToCheck}
icon={ArrowPathIcon}
label="Refresh similar questions"
variant="primary"
onClick={() => {
setContentToCheck(questionContent);
}}
/>
<div className="flex flex-col gap-y-2">
{similarQuestions?.map((question) => {
const { companyCounts, countryCounts, roleCounts } =
relabelQuestionAggregates(question.aggregatedQuestionEncounters);
return (
<SimilarQuestionCard
key={question.id}
companies={companyCounts}
content={question.content}
countries={countryCounts}
createEncounterButtonText="Yes, this is my question"
questionId={question.id}
roles={roleCounts}
timestamp={
question.seenAt.toLocaleDateString(undefined, {
month: 'short',
year: 'numeric',
}) ?? null
}
type={question.type}
onReceivedSubmit={async (data) => {
await addEncounterAsync({
cityId: data.cityId,
companyId: data.company,
countryId: data.countryId,
questionId: question.id,
role: data.role,
seenAt: data.seenAt,
stateId: data.stateId,
});
onSimilarQuestionFound();
}}
/>
);
})}
{similarQuestions?.length === 0 && (
<p className="font-semibold text-slate-900">
No similar questions found.
</p>
)}
</div>
<div
className="bg-primary-50 flex w-full justify-end gap-y-2 py-3 shadow-[0_0_0_100vmax_theme(colors.primary.50)]"
className="bg-primary-50 flex w-full justify-between gap-y-2 py-3 shadow-[0_0_0_100vmax_theme(colors.primary.50)]"
style={{
// Hack to make the background bleed outside the container
clipPath: 'inset(0 -100vmax)',
}}>
<div className="my-2 flex items-center sm:my-0">
<CheckboxInput
disabled={questionContent !== contentToCheck}
label={
questionContent !== contentToCheck
? 'I have checked that my question is new (Refresh similar questions to proceed)'
: 'I have checked that my question is new'
}
value={checkedSimilar}
onChange={handleCheckSimilarQuestions}
/>
</div>
<div className="flex gap-x-2">
<button
className="focus:ring-primary-500 inline-flex w-full justify-center rounded-md border border-slate-300 bg-white px-4 py-2 text-base font-medium text-slate-700 shadow-sm hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-offset-2 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"

@ -9,11 +9,15 @@ import CompanyTypeahead from '../typeahead/CompanyTypeahead';
import LocationTypeahead from '../typeahead/LocationTypeahead';
import RoleTypeahead from '../typeahead/RoleTypeahead';
import type { Location } from '~/types/questions';
export type CreateQuestionEncounterData = {
cityId?: string;
company: string;
location: string;
countryId: string;
role: string;
seenAt: Date;
stateId?: string;
};
export type CreateQuestionEncounterFormProps = {
@ -28,7 +32,9 @@ export default function CreateQuestionEncounterForm({
const [step, setStep] = useState(0);
const [selectedCompany, setSelectedCompany] = useState<string | null>(null);
const [selectedLocation, setSelectedLocation] = useState<string | null>(null);
const [selectedLocation, setSelectedLocation] = useState<Location | null>(
null,
);
const [selectedRole, setSelectedRole] = useState<string | null>(null);
const [selectedDate, setSelectedDate] = useState<Date>(
startOfMonth(new Date()),
@ -43,6 +49,7 @@ export default function CreateQuestionEncounterForm({
isLabelHidden={true}
placeholder="Other company"
suggestedCount={3}
// @ts-ignore TODO(questions): handle potentially null value.
onSelect={({ value: company }) => {
setSelectedCompany(company);
}}
@ -59,10 +66,11 @@ export default function CreateQuestionEncounterForm({
isLabelHidden={true}
placeholder="Other location"
suggestedCount={3}
onSelect={({ value: location }) => {
// @ts-ignore TODO(questions): handle potentially null value.
onSelect={(location) => {
setSelectedLocation(location);
}}
onSuggestionClick={({ value: location }) => {
onSuggestionClick={(location) => {
setSelectedLocation(location);
setStep(step + 1);
}}
@ -75,6 +83,7 @@ export default function CreateQuestionEncounterForm({
isLabelHidden={true}
placeholder="Other role"
suggestedCount={3}
// @ts-ignore TODO(questions): handle potentially null value.
onSelect={({ value: role }) => {
setSelectedRole(role);
}}
@ -127,11 +136,14 @@ export default function CreateQuestionEncounterForm({
selectedRole &&
selectedDate
) {
const { cityId, stateId, countryId } = selectedLocation;
onSubmit({
cityId,
company: selectedCompany,
location: selectedLocation,
countryId,
role: selectedRole,
seenAt: selectedDate,
stateId,
});
}
}}

@ -8,13 +8,16 @@ import type { RequireAllOrNone } from '~/utils/questions/RequireAllOrNone';
type TypeaheadProps = ComponentProps<typeof Typeahead>;
type TypeaheadOption = TypeaheadProps['options'][number];
export type ExpandedTypeaheadProps = RequireAllOrNone<{
export type ExpandedTypeaheadProps = Omit<TypeaheadProps, 'onSelect'> &
RequireAllOrNone<{
clearOnSelect?: boolean;
filterOption: (option: TypeaheadOption) => boolean;
onSuggestionClick: (option: TypeaheadOption) => void;
suggestedCount: number;
}> &
TypeaheadProps;
}> & {
onChange?: unknown; // Workaround: This prop is here just to absorb the onChange returned react-hook-form
onSelect: (option: TypeaheadOption) => void;
};
export default function ExpandedTypeahead({
suggestedCount = 0,
@ -23,6 +26,7 @@ export default function ExpandedTypeahead({
clearOnSelect = false,
options,
onSelect,
onChange: _,
...typeaheadProps
}: ExpandedTypeaheadProps) {
const [key, setKey] = useState(0);
@ -55,7 +59,8 @@ export default function ExpandedTypeahead({
if (clearOnSelect) {
setKey((key + 1) % 2);
}
onSelect(option);
// TODO: Remove onSelect null coercion once onSelect prop is refactored
onSelect(option!);
}}
/>
</div>

@ -1,21 +1,71 @@
import { LOCATIONS } from '~/utils/questions/constants';
import { useMemo, useState } from 'react';
import type { TypeaheadOption } from '@tih/ui';
import { trpc } from '~/utils/trpc';
import type { ExpandedTypeaheadProps } from './ExpandedTypeahead';
import ExpandedTypeahead from './ExpandedTypeahead';
import type { Location } from '~/types/questions';
export type LocationTypeaheadProps = Omit<
ExpandedTypeaheadProps,
'label' | 'onQueryChange' | 'options'
>;
'label' | 'onQueryChange' | 'onSelect' | 'onSuggestionClick' | 'options'
> & {
onSelect: (option: Location & TypeaheadOption) => void;
onSuggestionClick?: (option: Location) => void;
};
export default function LocationTypeahead({
onSelect,
onSuggestionClick,
...restProps
}: LocationTypeaheadProps) {
const [query, setQuery] = useState('');
const { data: locations } = trpc.useQuery([
'locations.cities.list',
{
name: query,
},
]);
const locationOptions = useMemo(() => {
return (
locations?.map(({ id, name, state }) => ({
cityId: id,
countryId: state.country.id,
id,
label: `${name}, ${state.name}, ${state.country.name}`,
stateId: state.id,
value: id,
})) ?? []
);
}, [locations]);
export default function LocationTypeahead(props: LocationTypeaheadProps) {
return (
<ExpandedTypeahead
{...(props as ExpandedTypeaheadProps)}
{...({
onSuggestionClick: onSuggestionClick
? (option: TypeaheadOption) => {
const location = locationOptions.find(
(locationOption) => locationOption.id === option.id,
)!;
onSuggestionClick({
...location,
...option,
});
}
: undefined,
...restProps,
} as ExpandedTypeaheadProps)}
label="Location"
options={LOCATIONS}
// eslint-disable-next-line @typescript-eslint/no-empty-function
onQueryChange={() => {}}
options={locationOptions}
onQueryChange={setQuery}
onSelect={({ id }: TypeaheadOption) => {
const location = locationOptions.find((option) => option.id === id)!;
onSelect(location);
}}
/>
);
}

@ -1,21 +1,34 @@
import { ROLES } from '~/utils/questions/constants';
import { useState } from 'react';
import { JobTitleLabels } from '~/components/shared/JobTitles';
import type { ExpandedTypeaheadProps } from './ExpandedTypeahead';
import ExpandedTypeahead from './ExpandedTypeahead';
import type { FilterChoices } from '../filter/FilterSection';
export type RoleTypeaheadProps = Omit<
ExpandedTypeaheadProps,
'label' | 'onQueryChange' | 'options'
>;
const ROLES: FilterChoices = Object.entries(JobTitleLabels).map(
([slug, label]) => ({
id: slug,
label,
value: slug,
}),
);
export default function RoleTypeahead(props: RoleTypeaheadProps) {
const [query, setQuery] = useState('');
return (
<ExpandedTypeahead
{...(props as ExpandedTypeaheadProps)}
label="Role"
options={ROLES}
// eslint-disable-next-line @typescript-eslint/no-empty-function
onQueryChange={() => {}}
options={ROLES.filter((option) =>
option.label.toLowerCase().includes(query.toLowerCase()),
)}
onQueryChange={setQuery}
/>
);
}

@ -15,7 +15,9 @@ export default function ResumeFilterPill({
<button
className={clsx(
'border-primary-500 focus:bg-primary-500 rounded-xl border border-transparent px-2 py-1 text-xs font-medium focus:text-white',
isSelected ? 'bg-primary-500 text-white' : 'text-primary-500 bg-white',
isSelected
? 'bg-primary-500 text-white'
: 'text-primary-500 hover:text-primary-700 bg-white hover:bg-slate-100',
)}
type="button"
onClick={onClick}>

@ -3,6 +3,8 @@ import type { SubmitHandler } from 'react-hook-form';
import { useForm } from 'react-hook-form';
import { Button, Dialog, TextArea } from '@tih/ui';
import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics';
import { trpc } from '~/utils/trpc';
type ResumeCommentsFormProps = Readonly<{
@ -25,6 +27,8 @@ export default function ResumeCommentsForm({
setShowCommentsForm,
}: ResumeCommentsFormProps) {
const [showDialog, setShowDialog] = useState(false);
const { event: gaEvent } = useGoogleAnalytics();
const {
register,
handleSubmit,
@ -50,6 +54,11 @@ export default function ResumeCommentsForm({
trpcContext.invalidateQueries(['resumes.resume.findAll']);
trpcContext.invalidateQueries(['resumes.resume.user.findUserStarred']);
trpcContext.invalidateQueries(['resumes.resume.user.findUserCreated']);
gaEvent({
action: 'resumes.comment_submit',
category: 'engagement',
label: 'Submit comment',
});
},
},
);

@ -3,6 +3,8 @@ import { useForm } from 'react-hook-form';
import type { ResumesSection } from '@prisma/client';
import { Button, TextArea } from '@tih/ui';
import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics';
import { trpc } from '~/utils/trpc';
type ResumeCommentEditFormProps = {
@ -33,6 +35,7 @@ export default function ResumeCommentReplyForm({
description: '',
},
});
const { event: gaEvent } = useGoogleAnalytics();
const trpcContext = trpc.useContext();
const commentReplyMutation = trpc.useMutation('resumes.comments.user.reply', {
@ -58,6 +61,12 @@ export default function ResumeCommentReplyForm({
{
onSuccess: () => {
setIsReplyingComment(false);
gaEvent({
action: 'resumes.comment_reply',
category: 'engagement',
label: 'Reply comment',
});
},
},
);

@ -7,6 +7,8 @@ import {
} from '@heroicons/react/20/solid';
import { Vote } from '@prisma/client';
import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics';
import { trpc } from '~/utils/trpc';
type ResumeCommentVoteButtonsProps = {
@ -20,6 +22,7 @@ export default function ResumeCommentVoteButtons({
}: ResumeCommentVoteButtonsProps) {
const [upvoteAnimation, setUpvoteAnimation] = useState(false);
const [downvoteAnimation, setDownvoteAnimation] = useState(false);
const { event: gaEvent } = useGoogleAnalytics();
const trpcContext = trpc.useContext();
const router = useRouter();
@ -35,6 +38,11 @@ export default function ResumeCommentVoteButtons({
onSuccess: () => {
// Comment updated, invalidate query to trigger refetch
trpcContext.invalidateQueries(['resumes.comments.votes.list']);
gaEvent({
action: 'resumes.comment_vote',
category: 'engagement',
label: 'Upvote/Downvote comment',
});
},
},
);
@ -44,6 +52,11 @@ export default function ResumeCommentVoteButtons({
onSuccess: () => {
// Comment updated, invalidate query to trigger refetch
trpcContext.invalidateQueries(['resumes.comments.votes.list']);
gaEvent({
action: 'resumes.comment_unvote',
category: 'engagement',
label: 'Unvote comment',
});
},
},
);

@ -0,0 +1,59 @@
import { useState } from 'react';
import type { TypeaheadOption } from '@tih/ui';
import { Typeahead } from '@tih/ui';
import { trpc } from '~/utils/trpc';
type Props = Readonly<{
disabled?: boolean;
errorMessage?: string;
isLabelHidden?: boolean;
label?: string;
onSelect: (option: TypeaheadOption | null) => void;
placeholder?: string;
required?: boolean;
value?: TypeaheadOption | null;
}>;
export default function CitiesTypeahead({
disabled,
label = 'City',
onSelect,
isLabelHidden,
placeholder,
required,
value,
}: Props) {
const [query, setQuery] = useState('');
const cities = trpc.useQuery([
'locations.cities.list',
{
name: query,
},
]);
const { data } = cities;
return (
<Typeahead
disabled={disabled}
isLabelHidden={isLabelHidden}
label={label}
noResultsMessage="No cities found"
nullable={true}
options={
data?.map(({ id, name, state }) => ({
id,
label: `${name}, ${state?.name}, ${state?.country?.name}`,
value: id,
})) ?? []
}
placeholder={placeholder}
required={required}
textSize="inherit"
value={value}
onQueryChange={setQuery}
onSelect={onSelect}
/>
);
}

@ -8,17 +8,19 @@ type Props = Readonly<{
disabled?: boolean;
errorMessage?: string;
isLabelHidden?: boolean;
onSelect: (option: TypeaheadOption) => void;
placeHolder?: string;
onSelect: (option: TypeaheadOption | null) => void;
placeholder?: string;
required?: boolean;
value?: TypeaheadOption | null;
}>;
export default function CompaniesTypeahead({
disabled,
onSelect,
isLabelHidden,
placeHolder,
placeholder,
required,
value,
}: Props) {
const [query, setQuery] = useState('');
const companies = trpc.useQuery([
@ -44,9 +46,10 @@ export default function CompaniesTypeahead({
value: id,
})) ?? []
}
placeholder={placeHolder}
placeholder={placeholder}
required={required}
textSize="inherit"
value={value}
onQueryChange={setQuery}
onSelect={onSelect}
/>

@ -0,0 +1,57 @@
import { useState } from 'react';
import type { TypeaheadOption } from '@tih/ui';
import { Typeahead } from '@tih/ui';
import { trpc } from '~/utils/trpc';
type Props = Readonly<{
disabled?: boolean;
errorMessage?: string;
isLabelHidden?: boolean;
onSelect: (option: TypeaheadOption | null) => void;
placeholder?: string;
required?: boolean;
value?: TypeaheadOption | null;
}>;
export default function CountriesTypeahead({
disabled,
onSelect,
isLabelHidden,
placeholder,
required,
value,
}: Props) {
const [query, setQuery] = useState('');
const countries = trpc.useQuery([
'locations.countries.list',
{
name: query,
},
]);
const { data } = countries;
return (
<Typeahead
disabled={disabled}
isLabelHidden={isLabelHidden}
label="Country"
noResultsMessage="No countries found"
nullable={true}
options={
data?.map(({ id, name }) => ({
id,
label: name,
value: id,
})) ?? []
}
placeholder={placeholder}
required={required}
textSize="inherit"
value={value}
onQueryChange={setQuery}
onSelect={onSelect}
/>
);
}

@ -7,17 +7,19 @@ import { JobTitleLabels } from './JobTitles';
type Props = Readonly<{
disabled?: boolean;
isLabelHidden?: boolean;
onSelect: (option: TypeaheadOption) => void;
placeHolder?: string;
onSelect: (option: TypeaheadOption | null) => void;
placeholder?: string;
required?: boolean;
value?: TypeaheadOption | null;
}>;
export default function JobTitlesTypeahead({
disabled,
onSelect,
isLabelHidden,
placeHolder,
placeholder,
required,
value,
}: Props) {
const [query, setQuery] = useState('');
const options = Object.entries(JobTitleLabels)
@ -39,9 +41,10 @@ export default function JobTitlesTypeahead({
noResultsMessage="No available job titles."
nullable={true}
options={options}
placeholder={placeHolder}
placeholder={placeholder}
required={required}
textSize="inherit"
value={value}
onQueryChange={setQuery}
onSelect={onSelect}
/>

@ -1,6 +1,7 @@
import type {
Company,
OffersAnalysis,
OffersAnalysisUnit,
OffersBackground,
OffersCurrency,
OffersEducation,
@ -18,9 +19,9 @@ import { TRPCError } from '@trpc/server';
import type {
AddToProfileResponse,
Analysis,
AnalysisHighestOffer,
AnalysisOffer,
AnalysisUnit,
Background,
CreateOfferProfileResponse,
DashboardOffer,
@ -35,7 +36,8 @@ import type {
SpecificYoe,
UserProfile,
UserProfileOffer,
Valuation} from '~/types/offers';
Valuation,
} from '~/types/offers';
const analysisOfferDtoMapper = (
offer: OffersOffer & {
@ -110,10 +112,9 @@ const analysisOfferDtoMapper = (
return analysisOfferDto;
};
const analysisDtoMapper = (
noOfOffers: number,
percentile: number,
topPercentileOffers: Array<
const analysisUnitDtoMapper = (
analysisUnit: OffersAnalysisUnit & {
topSimilarOffers: Array<
OffersOffer & {
company: Company;
offersFullTime:
@ -130,12 +131,14 @@ const analysisDtoMapper = (
| null;
};
}
>,
>;
},
) => {
const analysisDto: Analysis = {
noOfOffers,
percentile,
topPercentileOffers: topPercentileOffers.map((offer) =>
const analysisDto: AnalysisUnit = {
companyName: analysisUnit.companyName,
noOfOffers: analysisUnit.noOfSimilarOffers,
percentile: analysisUnit.percentile,
topPercentileOffers: analysisUnit.topSimilarOffers.map((offer) =>
analysisOfferDtoMapper(offer),
),
};
@ -165,17 +168,9 @@ const analysisHighestOfferDtoMapper = (
export const profileAnalysisDtoMapper = (
analysis:
| (OffersAnalysis & {
overallHighestOffer: OffersOffer & {
company: Company;
offersFullTime:
| (OffersFullTime & { totalCompensation: OffersCurrency })
| null;
offersIntern:
| (OffersIntern & { monthlySalary: OffersCurrency })
| null;
profile: OffersProfile & { background: OffersBackground | null };
};
topCompanyOffers: Array<
companyAnalysis: Array<
OffersAnalysisUnit & {
topSimilarOffers: Array<
OffersOffer & {
company: Company;
offersFullTime:
@ -195,7 +190,10 @@ export const profileAnalysisDtoMapper = (
};
}
>;
topOverallOffers: Array<
}
>;
overallAnalysis: OffersAnalysisUnit & {
topSimilarOffers: Array<
OffersOffer & {
company: Company;
offersFullTime:
@ -215,6 +213,17 @@ export const profileAnalysisDtoMapper = (
};
}
>;
};
overallHighestOffer: OffersOffer & {
company: Company;
offersFullTime:
| (OffersFullTime & { totalCompensation: OffersCurrency })
| null;
offersIntern:
| (OffersIntern & { monthlySalary: OffersCurrency })
| null;
profile: OffersProfile & { background: OffersBackground | null };
};
})
| null,
) => {
@ -223,23 +232,17 @@ export const profileAnalysisDtoMapper = (
}
const profileAnalysisDto: ProfileAnalysis = {
companyAnalysis: [
analysisDtoMapper(
analysis.noOfSimilarCompanyOffers,
analysis.companyPercentile,
analysis.topCompanyOffers,
companyAnalysis: analysis.companyAnalysis.map((analysisUnit) =>
analysisUnitDtoMapper(analysisUnit),
),
],
createdAt: analysis.createdAt,
id: analysis.id,
overallAnalysis: analysisDtoMapper(
analysis.noOfSimilarOffers,
analysis.overallPercentile,
analysis.topOverallOffers,
),
overallAnalysis: analysisUnitDtoMapper(analysis.overallAnalysis),
overallHighestOffer: analysisHighestOfferDtoMapper(
analysis.overallHighestOffer,
),
profileId: analysis.profileId,
updatedAt: analysis.updatedAt,
};
return profileAnalysisDto;
};
@ -441,17 +444,9 @@ export const profileDtoMapper = (
profile: OffersProfile & {
analysis:
| (OffersAnalysis & {
overallHighestOffer: OffersOffer & {
company: Company;
offersFullTime:
| (OffersFullTime & { totalCompensation: OffersCurrency })
| null;
offersIntern:
| (OffersIntern & { monthlySalary: OffersCurrency })
| null;
profile: OffersProfile & { background: OffersBackground | null };
};
topCompanyOffers: Array<
companyAnalysis: Array<
OffersAnalysisUnit & {
topSimilarOffers: Array<
OffersOffer & {
company: Company;
offersFullTime:
@ -471,7 +466,10 @@ export const profileDtoMapper = (
};
}
>;
topOverallOffers: Array<
}
>;
overallAnalysis: OffersAnalysisUnit & {
topSimilarOffers: Array<
OffersOffer & {
company: Company;
offersFullTime:
@ -491,6 +489,17 @@ export const profileDtoMapper = (
};
}
>;
};
overallHighestOffer: OffersOffer & {
company: Company;
offersFullTime:
| (OffersFullTime & { totalCompensation: OffersCurrency })
| null;
offersIntern:
| (OffersIntern & { monthlySalary: OffersCurrency })
| null;
profile: OffersProfile & { background: OffersBackground | null };
};
})
| null;
background:
@ -527,10 +536,10 @@ export const profileDtoMapper = (
offersIntern: (OffersIntern & { monthlySalary: OffersCurrency }) | null;
}
>;
user: User | null;
users: Array<User>;
},
inputToken: string | undefined,
inputUserId: string | null | undefined
inputUserId: string | null | undefined,
) => {
const profileDto: Profile = {
analysis: profileAnalysisDtoMapper(profile.analysis),
@ -547,18 +556,12 @@ export const profileDtoMapper = (
profileDto.editToken = profile.editToken ?? null;
profileDto.isEditable = true;
const users = profile.user
// TODO: BRYANN UNCOMMENT THIS ONCE U CHANGE THE SCHEMA
// for (let i = 0; i < users.length; i++) {
// if (users[i].id === inputUserId) {
// profileDto.isSaved = true
// }
// }
const { users } = profile;
// TODO: REMOVE THIS ONCE U CHANGE THE SCHEMA
if (users?.id === inputUserId) {
profileDto.isSaved = true
for (let i = 0; i < users.length; i++) {
if (users[i].id === inputUserId) {
profileDto.isSaved = true;
}
}
}
@ -645,38 +648,55 @@ export const getOffersResponseMapper = (
return getOffersResponse;
};
export const getUserProfileResponseMapper = (res: User & {
OffersProfile: Array<OffersProfile & {
offers: Array<OffersOffer & {
export const getUserProfileResponseMapper = (
res:
| (User & {
OffersProfile: Array<
OffersProfile & {
offers: Array<
OffersOffer & {
company: Company;
offersFullTime: (OffersFullTime & { totalCompensation: OffersCurrency }) | null;
offersIntern: (OffersIntern & { monthlySalary: OffersCurrency }) | null;
}>;
}>;
} | null): Array<UserProfile> => {
offersFullTime:
| (OffersFullTime & { totalCompensation: OffersCurrency })
| null;
offersIntern:
| (OffersIntern & { monthlySalary: OffersCurrency })
| null;
}
>;
}
>;
})
| null,
): Array<UserProfile> => {
if (res) {
return res.OffersProfile.map((profile) => {
return {
createdAt: profile.createdAt,
id: profile.id,
offers: profile.offers.map((offer) => {
return userProfileOfferDtoMapper(offer)
return userProfileOfferDtoMapper(offer);
}),
profileName: profile.profileName,
token: profile.editToken
}
})
token: profile.editToken,
};
}).sort((a, b) => {
return b.createdAt > a.createdAt ? 1 : -1;
});
}
return []
}
return [];
};
const userProfileOfferDtoMapper = (
offer: OffersOffer & {
company: Company;
offersFullTime: (OffersFullTime & { totalCompensation: OffersCurrency }) | null;
offersFullTime:
| (OffersFullTime & { totalCompensation: OffersCurrency })
| null;
offersIntern: (OffersIntern & { monthlySalary: OffersCurrency }) | null;
}): UserProfileOffer => {
},
): UserProfileOffer => {
const mappedOffer: UserProfileOffer = {
company: offersCompanyDtoMapper(offer.company),
id: offer.id,
@ -695,11 +715,10 @@ const userProfileOfferDtoMapper = (
offer.jobType === JobType.FULLTIME
? offer.offersFullTime?.title ?? ''
: offer.offersIntern?.title ?? '',
}
};
if (offer.offersFullTime?.totalCompensation) {
mappedOffer.income.value =
offer.offersFullTime.totalCompensation.value;
mappedOffer.income.value = offer.offersFullTime.totalCompensation.value;
mappedOffer.income.currency =
offer.offersFullTime.totalCompensation.currency;
mappedOffer.income.id = offer.offersFullTime.totalCompensation.id;
@ -709,11 +728,9 @@ const userProfileOfferDtoMapper = (
offer.offersFullTime.totalCompensation.baseCurrency;
} else if (offer.offersIntern?.monthlySalary) {
mappedOffer.income.value = offer.offersIntern.monthlySalary.value;
mappedOffer.income.currency =
offer.offersIntern.monthlySalary.currency;
mappedOffer.income.currency = offer.offersIntern.monthlySalary.currency;
mappedOffer.income.id = offer.offersIntern.monthlySalary.id;
mappedOffer.income.baseValue =
offer.offersIntern.monthlySalary.baseValue;
mappedOffer.income.baseValue = offer.offersIntern.monthlySalary.baseValue;
mappedOffer.income.baseCurrency =
offer.offersIntern.monthlySalary.baseCurrency;
} else {
@ -723,5 +740,5 @@ const userProfileOfferDtoMapper = (
});
}
return mappedOffer
}
return mappedOffer;
};

@ -0,0 +1,95 @@
import { useRouter } from 'next/router';
import { signIn, useSession } from 'next-auth/react';
import { useState } from 'react';
import { Button, Spinner } from '@tih/ui';
import DashboardOfferCard from '~/components/offers/dashboard/DashboardProfileCard';
import { trpc } from '~/utils/trpc';
import type { UserProfile } from '~/types/offers';
export default function ProfilesDashboard() {
const { status } = useSession();
const router = useRouter();
const [userProfiles, setUserProfiles] = useState<Array<UserProfile>>([]);
const userProfilesQuery = trpc.useQuery(
['offers.user.profile.getUserProfiles'],
{
onError: (error) => {
if (error.data?.code === 'UNAUTHORIZED') {
signIn();
}
},
onSuccess: (response: Array<UserProfile>) => {
setUserProfiles(response);
},
},
);
if (status === 'loading' || userProfilesQuery.isLoading) {
return (
<div className="flex h-screen w-screen">
<div className="m-auto mx-auto w-full justify-center">
<Spinner className="m-10" display="block" size="lg" />
</div>
</div>
);
}
if (status === 'unauthenticated') {
signIn();
}
if (userProfiles.length === 0) {
return (
<div className="flex h-screen w-screen">
<div className="m-auto mx-auto w-full justify-center text-xl">
<div className="mb-8 flex w-full flex-row justify-center">
<h2>You have not saved any offer profiles yet.</h2>
</div>
<div className="flex flex-row justify-center">
<Button
label="Submit your offers now!"
size="lg"
variant="primary"
onClick={() => router.push('/offers/submit')}
/>
</div>
</div>
</div>
);
}
return (
<>
{userProfilesQuery.isLoading && (
<div className="flex h-screen w-screen">
<div className="m-auto mx-auto w-full justify-center">
<Spinner className="m-10" display="block" size="lg" />
</div>
</div>
)}
{!userProfilesQuery.isLoading && (
<div className="mt-8 overflow-y-auto">
<h1 className="mx-auto mb-4 w-3/4 text-start text-4xl font-bold text-slate-900">
Your dashboard
</h1>
<p className="mx-auto w-3/4 text-start text-xl text-slate-900">
Save your offer profiles to dashboard to easily access and edit them
later.
</p>
<div className="justfy-center mt-8 flex w-screen">
<ul className="mx-auto w-3/4 space-y-3" role="list">
{userProfiles?.map((profile) => (
<li
key={profile.id}
className="overflow-hidden bg-white px-4 py-4 shadow sm:rounded-md sm:px-6">
<DashboardOfferCard key={profile.id} profile={profile} />
</li>
))}
</ul>
</div>
</div>
)}
</>
);
}

@ -8,11 +8,11 @@ import {
UsersIcon,
} from '@heroicons/react/24/outline';
import offersAnalysis from '~/components/offers/landing/images/offers-analysis.png';
import offersBrowse from '~/components/offers/landing/images/offers-browse.png';
import offersProfile from '~/components/offers/landing/images/offers-profile.png';
import LeftTextCard from '~/components/offers/landing/LeftTextCard';
import RightTextCard from '~/components/offers/landing/RightTextCard';
import offersAnalysis from '~/components/offers/features/images/offers-analysis.png';
import offersBrowse from '~/components/offers/features/images/offers-browse.png';
import offersProfile from '~/components/offers/features/images/offers-profile.png';
import LeftTextCard from '~/components/offers/features/LeftTextCard';
import RightTextCard from '~/components/offers/features/RightTextCard';
import { HOME_URL } from '~/components/offers/types';
const features = [
@ -38,32 +38,32 @@ const features = [
const footerNavigation = {
social: [
{
href: '#',
icon: (props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) => (
<svg fill="currentColor" viewBox="0 0 24 24" {...props}>
<path
clipRule="evenodd"
d="M22 12c0-5.523-4.477-10-10-10S2 6.477 2 12c0 4.991 3.657 9.128 8.438 9.878v-6.987h-2.54V12h2.54V9.797c0-2.506 1.492-3.89 3.777-3.89 1.094 0 2.238.195 2.238.195v2.46h-1.26c-1.243 0-1.63.771-1.63 1.562V12h2.773l-.443 2.89h-2.33v6.988C18.343 21.128 22 16.991 22 12z"
fillRule="evenodd"
/>
</svg>
),
name: 'Facebook',
},
{
href: '#',
icon: (props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) => (
<svg fill="currentColor" viewBox="0 0 24 24" {...props}>
<path
clipRule="evenodd"
d="M12.315 2c2.43 0 2.784.013 3.808.06 1.064.049 1.791.218 2.427.465a4.902 4.902 0 011.772 1.153 4.902 4.902 0 011.153 1.772c.247.636.416 1.363.465 2.427.048 1.067.06 1.407.06 4.123v.08c0 2.643-.012 2.987-.06 4.043-.049 1.064-.218 1.791-.465 2.427a4.902 4.902 0 01-1.153 1.772 4.902 4.902 0 01-1.772 1.153c-.636.247-1.363.416-2.427.465-1.067.048-1.407.06-4.123.06h-.08c-2.643 0-2.987-.012-4.043-.06-1.064-.049-1.791-.218-2.427-.465a4.902 4.902 0 01-1.772-1.153 4.902 4.902 0 01-1.153-1.772c-.247-.636-.416-1.363-.465-2.427-.047-1.024-.06-1.379-.06-3.808v-.63c0-2.43.013-2.784.06-3.808.049-1.064.218-1.791.465-2.427a4.902 4.902 0 011.153-1.772A4.902 4.902 0 015.45 2.525c.636-.247 1.363-.416 2.427-.465C8.901 2.013 9.256 2 11.685 2h.63zm-.081 1.802h-.468c-2.456 0-2.784.011-3.807.058-.975.045-1.504.207-1.857.344-.467.182-.8.398-1.15.748-.35.35-.566.683-.748 1.15-.137.353-.3.882-.344 1.857-.047 1.023-.058 1.351-.058 3.807v.468c0 2.456.011 2.784.058 3.807.045.975.207 1.504.344 1.857.182.466.399.8.748 1.15.35.35.683.566 1.15.748.353.137.882.3 1.857.344 1.054.048 1.37.058 4.041.058h.08c2.597 0 2.917-.01 3.96-.058.976-.045 1.505-.207 1.858-.344.466-.182.8-.398 1.15-.748.35-.35.566-.683.748-1.15.137-.353.3-.882.344-1.857.048-1.055.058-1.37.058-4.041v-.08c0-2.597-.01-2.917-.058-3.96-.045-.976-.207-1.505-.344-1.858a3.097 3.097 0 00-.748-1.15 3.098 3.098 0 00-1.15-.748c-.353-.137-.882-.3-1.857-.344-1.023-.047-1.351-.058-3.807-.058zM12 6.865a5.135 5.135 0 110 10.27 5.135 5.135 0 010-10.27zm0 1.802a3.333 3.333 0 100 6.666 3.333 3.333 0 000-6.666zm5.338-3.205a1.2 1.2 0 110 2.4 1.2 1.2 0 010-2.4z"
fillRule="evenodd"
/>
</svg>
),
name: 'Instagram',
},
// {
// href: '#',
// icon: (props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) => (
// <svg fill="currentColor" viewBox="0 0 24 24" {...props}>
// <path
// clipRule="evenodd"
// d="M22 12c0-5.523-4.477-10-10-10S2 6.477 2 12c0 4.991 3.657 9.128 8.438 9.878v-6.987h-2.54V12h2.54V9.797c0-2.506 1.492-3.89 3.777-3.89 1.094 0 2.238.195 2.238.195v2.46h-1.26c-1.243 0-1.63.771-1.63 1.562V12h2.773l-.443 2.89h-2.33v6.988C18.343 21.128 22 16.991 22 12z"
// fillRule="evenodd"
// />
// </svg>
// ),
// name: 'Facebook',
// },
// {
// href: '#',
// icon: (props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) => (
// <svg fill="currentColor" viewBox="0 0 24 24" {...props}>
// <path
// clipRule="evenodd"
// d="M12.315 2c2.43 0 2.784.013 3.808.06 1.064.049 1.791.218 2.427.465a4.902 4.902 0 011.772 1.153 4.902 4.902 0 011.153 1.772c.247.636.416 1.363.465 2.427.048 1.067.06 1.407.06 4.123v.08c0 2.643-.012 2.987-.06 4.043-.049 1.064-.218 1.791-.465 2.427a4.902 4.902 0 01-1.153 1.772 4.902 4.902 0 01-1.772 1.153c-.636.247-1.363.416-2.427.465-1.067.048-1.407.06-4.123.06h-.08c-2.643 0-2.987-.012-4.043-.06-1.064-.049-1.791-.218-2.427-.465a4.902 4.902 0 01-1.772-1.153 4.902 4.902 0 01-1.153-1.772c-.247-.636-.416-1.363-.465-2.427-.047-1.024-.06-1.379-.06-3.808v-.63c0-2.43.013-2.784.06-3.808.049-1.064.218-1.791.465-2.427a4.902 4.902 0 011.153-1.772A4.902 4.902 0 015.45 2.525c.636-.247 1.363-.416 2.427-.465C8.901 2.013 9.256 2 11.685 2h.63zm-.081 1.802h-.468c-2.456 0-2.784.011-3.807.058-.975.045-1.504.207-1.857.344-.467.182-.8.398-1.15.748-.35.35-.566.683-.748 1.15-.137.353-.3.882-.344 1.857-.047 1.023-.058 1.351-.058 3.807v.468c0 2.456.011 2.784.058 3.807.045.975.207 1.504.344 1.857.182.466.399.8.748 1.15.35.35.683.566 1.15.748.353.137.882.3 1.857.344 1.054.048 1.37.058 4.041.058h.08c2.597 0 2.917-.01 3.96-.058.976-.045 1.505-.207 1.858-.344.466-.182.8-.398 1.15-.748.35-.35.566-.683.748-1.15.137-.353.3-.882.344-1.857.048-1.055.058-1.37.058-4.041v-.08c0-2.597-.01-2.917-.058-3.96-.045-.976-.207-1.505-.344-1.858a3.097 3.097 0 00-.748-1.15 3.098 3.098 0 00-1.15-.748c-.353-.137-.882-.3-1.857-.344-1.023-.047-1.351-.058-3.807-.058zM12 6.865a5.135 5.135 0 110 10.27 5.135 5.135 0 010-10.27zm0 1.802a3.333 3.333 0 100 6.666 3.333 3.333 0 000-6.666zm5.338-3.205a1.2 1.2 0 110 2.4 1.2 1.2 0 010-2.4z"
// fillRule="evenodd"
// />
// </svg>
// ),
// name: 'Instagram',
// },
{
href: 'https://github.com/yangshun/tech-interview-handbook',
icon: (props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) => (
@ -87,6 +87,11 @@ export default function LandingPage() {
{/* Hero section */}
<div className="relative h-full">
<div className="relative px-4 py-16 sm:px-6 sm:py-24 lg:py-32 lg:px-8">
<img
alt="Tech Offers Repo"
className="mx-auto mb-8 w-auto"
src="/offers-logo.svg"
/>
<h1 className="text-center text-4xl font-bold tracking-tight sm:text-5xl lg:text-6xl">
<span>Choosing offers </span>
<span className="from-primary-600 -mb-1 mr-2 bg-gradient-to-r to-purple-500 bg-clip-text pb-1 pr-4 italic text-transparent">
@ -121,16 +126,16 @@ export default function LandingPage() {
/>
<div className="relative">
<LeftTextCard
description="An offer profile includes not only offers that a person received in their application cycle, but also background information such as education and work experience. Use offer profiles to help you better contextualize offers."
description="Filter relevant offers by job title, company, submission date, salary and more."
icon={
<InformationCircleIcon
<TableCellsIcon
aria-hidden="true"
className="h-6 w-6 text-white"
/>
}
imageAlt="Offer table page"
imageSrc={offersProfile}
title="Choosing an offer needs context"
imageSrc={offersBrowse}
title="Stay informed of recent offers"
/>
</div>
<div className="mt-36">
@ -149,16 +154,16 @@ export default function LandingPage() {
</div>
<div className="mt-36">
<LeftTextCard
description="Filter relevant offers by job title, company, submission date, salary and more."
description="An offer profile includes not only offers that a person received in their application cycle, but also background information such as education and work experience. Use offer profiles to help you better contextualize offers."
icon={
<TableCellsIcon
<InformationCircleIcon
aria-hidden="true"
className="h-6 w-6 text-white"
/>
}
imageAlt="Offer table page"
imageSrc={offersBrowse}
title="Stay informed of recent offers"
imageSrc={offersProfile}
title="Choosing an offer needs context"
/>
</div>
</div>

@ -2,6 +2,7 @@ import Link from 'next/link';
import { useState } from 'react';
import { Banner } from '@tih/ui';
import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics';
import OffersTable from '~/components/offers/table/OffersTable';
import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead';
import JobTitlesTypeahead from '~/components/shared/JobTitlesTypahead';
@ -9,6 +10,7 @@ import JobTitlesTypeahead from '~/components/shared/JobTitlesTypahead';
export default function OffersHomePage() {
const [jobTitleFilter, setjobTitleFilter] = useState('software-engineer');
const [companyFilter, setCompanyFilter] = useState('');
const { event: gaEvent } = useGoogleAnalytics();
return (
<main className="flex-1 overflow-y-auto">
@ -36,14 +38,32 @@ export default function OffersHomePage() {
<div className="flex items-center space-x-4">
<JobTitlesTypeahead
isLabelHidden={true}
placeHolder="Software Engineer"
onSelect={({ value }) => setjobTitleFilter(value)}
placeholder="Software Engineer"
onSelect={(option) => {
if (option) {
setjobTitleFilter(option.value);
gaEvent({
action: `offers.table_filter_job_title_${option.value}`,
category: 'engagement',
label: 'Filter by job title',
});
}
}}
/>
<span>in</span>
<CompaniesTypeahead
isLabelHidden={true}
placeHolder="All Companies"
onSelect={({ value }) => setCompanyFilter(value)}
placeholder="All Companies"
onSelect={(option) => {
if (option) {
setCompanyFilter(option.value);
gaEvent({
action: 'offers.table_filter_company',
category: 'engagement',
label: 'Filter by company',
});
}
}}
/>
</div>
</div>

@ -1,6 +1,8 @@
import Error from 'next/error';
import { useRouter } from 'next/router';
import { useSession } from 'next-auth/react';
import { useState } from 'react';
import { Spinner, useToast } from '@tih/ui';
import { ProfileDetailTab } from '~/components/offers/constants';
import ProfileComments from '~/components/offers/profile/ProfileComments';
@ -14,7 +16,6 @@ import { HOME_URL } from '~/components/offers/types';
import type { JobTitleType } from '~/components/shared/JobTitles';
import { getLabelForJobTitleType } from '~/components/shared/JobTitles';
import { useToast } from '~/../../../packages/ui/dist';
import { convertMoneyToString } from '~/utils/offers/currency';
import { getProfilePath } from '~/utils/offers/link';
import { formatDate } from '~/utils/offers/time';
@ -24,9 +25,6 @@ import type { Profile, ProfileAnalysis, ProfileOffer } from '~/types/offers';
export default function OfferProfile() {
const { showToast } = useToast();
const ErrorPage = (
<Error statusCode={404} title="Requested profile does not exist." />
);
const router = useRouter();
const { offerProfileId, token = '' } = router.query;
const [isEditable, setIsEditable] = useState(false);
@ -37,11 +35,16 @@ export default function OfferProfile() {
ProfileDetailTab.OFFERS,
);
const [analysis, setAnalysis] = useState<ProfileAnalysis>();
const { data: session } = useSession();
const getProfileQuery = trpc.useQuery(
[
'offers.profile.listOne',
{ profileId: offerProfileId as string, token: token as string },
{
profileId: offerProfileId as string,
token: token as string,
userId: session?.user?.id,
},
],
{
enabled: typeof offerProfileId === 'string',
@ -126,6 +129,7 @@ export default function OfferProfile() {
jobTitle: experience.title
? getLabelForJobTitleType(experience.title as JobTitleType)
: null,
jobType: experience.jobType || undefined,
monthlySalary: experience.monthlySalary
? convertMoneyToString(experience.monthlySalary)
: null,
@ -177,8 +181,20 @@ export default function OfferProfile() {
return (
<>
{getProfileQuery.isError && ErrorPage}
{!getProfileQuery.isError && (
{getProfileQuery.isError && (
<div className="flex w-full justify-center">
<Error statusCode={404} title="Requested profile does not exist" />
</div>
)}
{getProfileQuery.isLoading && (
<div className="flex h-screen w-screen">
<div className="m-auto mx-auto w-screen justify-center">
<Spinner display="block" size="lg" />
<div className="text-center">Loading...</div>
</div>
</div>
)}
{!getProfileQuery.isLoading && !getProfileQuery.isError && (
<div className="mb-4 flex flex h-screen w-screen items-center justify-center divide-x">
<div className="h-full w-2/3 divide-y">
<ProfileHeader
@ -186,6 +202,7 @@ export default function OfferProfile() {
handleDelete={handleDelete}
isEditable={isEditable}
isLoading={getProfileQuery.isLoading}
isSaved={getProfileQuery.data?.isSaved}
selectedTab={selectedTab}
setSelectedTab={setSelectedTab}
/>

@ -1,3 +1,4 @@
import Error from 'next/error';
import { useRouter } from 'next/router';
import { useState } from 'react';
import { JobType } from '@prisma/client';
@ -36,6 +37,7 @@ export default function OffersEditPage() {
? [{ jobType: JobType.FULLTIME }]
: experiences.map((exp) => ({
companyId: exp.company?.id,
companyName: exp.company?.name,
durationInMonths: exp.durationInMonths,
id: exp.id,
jobType: exp.jobType,
@ -53,6 +55,7 @@ export default function OffersEditPage() {
offers: data.offers.map((offer) => ({
comments: offer.comments,
companyId: offer.company.id,
companyName: offer.company.name,
id: offer.id,
jobType: offer.jobType,
location: offer.location,
@ -74,6 +77,11 @@ export default function OffersEditPage() {
return (
<>
{getProfileResult.isError && (
<div className="flex w-full justify-center">
<Error statusCode={404} title="Requested profile does not exist" />
</div>
)}
{getProfileResult.isLoading && (
<div className="flex w-full justify-center">
<Spinner className="m-10" display="block" size="lg" />

@ -70,7 +70,12 @@ export default function OffersSubmissionResult() {
return (
<>
{getAnalysis.isLoading && (
<Spinner className="m-10" display="block" size="lg" />
<div className="flex h-screen w-screen">
<div className="m-auto mx-auto w-screen justify-center">
<Spinner display="block" size="lg" />
<div className="text-center">Loading...</div>
</div>
</div>
)}
{!getAnalysis.isLoading && (
<div ref={pageRef} className="fixed h-full w-full overflow-y-scroll">
@ -98,6 +103,7 @@ export default function OffersSubmissionResult() {
{step === 1 && (
<div className="flex items-center justify-between">
<Button
addonPosition="start"
icon={ArrowLeftIcon}
label="Previous"
variant="secondary"

@ -1,17 +1,22 @@
import Head from 'next/head';
import { useRouter } from 'next/router';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { ArrowSmallLeftIcon } from '@heroicons/react/24/outline';
import { Button, Select, TextArea } from '@tih/ui';
import { Button, TextArea } from '@tih/ui';
import AnswerCommentListItem from '~/components/questions/AnswerCommentListItem';
import FullAnswerCard from '~/components/questions/card/FullAnswerCard';
import FullScreenSpinner from '~/components/questions/FullScreenSpinner';
import PaginationLoadMoreButton from '~/components/questions/PaginationLoadMoreButton';
import SortOptionsSelect from '~/components/questions/SortOptionsSelect';
import { APP_TITLE } from '~/utils/questions/constants';
import { useFormRegister } from '~/utils/questions/useFormRegister';
import { trpc } from '~/utils/trpc';
import { SortOrder, SortType } from '~/types/questions.d';
export type AnswerCommentData = {
commentContent: string;
};
@ -19,6 +24,13 @@ export type AnswerCommentData = {
export default function QuestionPage() {
const router = useRouter();
const [commentSortOrder, setCommentSortOrder] = useState<SortOrder>(
SortOrder.DESC,
);
const [commentSortType, setCommentSortType] = useState<SortType>(
SortType.NEW,
);
const {
register: comRegister,
reset: resetComment,
@ -36,10 +48,23 @@ export default function QuestionPage() {
{ answerId: answerId as string },
]);
const { data: comments } = trpc.useQuery([
const answerCommentInfiniteQuery = trpc.useInfiniteQuery(
[
'questions.answers.comments.getAnswerComments',
{ answerId: answerId as string },
]);
{
answerId: answerId as string,
limit: 5,
sortOrder: commentSortOrder,
sortType: commentSortType,
},
],
{
getNextPageParam: (lastPage) => lastPage.nextCursor,
keepPreviousData: true,
},
);
const { data: answerCommentsData } = answerCommentInfiniteQuery;
const { mutate: addComment } = trpc.useMutation(
'questions.answers.comments.user.create',
@ -47,7 +72,11 @@ export default function QuestionPage() {
onSuccess: () => {
utils.invalidateQueries([
'questions.answers.comments.getAnswerComments',
{ answerId: answerId as string },
{
answerId: answerId as string,
sortOrder: SortOrder.DESC,
sortType: SortType.NEW,
},
]);
},
},
@ -108,32 +137,6 @@ export default function QuestionPage() {
rows={2}
/>
<div className="my-3 flex justify-between">
<div className="flex items-baseline gap-2">
<span aria-hidden={true} className="text-sm">
Sort by:
</span>
<Select
display="inline"
isLabelHidden={true}
label="Sort by"
options={[
{
label: 'Most recent',
value: 'most-recent',
},
{
label: 'Most upvotes',
value: 'most-upvotes',
},
]}
value="most-recent"
onChange={(value) => {
// eslint-disable-next-line no-console
console.log(value);
}}
/>
</div>
<Button
disabled={!isCommentDirty || !isCommentValid}
label="Post"
@ -142,8 +145,22 @@ export default function QuestionPage() {
/>
</div>
</form>
{(comments ?? []).map((comment) => (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-2">
<p className="text-lg">Comments</p>
<div className="flex items-end gap-2">
<SortOptionsSelect
sortOrderValue={commentSortOrder}
sortTypeValue={commentSortType}
onSortOrderChange={setCommentSortOrder}
onSortTypeChange={setCommentSortType}
/>
</div>
</div>
{/* TODO: Allow to load more pages */}
{(answerCommentsData?.pages ?? []).flatMap(
({ processedQuestionAnswerCommentsData: comments }) =>
comments.map((comment) => (
<AnswerCommentListItem
key={comment.id}
answerCommentId={comment.id}
@ -153,7 +170,10 @@ export default function QuestionPage() {
createdAt={comment.createdAt}
upvoteCount={comment.numVotes}
/>
))}
)),
)}
<PaginationLoadMoreButton query={answerCommentInfiniteQuery} />
</div>
</div>
</div>
</div>

@ -1,19 +1,25 @@
import Head from 'next/head';
import { useRouter } from 'next/router';
import { useMemo, useState } from 'react';
import { useForm } from 'react-hook-form';
import { ArrowSmallLeftIcon } from '@heroicons/react/24/outline';
import { Button, Collapsible, Select, TextArea } from '@tih/ui';
import { Button, Collapsible, HorizontalDivider, TextArea } from '@tih/ui';
import AnswerCommentListItem from '~/components/questions/AnswerCommentListItem';
import FullQuestionCard from '~/components/questions/card/question/FullQuestionCard';
import QuestionAnswerCard from '~/components/questions/card/QuestionAnswerCard';
import FullScreenSpinner from '~/components/questions/FullScreenSpinner';
import PaginationLoadMoreButton from '~/components/questions/PaginationLoadMoreButton';
import SortOptionsSelect from '~/components/questions/SortOptionsSelect';
import { APP_TITLE } from '~/utils/questions/constants';
import createSlug from '~/utils/questions/createSlug';
import relabelQuestionAggregates from '~/utils/questions/relabelQuestionAggregates';
import { useFormRegister } from '~/utils/questions/useFormRegister';
import { trpc } from '~/utils/trpc';
import { SortOrder, SortType } from '~/types/questions.d';
export type AnswerQuestionData = {
answerContent: string;
};
@ -24,6 +30,19 @@ export type QuestionCommentData = {
export default function QuestionPage() {
const router = useRouter();
const [answerSortOrder, setAnswerSortOrder] = useState<SortOrder>(
SortOrder.DESC,
);
const [answerSortType, setAnswerSortType] = useState<SortType>(SortType.NEW);
const [commentSortOrder, setCommentSortOrder] = useState<SortOrder>(
SortOrder.DESC,
);
const [commentSortType, setCommentSortType] = useState<SortType>(
SortType.NEW,
);
const {
register: ansRegister,
handleSubmit,
@ -52,12 +71,33 @@ export default function QuestionPage() {
{ questionId: questionId as string },
]);
const relabeledAggregatedEncounters = useMemo(() => {
if (!aggregatedEncounters) {
return aggregatedEncounters;
}
return relabelQuestionAggregates(aggregatedEncounters);
}, [aggregatedEncounters]);
const utils = trpc.useContext();
const { data: comments } = trpc.useQuery([
const commentInfiniteQuery = trpc.useInfiniteQuery(
[
'questions.questions.comments.getQuestionComments',
{ questionId: questionId as string },
]);
{
limit: 5,
questionId: questionId as string,
sortOrder: commentSortOrder,
sortType: commentSortType,
},
],
{
getNextPageParam: (lastPage) => lastPage.nextCursor,
keepPreviousData: true,
},
);
const { data: commentData } = commentInfiniteQuery;
const { mutate: addComment } = trpc.useMutation(
'questions.questions.comments.user.create',
@ -70,10 +110,23 @@ export default function QuestionPage() {
},
);
const { data: answers } = trpc.useQuery([
const answerInfiniteQuery = trpc.useInfiniteQuery(
[
'questions.answers.getAnswers',
{ questionId: questionId as string },
]);
{
limit: 5,
questionId: questionId as string,
sortOrder: answerSortOrder,
sortType: answerSortType,
},
],
{
getNextPageParam: (lastPage) => lastPage.nextCursor,
keepPreviousData: true,
},
);
const { data: answerData } = answerInfiniteQuery;
const { mutate: addAnswer } = trpc.useMutation(
'questions.answers.user.create',
@ -138,11 +191,12 @@ export default function QuestionPage() {
<div className="flex max-w-7xl flex-1 flex-col gap-2">
<FullQuestionCard
{...question}
companies={aggregatedEncounters?.companyCounts ?? {}}
locations={aggregatedEncounters?.locationCounts ?? {}}
companies={relabeledAggregatedEncounters?.companyCounts ?? {}}
countries={relabeledAggregatedEncounters?.countryCounts ?? {}}
createEncounterButtonText="I received this too"
questionId={question.id}
receivedCount={undefined}
roles={aggregatedEncounters?.roleCounts ?? {}}
roles={relabeledAggregatedEncounters?.roleCounts ?? {}}
timestamp={question.seenAt.toLocaleDateString(undefined, {
month: 'short',
year: 'numeric',
@ -150,16 +204,19 @@ export default function QuestionPage() {
upvoteCount={question.numVotes}
onReceivedSubmit={(data) => {
addEncounter({
cityId: data.cityId,
companyId: data.company,
location: data.location,
countryId: data.countryId,
questionId: questionId as string,
role: data.role,
seenAt: data.seenAt,
stateId: data.stateId,
});
}}
/>
<div className="mx-2">
<Collapsible label={`${(comments ?? []).length} comment(s)`}>
<Collapsible label={`${question.numComments} comment(s)`}>
<div className="mt-4 px-4">
<form
className="mb-2"
onSubmit={handleCommentSubmit(handleSubmitComment)}>
@ -174,32 +231,6 @@ export default function QuestionPage() {
rows={2}
/>
<div className="my-3 flex justify-between">
<div className="flex items-baseline gap-2">
<span aria-hidden={true} className="text-sm">
Sort by:
</span>
<Select
display="inline"
isLabelHidden={true}
label="Sort by"
options={[
{
label: 'Most recent',
value: 'most-recent',
},
{
label: 'Most upvotes',
value: 'most-upvotes',
},
]}
value="most-recent"
onChange={(value) => {
// eslint-disable-next-line no-console
console.log(value);
}}
/>
</div>
<Button
disabled={!isCommentDirty || !isCommentValid}
label="Post"
@ -208,8 +239,22 @@ export default function QuestionPage() {
/>
</div>
</form>
{(comments ?? []).map((comment) => (
{/* TODO: Add button to load more */}
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-2">
<p className="text-lg">Comments</p>
<div className="flex items-end gap-2">
<SortOptionsSelect
sortOrderValue={commentSortOrder}
sortTypeValue={commentSortType}
onSortOrderChange={setCommentSortOrder}
onSortTypeChange={setCommentSortType}
/>
</div>
</div>
{(commentData?.pages ?? []).flatMap(
({ processedQuestionCommentsData: comments }) =>
comments.map((comment) => (
<AnswerCommentListItem
key={comment.id}
answerCommentId={comment.id}
@ -219,9 +264,14 @@ export default function QuestionPage() {
createdAt={comment.createdAt}
upvoteCount={comment.numVotes}
/>
))}
)),
)}
<PaginationLoadMoreButton query={commentInfiniteQuery} />
</div>
</div>
</Collapsible>
</div>
<HorizontalDivider />
<form onSubmit={handleSubmit(handleSubmitAnswer)}>
<TextArea
{...answerRegister('answerContent', {
@ -234,34 +284,6 @@ export default function QuestionPage() {
rows={5}
/>
<div className="mt-3 mb-1 flex justify-between">
<div className="flex items-baseline justify-start gap-2">
<p>{(answers ?? []).length} answers</p>
<div className="flex items-baseline gap-2">
<span aria-hidden={true} className="text-sm">
Sort by:
</span>
<Select
display="inline"
isLabelHidden={true}
label="Sort by"
options={[
{
label: 'Most recent',
value: 'most-recent',
},
{
label: 'Most upvotes',
value: 'most-upvotes',
},
]}
value="most-recent"
onChange={(value) => {
// eslint-disable-next-line no-console
console.log(value);
}}
/>
</div>
</div>
<Button
disabled={!isDirty || !isValid}
label="Contribute"
@ -270,7 +292,21 @@ export default function QuestionPage() {
/>
</div>
</form>
{(answers ?? []).map((answer) => (
<div className="flex items-center justify-between gap-2">
<p className="text-xl">{question.numAnswers} answers</p>
<div className="flex items-end gap-2">
<SortOptionsSelect
sortOrderValue={answerSortOrder}
sortTypeValue={answerSortType}
onSortOrderChange={setAnswerSortOrder}
onSortTypeChange={setAnswerSortType}
/>
</div>
</div>
{/* TODO: Add button to load more */}
{(answerData?.pages ?? []).flatMap(
({ processedAnswersData: answers }) =>
answers.map((answer) => (
<QuestionAnswerCard
key={answer.id}
answerId={answer.id}
@ -284,7 +320,9 @@ export default function QuestionPage() {
)}`}
upvoteCount={answer.numVotes}
/>
))}
)),
)}
<PaginationLoadMoreButton query={answerInfiniteQuery} />
</div>
</div>
</div>

@ -5,36 +5,53 @@ import { useEffect, useMemo, useState } from 'react';
import { Bars3BottomLeftIcon } from '@heroicons/react/20/solid';
import { NoSymbolIcon } from '@heroicons/react/24/outline';
import type { QuestionsQuestionType } from '@prisma/client';
import type { TypeaheadOption } from '@tih/ui';
import { Button, SlideOut } from '@tih/ui';
import QuestionOverviewCard from '~/components/questions/card/question/QuestionOverviewCard';
import ContributeQuestionCard from '~/components/questions/ContributeQuestionCard';
import FilterSection from '~/components/questions/filter/FilterSection';
import PaginationLoadMoreButton from '~/components/questions/PaginationLoadMoreButton';
import QuestionSearchBar from '~/components/questions/QuestionSearchBar';
import CompanyTypeahead from '~/components/questions/typeahead/CompanyTypeahead';
import LocationTypeahead from '~/components/questions/typeahead/LocationTypeahead';
import RoleTypeahead from '~/components/questions/typeahead/RoleTypeahead';
import { JobTitleLabels } from '~/components/shared/JobTitles';
import type { QuestionAge } from '~/utils/questions/constants';
import { SORT_TYPES } from '~/utils/questions/constants';
import { SORT_ORDERS } from '~/utils/questions/constants';
import { APP_TITLE } from '~/utils/questions/constants';
import { QUESTION_AGES, QUESTION_TYPES } from '~/utils/questions/constants';
import createSlug from '~/utils/questions/createSlug';
import relabelQuestionAggregates from '~/utils/questions/relabelQuestionAggregates';
import {
useSearchParam,
useSearchParamSingle,
} from '~/utils/questions/useSearchParam';
import { trpc } from '~/utils/trpc';
import type { Location } from '~/types/questions.d';
import { SortType } from '~/types/questions.d';
import { SortOrder } from '~/types/questions.d';
function locationToSlug(value: Location & TypeaheadOption): string {
return [
value.countryId,
value.stateId,
value.cityId,
value.id,
value.label,
value.value,
].join('-');
}
export default function QuestionsBrowsePage() {
const router = useRouter();
const [selectedCompanies, setSelectedCompanies, areCompaniesInitialized] =
useSearchParam('companies');
const [
selectedCompanySlugs,
setSelectedCompanySlugs,
areCompaniesInitialized,
] = useSearchParam('companies');
const [
selectedQuestionTypes,
setSelectedQuestionTypes,
@ -68,7 +85,13 @@ export default function QuestionsBrowsePage() {
const [selectedRoles, setSelectedRoles, areRolesInitialized] =
useSearchParam('roles');
const [selectedLocations, setSelectedLocations, areLocationsInitialized] =
useSearchParam('locations');
useSearchParam<Location & TypeaheadOption>('locations', {
paramToString: locationToSlug,
stringToParam: (param) => {
const [countryId, stateId, cityId, id, label, value] = param.split('-');
return { cityId, countryId, id, label, stateId, value };
},
});
const [sortOrder, setSortOrder, isSortOrderInitialized] =
useSearchParamSingle<SortOrder>('sortOrder', {
@ -120,13 +143,13 @@ export default function QuestionsBrowsePage() {
const hasFilters = useMemo(
() =>
selectedCompanies.length > 0 ||
selectedCompanySlugs.length > 0 ||
selectedQuestionTypes.length > 0 ||
selectedQuestionAge !== 'all' ||
selectedRoles.length > 0 ||
selectedLocations.length > 0,
[
selectedCompanies,
selectedCompanySlugs,
selectedQuestionTypes,
selectedQuestionAge,
selectedRoles,
@ -145,24 +168,24 @@ export default function QuestionsBrowsePage() {
: undefined;
}, [selectedQuestionAge]);
const {
data: questionsQueryData,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = trpc.useInfiniteQuery(
const questionsInfiniteQuery = trpc.useInfiniteQuery(
[
'questions.questions.getQuestionsByFilter',
{
companyNames: selectedCompanies,
// TODO: Enable filtering by countryIds and stateIds
cityIds: selectedLocations
.map(({ cityId }) => cityId)
.filter((id) => id !== undefined) as Array<string>,
companyIds: selectedCompanySlugs.map((slug) => slug.split('_')[0]),
countryIds: [],
endDate: today,
limit: 10,
locations: selectedLocations,
questionTypes: selectedQuestionTypes,
roles: selectedRoles,
sortOrder,
sortType,
startDate,
stateIds: [],
},
],
{
@ -171,12 +194,14 @@ export default function QuestionsBrowsePage() {
},
);
const { data: questionsQueryData } = questionsInfiniteQuery;
const questionCount = useMemo(() => {
if (!questionsQueryData) {
return undefined;
}
return questionsQueryData.pages.reduce(
(acc, page) => acc + page.data.length,
(acc, page) => acc + (page.data.length as number),
0,
);
}, [questionsQueryData]);
@ -237,8 +262,8 @@ export default function QuestionsBrowsePage() {
Router.replace({
pathname,
query: {
companies: selectedCompanies,
locations: selectedLocations,
companies: selectedCompanySlugs,
locations: selectedLocations.map(locationToSlug),
questionAge: selectedQuestionAge,
questionTypes: selectedQuestionTypes,
roles: selectedRoles,
@ -253,7 +278,7 @@ export default function QuestionsBrowsePage() {
areSearchOptionsInitialized,
loaded,
pathname,
selectedCompanies,
selectedCompanySlugs,
selectedRoles,
selectedLocations,
selectedQuestionAge,
@ -263,19 +288,22 @@ export default function QuestionsBrowsePage() {
]);
const selectedCompanyOptions = useMemo(() => {
return selectedCompanies.map((company) => ({
return selectedCompanySlugs.map((company) => {
const [id, label] = company.split('_');
return {
checked: true,
id: company,
label: company,
value: company,
}));
}, [selectedCompanies]);
id,
label,
value: id,
};
});
}, [selectedCompanySlugs]);
const selectedRoleOptions = useMemo(() => {
return selectedRoles.map((role) => ({
checked: true,
id: role,
label: role,
label: JobTitleLabels[role as keyof typeof JobTitleLabels],
value: role,
}));
}, [selectedRoles]);
@ -283,9 +311,7 @@ export default function QuestionsBrowsePage() {
const selectedLocationOptions = useMemo(() => {
return selectedLocations.map((location) => ({
checked: true,
id: location,
label: location,
value: location,
...location,
}));
}, [selectedLocations]);
@ -303,7 +329,7 @@ export default function QuestionsBrowsePage() {
label="Clear filters"
variant="tertiary"
onClick={() => {
setSelectedCompanies([]);
setSelectedCompanySlugs([]);
setSelectedQuestionTypes([]);
setSelectedQuestionAge('all');
setSelectedRoles([]);
@ -318,13 +344,14 @@ export default function QuestionsBrowsePage() {
{...field}
clearOnSelect={true}
filterOption={(option) => {
return !selectedCompanies.some((company) => {
return company === option.value;
return !selectedCompanySlugs.some((companySlug) => {
return companySlug === `${option.id}_${option.label}`;
});
}}
isLabelHidden={true}
placeholder="Search companies"
onSelect={(option) => {
// @ts-ignore TODO(questions): handle potentially null value.
onOptionChange({
...option,
checked: true,
@ -334,10 +361,15 @@ export default function QuestionsBrowsePage() {
)}
onOptionChange={(option) => {
if (option.checked) {
setSelectedCompanies([...selectedCompanies, option.label]);
setSelectedCompanySlugs([
...selectedCompanySlugs,
`${option.id}_${option.label}`,
]);
} else {
setSelectedCompanies(
selectedCompanies.filter((company) => company !== option.label),
setSelectedCompanySlugs(
selectedCompanySlugs.filter(
(companySlug) => companySlug !== `${option.id}_${option.label}`,
),
);
}
}}
@ -345,7 +377,10 @@ export default function QuestionsBrowsePage() {
<FilterSection
label="Roles"
options={selectedRoleOptions}
renderInput={({ onOptionChange, field: { ref: _, ...field } }) => (
renderInput={({
onOptionChange,
field: { ref: _, onChange: __, ...field },
}) => (
<RoleTypeahead
{...field}
clearOnSelect={true}
@ -357,6 +392,7 @@ export default function QuestionsBrowsePage() {
isLabelHidden={true}
placeholder="Search roles"
onSelect={(option) => {
// @ts-ignore TODO(questions): handle potentially null value.
onOptionChange({
...option,
checked: true,
@ -369,7 +405,7 @@ export default function QuestionsBrowsePage() {
setSelectedRoles([...selectedRoles, option.value]);
} else {
setSelectedRoles(
selectedCompanies.filter((role) => role !== option.value),
selectedRoles.filter((role) => role !== option.value),
);
}
}}
@ -402,18 +438,22 @@ export default function QuestionsBrowsePage() {
<FilterSection
label="Locations"
options={selectedLocationOptions}
renderInput={({ onOptionChange, field: { ref: _, ...field } }) => (
renderInput={({
onOptionChange,
field: { ref: _, onChange: __, ...field },
}) => (
<LocationTypeahead
{...field}
clearOnSelect={true}
filterOption={(option) => {
return !selectedLocations.some((location) => {
return location === option.value;
return location.id === option.id;
});
}}
isLabelHidden={true}
placeholder="Search locations"
onSelect={(option) => {
// @ts-ignore TODO(offers): fix potentially empty value.
onOptionChange({
...option,
checked: true,
@ -423,10 +463,14 @@ export default function QuestionsBrowsePage() {
)}
onOptionChange={(option) => {
if (option.checked) {
setSelectedLocations([...selectedLocations, option.value]);
// TODO: Fix type inference, then remove the `as` cast.
setSelectedLocations([
...selectedLocations,
option as unknown as Location & TypeaheadOption,
]);
} else {
setSelectedLocations(
selectedLocations.filter((role) => role !== option.value),
selectedLocations.filter((location) => location.id !== option.id),
);
}
}}
@ -442,24 +486,26 @@ export default function QuestionsBrowsePage() {
<main className="flex flex-1 flex-col items-stretch">
<div className="flex h-full flex-1">
<section className="flex min-h-0 flex-1 flex-col items-center overflow-auto">
<div className="flex min-h-0 max-w-3xl flex-1 p-4">
<div className="flex flex-1 flex-col items-stretch justify-start gap-8">
<div className="m-4 flex max-w-3xl flex-1 flex-col items-stretch justify-start gap-6">
<ContributeQuestionCard
onSubmit={(data) => {
const { cityId, countryId, stateId } = data.location;
createQuestion({
cityId,
companyId: data.company,
content: data.questionContent,
location: data.location,
countryId,
questionType: data.questionType,
role: data.role,
role: data.role.value,
seenAt: data.date,
stateId,
});
}}
/>
<div className="flex flex-col items-stretch gap-4">
<div className="sticky top-0 border-b border-slate-300 bg-slate-50 py-4">
<QuestionSearchBar
sortOrderOptions={SORT_ORDERS}
sortOrderValue={sortOrder}
sortTypeOptions={SORT_TYPES}
sortTypeValue={sortType}
onFilterOptionsToggle={() => {
setFilterDrawerOpen(!filterDrawerOpen);
@ -467,28 +513,29 @@ export default function QuestionsBrowsePage() {
onSortOrderChange={setSortOrder}
onSortTypeChange={setSortType}
/>
</div>
<div className="flex flex-col gap-2 pb-4">
{(questionsQueryData?.pages ?? []).flatMap(
({ data: questions }) =>
questions.map((question) => (
questions.map((question) => {
const { companyCounts, countryCounts, roleCounts } =
relabelQuestionAggregates(
question.aggregatedQuestionEncounters,
);
return (
<QuestionOverviewCard
key={question.id}
answerCount={question.numAnswers}
companies={
question.aggregatedQuestionEncounters.companyCounts
}
companies={companyCounts}
content={question.content}
countries={countryCounts}
href={`/questions/${question.id}/${createSlug(
question.content,
)}`}
locations={
question.aggregatedQuestionEncounters.locationCounts
}
questionId={question.id}
receivedCount={question.receivedCount}
roles={
question.aggregatedQuestionEncounters.roleCounts
}
roles={roleCounts}
timestamp={question.seenAt.toLocaleDateString(
undefined,
{
@ -499,17 +546,12 @@ export default function QuestionsBrowsePage() {
type={question.type}
upvoteCount={question.numVotes}
/>
)),
);
}),
)}
{questionCount !== 0 && (
<PaginationLoadMoreButton query={questionsInfiniteQuery} />
)}
<Button
disabled={!hasNextPage || isFetchingNextPage}
isLoading={isFetchingNextPage}
label={hasNextPage ? 'Load more' : 'Nothing more to load'}
variant="tertiary"
onClick={() => {
fetchNextPage();
}}
/>
{questionCount === 0 && (
<div className="flex w-full items-center justify-center gap-2 rounded-md border border-slate-300 bg-slate-200 p-4 text-slate-600">
<NoSymbolIcon className="h-6 w-6" />

@ -15,6 +15,7 @@ import DeleteListDialog from '~/components/questions/DeleteListDialog';
import { Button } from '~/../../../packages/ui/dist';
import { APP_TITLE } from '~/utils/questions/constants';
import createSlug from '~/utils/questions/createSlug';
import relabelQuestionAggregates from '~/utils/questions/relabelQuestionAggregates';
import { trpc } from '~/utils/trpc';
export default function ListPage() {
@ -172,24 +173,24 @@ export default function ListPage() {
{lists?.[selectedListIndex] && (
<div className="flex flex-col gap-4 pb-4">
{lists[selectedListIndex].questionEntries.map(
({ question, id: entryId }) => (
({ question, id: entryId }) => {
const { companyCounts, countryCounts, roleCounts } =
relabelQuestionAggregates(
question.aggregatedQuestionEncounters,
);
return (
<QuestionListCard
key={question.id}
companies={
question.aggregatedQuestionEncounters.companyCounts
}
companies={companyCounts}
content={question.content}
countries={countryCounts}
href={`/questions/${question.id}/${createSlug(
question.content,
)}`}
locations={
question.aggregatedQuestionEncounters.locationCounts
}
questionId={question.id}
receivedCount={question.receivedCount}
roles={
question.aggregatedQuestionEncounters.roleCounts
}
roles={roleCounts}
timestamp={question.seenAt.toLocaleDateString(
undefined,
{
@ -202,7 +203,8 @@ export default function ListPage() {
deleteQuestionEntry({ id: entryId });
}}
/>
),
);
},
)}
{lists[selectedListIndex].questionEntries?.length === 0 && (
<div className="flex w-full items-center justify-center gap-2 rounded-md border border-slate-300 bg-slate-200 p-4 text-slate-600">

@ -142,6 +142,11 @@ export default function ResumeReviewPage() {
pathname: '/resumes',
query: {
currentPage: JSON.stringify(1),
isFiltersOpen: JSON.stringify({
experience: experienceLabel !== undefined,
location: locationLabel !== undefined,
role: roleLabel !== undefined,
}),
searchValue: JSON.stringify(''),
shortcutSelected: JSON.stringify('all'),
sortOrder: JSON.stringify('latest'),

@ -127,6 +127,13 @@ export default function ResumeHomePage() {
'userFilters',
INITIAL_FILTER_STATE,
);
const [isFiltersOpen, setIsFiltersOpen, isFiltersOpenInit] = useSearchParams<
Record<FilterId, boolean>
>('isFiltersOpen', {
experience: false,
location: false,
role: false,
});
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false);
const skip = (currentPage - 1) * PAGE_LIMIT;
@ -137,7 +144,8 @@ export default function ResumeHomePage() {
isSearchValueInit &&
isShortcutInit &&
isCurrentPageInit &&
isUserFiltersInit
isUserFiltersInit &&
isFiltersOpenInit
);
}, [
isTabsValueInit,
@ -146,6 +154,7 @@ export default function ResumeHomePage() {
isShortcutInit,
isCurrentPageInit,
isUserFiltersInit,
isFiltersOpenInit,
]);
useEffect(() => {
@ -164,6 +173,7 @@ export default function ResumeHomePage() {
pathname: router.pathname,
query: {
currentPage: JSON.stringify(currentPage),
isFiltersOpen: JSON.stringify(isFiltersOpen),
searchValue: JSON.stringify(searchValue),
shortcutSelected: JSON.stringify(shortcutSelected),
sortOrder: JSON.stringify(sortOrder),
@ -180,6 +190,7 @@ export default function ResumeHomePage() {
currentPage,
router.pathname,
isSearchOptionsInit,
isFiltersOpen,
]);
const allResumesQuery = trpc.useQuery(
@ -399,11 +410,19 @@ export default function ResumeHomePage() {
<Disclosure
key={filter.id}
as="div"
className="border-t border-slate-200 px-4 pt-6 pb-4">
className="border-t border-slate-200 px-4 pt-6 pb-4"
defaultOpen={isFiltersOpen[filter.id]}>
{({ open }) => (
<>
<h3 className="-mx-2 -my-3 flow-root">
<Disclosure.Button className="flex w-full items-center justify-between bg-white px-2 py-3 text-slate-400 hover:text-slate-500">
<Disclosure.Button
className="flex w-full items-center justify-between bg-white px-2 py-3 text-slate-400 hover:text-slate-500"
onClick={() =>
setIsFiltersOpen({
...isFiltersOpen,
[filter.id]: !isFiltersOpen[filter.id],
})
}>
<span className="font-medium text-slate-900">
{filter.label}
</span>
@ -427,12 +446,9 @@ export default function ResumeHomePage() {
{filter.options.map((option) => (
<div
key={option.value}
className="[&>div>div:nth-child(1)>input]:text-primary-600 [&>div>div:nth-child(1)>input]:ring-primary-500 [&>div>div:nth-child(2)>label]:font-normal">
className="[&>div>div:nth-child(1)>input]:text-primary-600 [&>div>div:nth-child(1)>input]:ring-primary-500 flex items-center px-1 text-sm [&>div>div:nth-child(2)>label]:font-normal">
<CheckboxInput
label={`${option.label} (${getFilterCount(
filter.label,
option.label,
)})`}
label={option.label}
value={userFilters[filter.id].includes(
option.value,
)}
@ -444,11 +460,19 @@ export default function ResumeHomePage() {
)
}
/>
<span className="ml-1 text-slate-500">
(
{getFilterCount(
filter.label,
option.label,
)}
)
</span>
</div>
))}
</div>
<p
className="cursor-pointer text-sm text-slate-500 underline"
className="inline-block cursor-pointer text-sm text-slate-500 underline hover:text-slate-700"
onClick={() => onClearFilterClick(filter.id)}>
Clear
</p>
@ -491,15 +515,24 @@ export default function ResumeHomePage() {
<h3 className="text-md font-medium tracking-tight text-slate-900">
Explore these filters
</h3>
{filters.map((filter) => (
{isFiltersOpenInit &&
filters.map((filter) => (
<Disclosure
key={filter.id}
as="div"
className="border-b border-slate-200 pt-6 pb-4">
className="border-b border-slate-200 pt-6 pb-4"
defaultOpen={isFiltersOpen[filter.id]}>
{({ open }) => (
<>
<h3 className="-my-3 flow-root">
<Disclosure.Button className="flex w-full items-center justify-between py-3 text-sm text-slate-400 hover:text-slate-500">
<Disclosure.Button
className="flex w-full items-center justify-between py-3 text-sm text-slate-400 hover:text-slate-500"
onClick={() =>
setIsFiltersOpen({
...isFiltersOpen,
[filter.id]: !isFiltersOpen[filter.id],
})
}>
<span className="font-medium text-slate-900">
{filter.label}
</span>
@ -527,12 +560,9 @@ export default function ResumeHomePage() {
{filter.options.map((option) => (
<div
key={option.value}
className="[&>div>div:nth-child(1)>input]:text-primary-600 [&>div>div:nth-child(1)>input]:ring-primary-500 px-1 [&>div>div:nth-child(2)>label]:font-normal">
className="[&>div>div:nth-child(1)>input]:text-primary-600 [&>div>div:nth-child(1)>input]:ring-primary-500 flex items-center px-1 text-sm [&>div>div:nth-child(2)>label]:font-normal">
<CheckboxInput
label={`${option.label} (${getFilterCount(
filter.label,
option.label,
)})`}
label={option.label}
value={userFilters[filter.id].includes(
option.value,
)}
@ -544,11 +574,16 @@ export default function ResumeHomePage() {
)
}
/>
<span className="ml-1 text-slate-500">
(
{getFilterCount(filter.label, option.label)}
)
</span>
</div>
))}
</CheckboxList>
<p
className="cursor-pointer text-sm text-slate-500 underline"
className="inline-block cursor-pointer text-sm text-slate-500 underline hover:text-slate-700"
onClick={() => onClearFilterClick(filter.id)}>
Clear
</p>

@ -171,9 +171,6 @@ export default function SubmitResumeForm({
onSuccess() {
if (isNewForm) {
trpcContext.invalidateQueries('resumes.resume.findAll');
trpcContext.invalidateQueries(
'resumes.resume.getTotalFilterCounts',
);
router.push('/resumes');
gaEvent({
action: 'resumes.submit_button_click',

@ -4,7 +4,9 @@ import { Button } from '@tih/ui';
import { useToast } from '@tih/ui';
import { HorizontalDivider } from '@tih/ui';
import CitiesTypeahead from '~/components/shared/CitiesTypeahead';
import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead';
import CountriesTypeahead from '~/components/shared/CountriesTypeahead';
import JobTitlesTypeahead from '~/components/shared/JobTitlesTypahead';
import type {
Month,
@ -15,6 +17,11 @@ import MonthYearPicker from '~/components/shared/MonthYearPicker';
export default function HomePage() {
const [selectedCompany, setSelectedCompany] =
useState<TypeaheadOption | null>(null);
const [selectedCountry, setSelectedCountry] =
useState<TypeaheadOption | null>(null);
const [selectedCity, setSelectedCity] = useState<TypeaheadOption | null>(
null,
);
const [selectedJobTitle, setSelectedJobTitle] =
useState<TypeaheadOption | null>(null);
@ -26,12 +33,12 @@ export default function HomePage() {
const { showToast } = useToast();
return (
<main className="flex-1 overflow-y-auto">
<div className="flex h-full items-center justify-center">
<div className="space-y-4">
<main className="mx-auto max-w-5xl flex-1 overflow-y-auto py-24">
<h1 className="text-primary-600 text-center text-4xl font-bold">
Test Page
</h1>
<div className="mt-8 grid grid-cols-2 gap-8">
<div className="space-y-4">
<CompaniesTypeahead
onSelect={(option) => setSelectedCompany(option)}
/>
@ -42,6 +49,15 @@ export default function HomePage() {
/>
<pre>{JSON.stringify(selectedJobTitle, null, 2)}</pre>
<HorizontalDivider />
<CountriesTypeahead
onSelect={(option) => setSelectedCountry(option)}
/>
<pre>{JSON.stringify(selectedCountry, null, 2)}</pre>
<HorizontalDivider />
<CitiesTypeahead onSelect={(option) => setSelectedCity(option)} />
<pre>{JSON.stringify(selectedCity, null, 2)}</pre>
</div>
<div className="space-y-4">
<MonthYearPicker
errorMessage={
monthYear.month == null || monthYear.year == null

@ -2,6 +2,7 @@ import superjson from 'superjson';
import { companiesRouter } from './companies-router';
import { createRouter } from './context';
import { locationsRouter } from './locations-router';
import { offersRouter } from './offers/offers';
import { offersAnalysisRouter } from './offers/offers-analysis-router';
import { offersCommentsRouter } from './offers/offers-comments-router';
@ -38,6 +39,7 @@ export const appRouter = createRouter()
.merge('todos.', todosRouter)
.merge('todos.user.', todosUserRouter)
.merge('companies.', companiesRouter)
.merge('locations.', locationsRouter)
.merge('resumes.resume.', resumesRouter)
.merge('resumes.resume.user.', resumesResumeUserRouter)
.merge('resumes.resume.', resumesStarUserRouter)

@ -0,0 +1,59 @@
import { z } from 'zod';
import { createRouter } from './context';
export const locationsRouter = createRouter()
.query('cities.list', {
input: z.object({
name: z.string(),
}),
async resolve({ ctx, input }) {
return await ctx.prisma.city.findMany({
orderBy: {
name: 'asc',
},
select: {
id: true,
name: true,
state: {
select: {
country: {
select: {
id: true,
name: true,
},
},
id: true,
name: true,
},
},
},
take: 10,
where: {
name: {
contains: input.name,
mode: 'insensitive',
},
},
});
},
})
.query('countries.list', {
input: z.object({
name: z.string(),
}),
async resolve({ ctx, input }) {
return await ctx.prisma.country.findMany({
orderBy: {
name: 'asc',
},
take: 10,
where: {
name: {
contains: input.name,
mode: 'insensitive',
},
},
});
},
});

@ -14,20 +14,17 @@ import { profileAnalysisDtoMapper } from '~/mappers/offers-mappers';
import { createRouter } from '../context';
const searchOfferPercentile = (
offer: OffersOffer & {
type Offer = OffersOffer & {
company: Company;
offersFullTime:
| (OffersFullTime & {
baseSalary: OffersCurrency | null;
bonus: OffersCurrency | null;
stocks: OffersCurrency | null;
totalCompensation: OffersCurrency;
})
| (OffersFullTime & { totalCompensation: OffersCurrency })
| null;
offersIntern: (OffersIntern & { monthlySalary: OffersCurrency }) | null;
profile: OffersProfile & { background: OffersBackground | null };
},
};
const searchOfferPercentile = (
offer: Offer,
similarOffers: Array<
OffersOffer & {
company: Company;
@ -58,7 +55,9 @@ export const offersAnalysisRouter = createRouter()
async resolve({ ctx, input }) {
const analysis = await ctx.prisma.offersAnalysis.findFirst({
include: {
overallHighestOffer: {
companyAnalysis: {
include: {
topSimilarOffers: {
include: {
company: true,
offersFullTime: {
@ -73,12 +72,24 @@ export const offersAnalysisRouter = createRouter()
},
profile: {
include: {
background: true,
background: {
include: {
experiences: {
include: {
company: true,
},
},
},
},
},
topCompanyOffers: {
},
},
},
},
},
overallAnalysis: {
include: {
topSimilarOffers: {
include: {
company: true,
offersFullTime: {
@ -106,7 +117,9 @@ export const offersAnalysisRouter = createRouter()
},
},
},
topOverallOffers: {
},
},
overallHighestOffer: {
include: {
company: true,
offersFullTime: {
@ -121,15 +134,7 @@ export const offersAnalysisRouter = createRouter()
},
profile: {
include: {
background: {
include: {
experiences: {
include: {
company: true,
},
},
},
},
background: true,
},
},
},
@ -310,22 +315,23 @@ export const offersAnalysisRouter = createRouter()
},
});
let similarCompanyOffers = similarOffers.filter(
(offer) => offer.companyId === overallHighestOffer.companyId,
);
// COMPANY ANALYSIS
const companyMap = new Map<string, Offer>();
offers.forEach((offer) => {
if (companyMap.get(offer.companyId) == null) {
companyMap.set(offer.companyId, offer);
}
});
// CALCULATE PERCENTILES
const overallIndex = searchOfferPercentile(
overallHighestOffer,
similarOffers,
const companyAnalysis = Array.from(companyMap.values()).map(
(companyOffer) => {
// TODO: Refactor calculating analysis into a function
let similarCompanyOffers = similarOffers.filter(
(offer) => offer.companyId === companyOffer.companyId,
);
const overallPercentile =
similarOffers.length <= 1
? 100
: 100 - (100 * overallIndex) / (similarOffers.length - 1);
const companyIndex = searchOfferPercentile(
overallHighestOffer,
companyOffer,
similarCompanyOffers,
);
const companyPercentile =
@ -333,25 +339,11 @@ export const offersAnalysisRouter = createRouter()
? 100
: 100 - (100 * companyIndex) / (similarCompanyOffers.length - 1);
// FIND TOP >=90 PERCENTILE OFFERS, DOESN'T GIVE 100th PERCENTILE
// e.g. If there only 4 offers, it gives the 2nd and 3rd offer
similarOffers = similarOffers.filter(
(offer) => offer.id !== overallHighestOffer.id,
);
// Get top offers (excluding user's offer)
similarCompanyOffers = similarCompanyOffers.filter(
(offer) => offer.id !== overallHighestOffer.id,
(offer) => offer.id !== companyOffer.id,
);
const noOfSimilarOffers = similarOffers.length;
const similarOffers90PercentileIndex = Math.ceil(noOfSimilarOffers * 0.1);
const topPercentileOffers =
noOfSimilarOffers > 2
? similarOffers.slice(
similarOffers90PercentileIndex,
similarOffers90PercentileIndex + 2,
)
: similarOffers;
const noOfSimilarCompanyOffers = similarCompanyOffers.length;
const similarCompanyOffers90PercentileIndex = Math.ceil(
noOfSimilarCompanyOffers * 0.1,
@ -364,35 +356,82 @@ export const offersAnalysisRouter = createRouter()
)
: similarCompanyOffers;
return {
companyName: companyOffer.company.name,
noOfSimilarOffers: noOfSimilarCompanyOffers,
percentile: companyPercentile,
topSimilarOffers: topPercentileCompanyOffers,
};
},
);
// OVERALL ANALYSIS
const overallIndex = searchOfferPercentile(
overallHighestOffer,
similarOffers,
);
const overallPercentile =
similarOffers.length <= 1
? 100
: 100 - (100 * overallIndex) / (similarOffers.length - 1);
similarOffers = similarOffers.filter(
(offer) => offer.id !== overallHighestOffer.id,
);
const noOfSimilarOffers = similarOffers.length;
const similarOffers90PercentileIndex = Math.ceil(noOfSimilarOffers * 0.1);
const topPercentileOffers =
noOfSimilarOffers > 2
? similarOffers.slice(
similarOffers90PercentileIndex,
similarOffers90PercentileIndex + 2,
)
: similarOffers;
const analysis = await ctx.prisma.offersAnalysis.create({
data: {
companyPercentile,
noOfSimilarCompanyOffers,
companyAnalysis: {
create: companyAnalysis.map((analysisUnit) => {
return {
companyName: analysisUnit.companyName,
noOfSimilarOffers: analysisUnit.noOfSimilarOffers,
percentile: analysisUnit.percentile,
topSimilarOffers: {
connect: analysisUnit.topSimilarOffers.map((offer) => {
return { id: offer.id };
}),
},
};
}),
},
overallAnalysis: {
create: {
companyName: overallHighestOffer.company.name,
noOfSimilarOffers,
percentile: overallPercentile,
topSimilarOffers: {
connect: topPercentileOffers.map((offer) => {
return { id: offer.id };
}),
},
},
},
overallHighestOffer: {
connect: {
id: overallHighestOffer.id,
},
},
overallPercentile,
profile: {
connect: {
id: input.profileId,
},
},
topCompanyOffers: {
connect: topPercentileCompanyOffers.map((offer) => {
return { id: offer.id };
}),
},
topOverallOffers: {
connect: topPercentileOffers.map((offer) => {
return { id: offer.id };
}),
},
},
include: {
overallHighestOffer: {
companyAnalysis: {
include: {
topSimilarOffers: {
include: {
company: true,
offersFullTime: {
@ -407,12 +446,24 @@ export const offersAnalysisRouter = createRouter()
},
profile: {
include: {
background: true,
background: {
include: {
experiences: {
include: {
company: true,
},
},
},
},
},
},
},
},
topCompanyOffers: {
},
},
overallAnalysis: {
include: {
topSimilarOffers: {
include: {
company: true,
offersFullTime: {
@ -440,7 +491,9 @@ export const offersAnalysisRouter = createRouter()
},
},
},
topOverallOffers: {
},
},
overallHighestOffer: {
include: {
company: true,
offersFullTime: {
@ -455,15 +508,7 @@ export const offersAnalysisRouter = createRouter()
},
profile: {
include: {
background: {
include: {
experiences: {
include: {
company: true,
},
},
},
},
background: true,
},
},
},

@ -321,16 +321,11 @@ export const offersCommentsRouter = createRouter()
},
});
// If (result) {
// return result.discussion.filter((x) => x.replyingToId === null);
// }
// return result;
}
} else {
throw new trpc.TRPCError({
code: 'UNAUTHORIZED',
message: 'Wrong userId or token.',
});
}
},
});

@ -128,7 +128,9 @@ export const offersProfileRouter = createRouter()
include: {
analysis: {
include: {
overallHighestOffer: {
companyAnalysis: {
include: {
topSimilarOffers: {
include: {
company: true,
offersFullTime: {
@ -143,12 +145,24 @@ export const offersProfileRouter = createRouter()
},
profile: {
include: {
background: true,
background: {
include: {
experiences: {
include: {
company: true,
},
},
},
},
topCompanyOffers: {
},
},
},
},
},
},
overallAnalysis: {
include: {
topSimilarOffers: {
include: {
company: true,
offersFullTime: {
@ -176,7 +190,9 @@ export const offersProfileRouter = createRouter()
},
},
},
topOverallOffers: {
},
},
overallHighestOffer: {
include: {
company: true,
offersFullTime: {
@ -191,15 +207,7 @@ export const offersProfileRouter = createRouter()
},
profile: {
include: {
background: {
include: {
experiences: {
include: {
company: true,
},
},
},
},
background: true,
},
},
},
@ -244,7 +252,7 @@ export const offersProfileRouter = createRouter()
},
},
},
user: true,
users: true,
},
where: {
id: input.profileId,
@ -409,7 +417,7 @@ export const offersProfileRouter = createRouter()
message: 'Missing fields in background experiences.',
});
}),
)
),
},
specificYoes: {
create: input.background.specificYoes.map((x) => {

@ -3,7 +3,8 @@ import * as trpc from '@trpc/server';
import { TRPCError } from '@trpc/server';
import {
addToProfileResponseMapper, getUserProfileResponseMapper,
addToProfileResponseMapper,
getUserProfileResponseMapper,
} from '~/mappers/offers-mappers';
import { createProtectedRouter } from '../context';
@ -23,11 +24,10 @@ export const offersUserProfileRouter = createProtectedRouter()
const profileEditToken = profile?.editToken;
if (profileEditToken === input.token) {
const userId = ctx.session.user.id
const userId = ctx.session.user.id;
const updated = await ctx.prisma.offersProfile.update({
data: {
user: {
users: {
connect: {
id: userId,
},
@ -49,7 +49,7 @@ export const offersUserProfileRouter = createProtectedRouter()
})
.query('getUserProfiles', {
async resolve({ ctx }) {
const userId = ctx.session.user.id
const userId = ctx.session.user.id;
const result = await ctx.prisma.user.findFirst({
include: {
OffersProfile: {
@ -59,42 +59,42 @@ export const offersUserProfileRouter = createProtectedRouter()
company: true,
offersFullTime: {
include: {
totalCompensation: true
}
totalCompensation: true,
},
},
offersIntern: {
include: {
monthlySalary: true
}
}
}
}
}
}
monthlySalary: true,
},
},
},
},
},
},
},
where: {
id: userId
}
})
id: userId,
},
});
return getUserProfileResponseMapper(result)
}
return getUserProfileResponseMapper(result);
},
})
.mutation('removeFromUserProfile', {
input: z.object({
profileId: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session.user.id
const userId = ctx.session.user.id;
const profiles = await ctx.prisma.user.findFirst({
include: {
OffersProfile: true
OffersProfile: true,
},
where: {
id: userId
}
})
id: userId,
},
});
// Validation
let doesProfileExist = false;
@ -102,7 +102,7 @@ export const offersUserProfileRouter = createProtectedRouter()
if (profiles?.OffersProfile) {
for (let i = 0; i < profiles.OffersProfile.length; i++) {
if (profiles.OffersProfile[i].id === input.profileId) {
doesProfileExist = true
doesProfileExist = true;
}
}
}
@ -110,22 +110,23 @@ export const offersUserProfileRouter = createProtectedRouter()
if (!doesProfileExist) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'No such profile id saved.'
})
message: 'No such profile id saved.',
});
}
await ctx.prisma.user.update({
data: {
OffersProfile: {
disconnect: [{
id: input.profileId
}]
}
disconnect: [
{
id: input.profileId,
},
],
},
},
where: {
id: userId
}
})
}
})
id: userId,
},
});
},
});

@ -1,275 +0,0 @@
import { z } from 'zod';
import { TRPCError } from '@trpc/server';
import { createQuestionWithAggregateData } from '~/utils/questions/server/aggregate-encounters';
import { createProtectedRouter } from './context';
export const questionsListRouter = createProtectedRouter()
.query('getListsByUser', {
async resolve({ ctx }) {
const userId = ctx.session?.user?.id;
// TODO: Optimize by not returning question entries
const questionsLists = await ctx.prisma.questionsList.findMany({
include: {
questionEntries: {
include: {
question: {
include: {
_count: {
select: {
answers: true,
comments: true,
},
},
encounters: {
select: {
company: true,
location: true,
role: true,
seenAt: true,
},
},
user: {
select: {
name: true,
},
},
votes: true,
},
},
},
},
},
orderBy: {
createdAt: 'asc',
},
where: {
userId,
},
});
const lists = questionsLists.map((list) => ({
...list,
questionEntries: list.questionEntries.map((entry) => ({
...entry,
question: createQuestionWithAggregateData(entry.question),
})),
}));
return lists;
},
})
.query('getListById', {
input: z.object({
listId: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const { listId } = input;
const questionList = await ctx.prisma.questionsList.findFirst({
include: {
questionEntries: {
include: {
question: {
include: {
_count: {
select: {
answers: true,
comments: true,
},
},
encounters: {
select: {
company: true,
location: true,
role: true,
seenAt: true,
},
},
user: {
select: {
name: true,
},
},
votes: true,
},
},
},
},
},
orderBy: {
createdAt: 'asc',
},
where: {
id: listId,
userId,
},
});
if (!questionList) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Question list not found',
});
}
return {
...questionList,
questionEntries: questionList.questionEntries.map((questionEntry) => ({
...questionEntry,
question: createQuestionWithAggregateData(questionEntry.question),
})),
};
},
})
.mutation('create', {
input: z.object({
name: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const { name } = input;
return await ctx.prisma.questionsList.create({
data: {
name,
userId,
},
});
},
})
.mutation('update', {
input: z.object({
id: z.string(),
name: z.string().optional(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const { name, id } = input;
const listToUpdate = await ctx.prisma.questionsList.findUnique({
where: {
id: input.id,
},
});
if (listToUpdate?.id !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
});
}
return await ctx.prisma.questionsList.update({
data: {
name,
},
where: {
id,
},
});
},
})
.mutation('delete', {
input: z.object({
id: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const listToDelete = await ctx.prisma.questionsList.findUnique({
where: {
id: input.id,
},
});
if (listToDelete?.userId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
});
}
return await ctx.prisma.questionsList.delete({
where: {
id: input.id,
},
});
},
})
.mutation('createQuestionEntry', {
input: z.object({
listId: z.string(),
questionId: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const listToAugment = await ctx.prisma.questionsList.findUnique({
where: {
id: input.listId,
},
});
if (listToAugment?.userId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
});
}
const { questionId, listId } = input;
return await ctx.prisma.questionsListQuestionEntry.create({
data: {
listId,
questionId,
},
});
},
})
.mutation('deleteQuestionEntry', {
input: z.object({
id: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const entryToDelete =
await ctx.prisma.questionsListQuestionEntry.findUnique({
where: {
id: input.id,
},
});
if (entryToDelete === null) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Entry not found.',
});
}
const listToAugment = await ctx.prisma.questionsList.findUnique({
where: {
id: entryToDelete.listId,
},
});
if (listToAugment?.userId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
});
}
return await ctx.prisma.questionsListQuestionEntry.delete({
where: {
id: input.id,
},
});
},
});

@ -1,437 +0,0 @@
import { z } from 'zod';
import { QuestionsQuestionType, Vote } from '@prisma/client';
import { TRPCError } from '@trpc/server';
import { createQuestionWithAggregateData } from '~/utils/questions/server/aggregate-encounters';
import { createProtectedRouter } from './context';
import { SortOrder, SortType } from '~/types/questions.d';
export const questionsQuestionRouter = createProtectedRouter()
.query('getQuestionsByFilter', {
input: z.object({
companyNames: z.string().array(),
cursor: z
.object({
idCursor: z.string().optional(),
lastSeenCursor: z.date().nullish().optional(),
upvoteCursor: z.number().optional(),
})
.nullish(),
endDate: z.date().default(new Date()),
limit: z.number().min(1).default(50),
locations: z.string().array(),
questionTypes: z.nativeEnum(QuestionsQuestionType).array(),
roles: z.string().array(),
sortOrder: z.nativeEnum(SortOrder),
sortType: z.nativeEnum(SortType),
startDate: z.date().optional(),
}),
async resolve({ ctx, input }) {
const { cursor } = input;
const sortCondition =
input.sortType === SortType.TOP
? [
{
upvotes: input.sortOrder,
},
{
id: input.sortOrder,
},
]
: [
{
lastSeenAt: input.sortOrder,
},
{
id: input.sortOrder,
},
];
const questionsData = await ctx.prisma.questionsQuestion.findMany({
cursor:
cursor !== undefined
? {
id: cursor ? cursor!.idCursor : undefined,
}
: undefined,
include: {
_count: {
select: {
answers: true,
comments: true,
},
},
encounters: {
select: {
company: true,
location: true,
role: true,
seenAt: true,
},
},
user: {
select: {
name: true,
},
},
votes: true,
},
orderBy: sortCondition,
take: input.limit + 1,
where: {
...(input.questionTypes.length > 0
? {
questionType: {
in: input.questionTypes,
},
}
: {}),
encounters: {
some: {
seenAt: {
gte: input.startDate,
lte: input.endDate,
},
...(input.companyNames.length > 0
? {
company: {
name: {
in: input.companyNames,
},
},
}
: {}),
...(input.locations.length > 0
? {
location: {
in: input.locations,
},
}
: {}),
...(input.roles.length > 0
? {
role: {
in: input.roles,
},
}
: {}),
},
},
},
});
const processedQuestionsData = questionsData.map(
createQuestionWithAggregateData,
);
let nextCursor: typeof cursor | undefined = undefined;
if (questionsData.length > input.limit) {
const nextItem = questionsData.pop()!;
processedQuestionsData.pop();
const nextIdCursor: string | undefined = nextItem.id;
const nextLastSeenCursor =
input.sortType === SortType.NEW ? nextItem.lastSeenAt : undefined;
const nextUpvoteCursor =
input.sortType === SortType.TOP ? nextItem.upvotes : undefined;
nextCursor = {
idCursor: nextIdCursor,
lastSeenCursor: nextLastSeenCursor,
upvoteCursor: nextUpvoteCursor,
};
}
return {
data: processedQuestionsData,
nextCursor,
};
},
})
.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',
});
}
return createQuestionWithAggregateData(questionData);
},
})
.mutation('create', {
input: z.object({
companyId: 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({
data: {
content: input.content,
encounters: {
create: {
company: {
connect: {
id: input.companyId,
},
},
location: input.location,
role: input.role,
seenAt: input.seenAt,
user: {
connect: {
id: userId,
},
},
},
},
lastSeenAt: input.seenAt,
questionType: input.questionType,
userId,
},
});
},
})
.mutation('update', {
input: z.object({
content: z.string().optional(),
id: z.string(),
questionType: z.nativeEnum(QuestionsQuestionType).optional(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const questionToUpdate = await ctx.prisma.questionsQuestion.findUnique({
where: {
id: input.id,
},
});
if (questionToUpdate?.id !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
// Optional: pass the original error to retain stack trace
});
}
const { content, questionType } = input;
return await ctx.prisma.questionsQuestion.update({
data: {
content,
questionType,
},
where: {
id: input.id,
},
});
},
})
.mutation('delete', {
input: z.object({
id: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const questionToDelete = await ctx.prisma.questionsQuestion.findUnique({
where: {
id: input.id,
},
});
if (questionToDelete?.id !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
// Optional: pass the original error to retain stack trace
});
}
return await ctx.prisma.questionsQuestion.delete({
where: {
id: input.id,
},
});
},
})
.query('getVote', {
input: z.object({
questionId: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const { questionId } = input;
return await ctx.prisma.questionsQuestionVote.findUnique({
where: {
questionId_userId: { questionId, userId },
},
});
},
})
.mutation('createVote', {
input: z.object({
questionId: z.string(),
vote: z.nativeEnum(Vote),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const { questionId, vote } = input;
const incrementValue = vote === Vote.UPVOTE ? 1 : -1;
const [questionVote] = await ctx.prisma.$transaction([
ctx.prisma.questionsQuestionVote.create({
data: {
questionId,
userId,
vote,
},
}),
ctx.prisma.questionsQuestion.update({
data: {
upvotes: {
increment: incrementValue,
},
},
where: {
id: questionId,
},
}),
]);
return questionVote;
},
})
.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.questionsQuestionVote.findUnique({
where: {
id: input.id,
},
});
if (voteToUpdate?.userId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
});
}
const incrementValue = vote === Vote.UPVOTE ? 2 : -2;
const [questionVote] = await ctx.prisma.$transaction([
ctx.prisma.questionsQuestionVote.update({
data: {
vote,
},
where: {
id,
},
}),
ctx.prisma.questionsQuestion.update({
data: {
upvotes: {
increment: incrementValue,
},
},
where: {
id: voteToUpdate.questionId,
},
}),
]);
return questionVote;
},
})
.mutation('deleteVote', {
input: z.object({
id: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const voteToDelete = await ctx.prisma.questionsQuestionVote.findUnique({
where: {
id: input.id,
},
});
if (voteToDelete?.userId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
});
}
const incrementValue = voteToDelete.vote === Vote.UPVOTE ? -1 : 1;
const [questionVote] = await ctx.prisma.$transaction([
ctx.prisma.questionsQuestionVote.delete({
where: {
id: input.id,
},
}),
ctx.prisma.questionsQuestion.update({
data: {
upvotes: {
increment: incrementValue,
},
},
where: {
id: voteToDelete.questionId,
},
}),
]);
return questionVote;
},
});

@ -4,16 +4,43 @@ import { Vote } from '@prisma/client';
import { createRouter } from '../context';
import type { AnswerComment } from '~/types/questions';
import { SortOrder, SortType } from '~/types/questions.d';
export const questionsAnswerCommentRouter = createRouter().query(
'getAnswerComments',
{
input: z.object({
answerId: z.string(),
cursor: z.string().nullish(),
limit: z.number().min(1).default(50),
sortOrder: z.nativeEnum(SortOrder),
sortType: z.nativeEnum(SortType),
}),
async resolve({ ctx, input }) {
const { answerId, cursor } = input;
const sortCondition =
input.sortType === SortType.TOP
? [
{
upvotes: input.sortOrder,
},
{
id: input.sortOrder,
},
]
: [
{
updatedAt: input.sortOrder,
},
{
id: input.sortOrder,
},
];
const questionAnswerCommentsData =
await ctx.prisma.questionsAnswerComment.findMany({
cursor: cursor ? { id: cursor } : undefined,
include: {
user: {
select: {
@ -23,14 +50,13 @@ export const questionsAnswerCommentRouter = createRouter().query(
},
votes: true,
},
orderBy: {
createdAt: 'desc',
},
orderBy: sortCondition,
take: input.limit + 1,
where: {
answerId: input.answerId,
answerId,
},
});
return questionAnswerCommentsData.map((data) => {
const processedQuestionAnswerCommentsData = questionAnswerCommentsData.map((data) => {
const votes: number = data.votes.reduce(
(previousValue: number, currentValue) => {
let result: number = previousValue;
@ -59,6 +85,22 @@ export const questionsAnswerCommentRouter = createRouter().query(
};
return answerComment;
});
let nextCursor: typeof cursor | undefined = undefined;
if (questionAnswerCommentsData.length > input.limit) {
const nextItem = questionAnswerCommentsData.pop()!;
processedQuestionAnswerCommentsData.pop();
const nextIdCursor: string | undefined = nextItem.id;
nextCursor = nextIdCursor;
}
return {
nextCursor,
processedQuestionAnswerCommentsData,
}
},
},
);

@ -39,7 +39,7 @@ export const questionsAnswerCommentUserRouter = createProtectedRouter()
},
});
if (answerCommentToUpdate?.id !== userId) {
if (answerCommentToUpdate?.userId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
@ -71,7 +71,7 @@ export const questionsAnswerCommentUserRouter = createProtectedRouter()
},
});
if (answerCommentToDelete?.id !== userId) {
if (answerCommentToDelete?.userId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
@ -100,130 +100,248 @@ export const questionsAnswerCommentUserRouter = createProtectedRouter()
});
},
})
.mutation('createVote', {
.mutation('setUpVote', {
input: z.object({
answerCommentId: z.string(),
vote: z.nativeEnum(Vote),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const { answerCommentId } = input;
const { answerCommentId, vote } = input;
return await ctx.prisma.$transaction(async (tx) => {
const answerCommentToUpdate =
await tx.questionsAnswerComment.findUnique({
where: {
id: answerCommentId,
},
});
const incrementValue = vote === Vote.UPVOTE ? 1 : -1;
if (answerCommentToUpdate === null) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Answer Comment do not exist.',
});
}
const [answerCommentVote] = await ctx.prisma.$transaction([
ctx.prisma.questionsAnswerCommentVote.create({
const vote = await tx.questionsAnswerCommentVote.findUnique({
where: {
answerCommentId_userId: { answerCommentId, userId },
},
});
if (vote === null) {
const createdVote = await tx.questionsAnswerCommentVote.create({
data: {
answerCommentId,
userId,
vote,
vote: Vote.UPVOTE,
},
}),
ctx.prisma.questionsAnswerComment.update({
});
await tx.questionsAnswerComment.update({
data: {
upvotes: {
increment: incrementValue,
increment: 1,
},
},
where: {
id: answerCommentId,
},
}),
]);
});
return createdVote;
}
if (vote!.userId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
});
}
if (vote!.vote === Vote.UPVOTE) {
return vote;
}
return answerCommentVote;
if (vote.vote === Vote.DOWNVOTE) {
const updatedVote = await tx.questionsAnswerCommentVote.update({
data: {
answerCommentId,
userId,
vote: Vote.UPVOTE,
},
where: {
id: vote.id,
},
});
await tx.questionsAnswerComment.update({
data: {
upvotes: {
increment: 2,
},
},
where: {
id: answerCommentId,
},
});
return updatedVote;
}
});
},
})
.mutation('updateVote', {
.mutation('setDownVote', {
input: z.object({
id: z.string(),
vote: z.nativeEnum(Vote),
answerCommentId: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const { id, vote } = input;
const { answerCommentId } = input;
const voteToUpdate =
await ctx.prisma.questionsAnswerCommentVote.findUnique({
return await ctx.prisma.$transaction(async (tx) => {
const answerCommentToUpdate =
await tx.questionsAnswerComment.findUnique({
where: {
id: input.id,
id: answerCommentId,
},
});
if (voteToUpdate?.userId !== userId) {
if (answerCommentToUpdate === null) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Answer Comment do not exist.',
});
}
const vote = await tx.questionsAnswerCommentVote.findUnique({
where: {
answerCommentId_userId: { answerCommentId, userId },
},
});
if (vote === null) {
const createdVote = await tx.questionsAnswerCommentVote.create({
data: {
answerCommentId,
userId,
vote: Vote.DOWNVOTE,
},
});
await tx.questionsAnswerComment.update({
data: {
upvotes: {
increment: -1,
},
},
where: {
id: answerCommentId,
},
});
return createdVote;
}
if (vote!.userId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
});
}
const incrementValue = vote === Vote.UPVOTE ? 2 : -2;
if (vote!.vote === Vote.DOWNVOTE) {
return vote;
}
const [answerCommentVote] = await ctx.prisma.$transaction([
ctx.prisma.questionsAnswerCommentVote.update({
if (vote.vote === Vote.UPVOTE) {
const updatedVote = await tx.questionsAnswerCommentVote.update({
data: {
vote,
answerCommentId,
userId,
vote: Vote.DOWNVOTE,
},
where: {
id,
id: vote.id,
},
}),
ctx.prisma.questionsAnswerComment.update({
});
await tx.questionsAnswerComment.update({
data: {
upvotes: {
increment: incrementValue,
increment: -2,
},
},
where: {
id: voteToUpdate.answerCommentId,
id: answerCommentId,
},
}),
]);
});
return answerCommentVote;
return updatedVote;
}
});
},
})
.mutation('deleteVote', {
.mutation('setNoVote', {
input: z.object({
id: z.string(),
answerCommentId: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const { answerCommentId } = input;
const voteToDelete =
await ctx.prisma.questionsAnswerCommentVote.findUnique({
return await ctx.prisma.$transaction(async (tx) => {
const answerCommentToUpdate =
await tx.questionsAnswerComment.findUnique({
where: {
id: input.id,
id: answerCommentId,
},
});
if (voteToDelete?.userId !== userId) {
if (answerCommentToUpdate === null) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Answer Comment do not exist.',
});
}
const voteToDelete = await tx.questionsAnswerCommentVote.findUnique({
where: {
answerCommentId_userId: { answerCommentId, userId },
},
});
if (voteToDelete === null) {
return null;
}
if (voteToDelete!.userId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
});
}
const incrementValue = voteToDelete.vote === Vote.UPVOTE ? -1 : 1;
const incrementValue = voteToDelete!.vote === Vote.UPVOTE ? -1 : 1;
const [answerCommentVote] = await ctx.prisma.$transaction([
ctx.prisma.questionsAnswerCommentVote.delete({
await tx.questionsAnswerCommentVote.delete({
where: {
id: input.id,
id: voteToDelete.id,
},
}),
ctx.prisma.questionsAnswerComment.update({
});
await tx.questionsAnswerComment.update({
data: {
upvotes: {
increment: incrementValue,
},
},
where: {
id: voteToDelete.answerCommentId,
id: answerCommentId,
},
}),
]);
return answerCommentVote;
});
return voteToDelete;
});
},
});

@ -5,16 +5,42 @@ import { TRPCError } from '@trpc/server';
import { createRouter } from '../context';
import type { Answer } from '~/types/questions';
import { SortOrder, SortType } from '~/types/questions.d';
export const questionsAnswerRouter = createRouter()
.query('getAnswers', {
input: z.object({
cursor: z.string().nullish(),
limit: z.number().min(1).default(50),
questionId: z.string(),
sortOrder: z.nativeEnum(SortOrder),
sortType: z.nativeEnum(SortType),
}),
async resolve({ ctx, input }) {
const { questionId } = input;
const { questionId, cursor } = input;
const sortCondition =
input.sortType === SortType.TOP
? [
{
upvotes: input.sortOrder,
},
{
id: input.sortOrder,
},
]
: [
{
updatedAt: input.sortOrder,
},
{
id: input.sortOrder,
},
];
const answersData = await ctx.prisma.questionsAnswer.findMany({
cursor: cursor ? { id: cursor } : undefined,
include: {
_count: {
select: {
@ -29,14 +55,14 @@ export const questionsAnswerRouter = createRouter()
},
votes: true,
},
orderBy: {
createdAt: 'desc',
},
orderBy: sortCondition,
take: input.limit + 1,
where: {
questionId,
},
});
return answersData.map((data) => {
const processedAnswersData = answersData.map((data) => {
const votes: number = data.votes.reduce(
(previousValue: number, currentValue) => {
let result: number = previousValue;
@ -65,6 +91,22 @@ export const questionsAnswerRouter = createRouter()
};
return answer;
});
let nextCursor: typeof cursor | undefined = undefined;
if (answersData.length > input.limit) {
const nextItem = answersData.pop()!;
processedAnswersData.pop();
const nextIdCursor: string | undefined = nextItem.id;
nextCursor = nextIdCursor;
}
return {
nextCursor,
processedAnswersData,
}
},
})
.query('getAnswerById', {

@ -39,7 +39,7 @@ export const questionsAnswerUserRouter = createProtectedRouter()
},
});
if (answerToUpdate?.id !== userId) {
if (answerToUpdate?.userId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
@ -69,7 +69,7 @@ export const questionsAnswerUserRouter = createProtectedRouter()
},
});
if (answerToDelete?.id !== userId) {
if (answerToDelete?.userId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
@ -98,127 +98,245 @@ export const questionsAnswerUserRouter = createProtectedRouter()
});
},
})
.mutation('createVote', {
.mutation('setUpVote', {
input: z.object({
answerId: z.string(),
vote: z.nativeEnum(Vote),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const { answerId } = input;
const { answerId, vote } = input;
return await ctx.prisma.$transaction(async (tx) => {
const answerToUpdate = await tx.questionsAnswer.findUnique({
where: {
id: answerId,
},
});
const incrementValue = vote === Vote.UPVOTE ? 1 : -1;
if (answerToUpdate === null) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Answer do not exist.',
});
}
const [answerVote] = await ctx.prisma.$transaction([
ctx.prisma.questionsAnswerVote.create({
const vote = await tx.questionsAnswerVote.findUnique({
where: {
answerId_userId: { answerId, userId },
},
});
if (vote === null) {
const createdVote = await tx.questionsAnswerVote.create({
data: {
answerId,
userId,
vote,
vote: Vote.UPVOTE,
},
}),
ctx.prisma.questionsAnswer.update({
});
await tx.questionsAnswer.update({
data: {
upvotes: {
increment: incrementValue,
increment: 1,
},
},
where: {
id: answerId,
},
}),
]);
return answerVote;
});
return createdVote;
}
if (vote!.userId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
});
}
if (vote!.vote === Vote.UPVOTE) {
return vote;
}
if (vote.vote === Vote.DOWNVOTE) {
const updatedVote = await tx.questionsAnswerVote.update({
data: {
answerId,
userId,
vote: Vote.UPVOTE,
},
where: {
id: vote.id,
},
});
await tx.questionsAnswer.update({
data: {
upvotes: {
increment: 2,
},
},
where: {
id: answerId,
},
});
return updatedVote;
}
});
},
})
.mutation('updateVote', {
.mutation('setDownVote', {
input: z.object({
id: z.string(),
vote: z.nativeEnum(Vote),
answerId: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const { id, vote } = input;
const { answerId } = input;
const voteToUpdate = await ctx.prisma.questionsAnswerVote.findUnique({
return await ctx.prisma.$transaction(async (tx) => {
const answerToUpdate = await tx.questionsAnswer.findUnique({
where: {
id: input.id,
id: answerId,
},
});
if (answerToUpdate === null) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Answer do not exist.',
});
}
const vote = await tx.questionsAnswerVote.findUnique({
where: {
answerId_userId: { answerId, userId },
},
});
if (vote === null) {
const createdVote = await tx.questionsAnswerVote.create({
data: {
answerId,
userId,
vote: Vote.DOWNVOTE,
},
});
await tx.questionsAnswer.update({
data: {
upvotes: {
increment: -1,
},
},
where: {
id: answerId,
},
});
if (voteToUpdate?.userId !== userId) {
return createdVote;
}
if (vote!.userId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
});
}
const incrementValue = vote === Vote.UPVOTE ? 2 : -2;
if (vote!.vote === Vote.DOWNVOTE) {
return vote;
}
const [questionsAnswerVote] = await ctx.prisma.$transaction([
ctx.prisma.questionsAnswerVote.update({
if (vote.vote === Vote.UPVOTE) {
const updatedVote = await tx.questionsAnswerVote.update({
data: {
vote,
answerId,
userId,
vote: Vote.DOWNVOTE,
},
where: {
id,
id: vote.id,
},
}),
ctx.prisma.questionsAnswer.update({
});
await tx.questionsAnswer.update({
data: {
upvotes: {
increment: incrementValue,
increment: -2,
},
},
where: {
id: voteToUpdate.answerId,
id: answerId,
},
}),
]);
});
return questionsAnswerVote;
return updatedVote;
}
});
},
})
.mutation('deleteVote', {
.mutation('setNoVote', {
input: z.object({
id: z.string(),
answerId: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const { answerId } = input;
const voteToDelete = await ctx.prisma.questionsAnswerVote.findUnique({
return await ctx.prisma.$transaction(async (tx) => {
const answerToUpdate = await tx.questionsAnswer.findUnique({
where: {
id: input.id,
id: answerId,
},
});
if (voteToDelete?.userId !== userId) {
if (answerToUpdate === null) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Answer do not exist.',
});
}
const voteToDelete = await tx.questionsAnswerVote.findUnique({
where: {
answerId_userId: { answerId, userId },
},
});
if (voteToDelete === null) {
return null;
}
if (voteToDelete!.userId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
});
}
const incrementValue = voteToDelete.vote === Vote.UPVOTE ? -1 : 1;
const incrementValue = voteToDelete!.vote === Vote.UPVOTE ? -1 : 1;
const [questionsAnswerVote] = await ctx.prisma.$transaction([
ctx.prisma.questionsAnswerVote.delete({
await tx.questionsAnswerVote.delete({
where: {
id: input.id,
id: voteToDelete.id,
},
}),
ctx.prisma.questionsAnswer.update({
});
await tx.questionsAnswer.update({
data: {
upvotes: {
increment: incrementValue,
},
},
where: {
id: voteToDelete.answerId,
id: answerId,
},
}),
]);
return questionsAnswerVote;
});
return voteToDelete;
});
},
});

@ -25,10 +25,12 @@ export const questionsListRouter = createProtectedRouter()
},
encounters: {
select: {
city: true,
company: true,
location: true,
country: true,
role: true,
seenAt: true,
state: true,
},
},
user: {
@ -83,10 +85,12 @@ export const questionsListRouter = createProtectedRouter()
},
encounters: {
select: {
city: true,
company: true,
location: true,
country: true,
role: true,
seenAt: true,
state: true,
},
},
user: {

@ -4,17 +4,43 @@ import { Vote } from '@prisma/client';
import { createRouter } from '../context';
import type { QuestionComment } from '~/types/questions';
import { SortOrder, SortType } from '~/types/questions.d';
export const questionsQuestionCommentRouter = createRouter().query(
'getQuestionComments',
{
input: z.object({
cursor: z.string().nullish(),
limit: z.number().min(1).default(50),
questionId: z.string(),
sortOrder: z.nativeEnum(SortOrder),
sortType: z.nativeEnum(SortType),
}),
async resolve({ ctx, input }) {
const { questionId } = input;
const { questionId, cursor } = input;
const sortCondition =
input.sortType === SortType.TOP
? [
{
upvotes: input.sortOrder,
},
{
id: input.sortOrder,
},
]
: [
{
updatedAt: input.sortOrder,
},
{
id: input.sortOrder,
},
];
const questionCommentsData =
await ctx.prisma.questionsQuestionComment.findMany({
cursor: cursor ? { id: cursor } : undefined,
include: {
user: {
select: {
@ -24,14 +50,13 @@ export const questionsQuestionCommentRouter = createRouter().query(
},
votes: true,
},
orderBy: {
createdAt: 'desc',
},
orderBy: sortCondition,
take: input.limit + 1,
where: {
questionId,
},
});
return questionCommentsData.map((data) => {
const processedQuestionCommentsData = questionCommentsData.map((data) => {
const votes: number = data.votes.reduce(
(previousValue: number, currentValue) => {
let result: number = previousValue;
@ -59,6 +84,22 @@ export const questionsQuestionCommentRouter = createRouter().query(
};
return questionComment;
});
let nextCursor: typeof cursor | undefined = undefined;
if (questionCommentsData.length > input.limit) {
const nextItem = questionCommentsData.pop()!;
processedQuestionCommentsData.pop();
const nextIdCursor: string | undefined = nextItem.id;
nextCursor = nextIdCursor;
}
return {
nextCursor,
processedQuestionCommentsData,
}
},
},
);

@ -41,7 +41,7 @@ export const questionsQuestionCommentUserRouter = createProtectedRouter()
},
});
if (questionCommentToUpdate?.id !== userId) {
if (questionCommentToUpdate?.userId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
@ -72,7 +72,7 @@ export const questionsQuestionCommentUserRouter = createProtectedRouter()
},
});
if (questionCommentToDelete?.id !== userId) {
if (questionCommentToDelete?.userId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
@ -101,128 +101,251 @@ export const questionsQuestionCommentUserRouter = createProtectedRouter()
});
},
})
.mutation('createVote', {
.mutation('setUpVote', {
input: z.object({
questionCommentId: z.string(),
vote: z.nativeEnum(Vote),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const { questionCommentId, vote } = input;
const { questionCommentId } = input;
return await ctx.prisma.$transaction(async (tx) => {
const questionCommentToUpdate =
await tx.questionsQuestionComment.findUnique({
where: {
id: questionCommentId,
},
});
if (questionCommentToUpdate === null) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Question Comment do not exist.',
});
}
const incrementValue: number = vote === Vote.UPVOTE ? 1 : -1;
const vote = await tx.questionsQuestionCommentVote.findUnique({
where: {
questionCommentId_userId: { questionCommentId, userId },
},
});
const [questionCommentVote] = await ctx.prisma.$transaction([
ctx.prisma.questionsQuestionCommentVote.create({
if (vote === null) {
const createdVote = await tx.questionsQuestionCommentVote.create({
data: {
questionCommentId,
userId,
vote,
vote: Vote.UPVOTE,
},
}),
ctx.prisma.questionsQuestionComment.update({
});
await tx.questionsQuestionComment.update({
data: {
upvotes: {
increment: incrementValue,
increment: 1,
},
},
where: {
id: questionCommentId,
},
}),
]);
return questionCommentVote;
});
return createdVote;
}
if (vote!.userId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
});
}
if (vote!.vote === Vote.UPVOTE) {
return vote;
}
if (vote.vote === Vote.DOWNVOTE) {
const updatedVote = await tx.questionsQuestionCommentVote.update({
data: {
questionCommentId,
userId,
vote: Vote.UPVOTE,
},
where: {
id: vote.id,
},
});
await tx.questionsQuestionComment.update({
data: {
upvotes: {
increment: 2,
},
},
where: {
id: questionCommentId,
},
});
return updatedVote;
}
});
},
})
.mutation('updateVote', {
.mutation('setDownVote', {
input: z.object({
id: z.string(),
vote: z.nativeEnum(Vote),
questionCommentId: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const { id, vote } = input;
const { questionCommentId } = input;
const voteToUpdate =
await ctx.prisma.questionsQuestionCommentVote.findUnique({
return await ctx.prisma.$transaction(async (tx) => {
const questionCommentToUpdate =
await tx.questionsQuestionComment.findUnique({
where: {
id: input.id,
id: questionCommentId,
},
});
if (voteToUpdate?.userId !== userId) {
if (questionCommentToUpdate === null) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
code: 'BAD_REQUEST',
message: 'Question Comment do not exist.',
});
}
const incrementValue = vote === Vote.UPVOTE ? 2 : -2;
const vote = await tx.questionsQuestionCommentVote.findUnique({
where: {
questionCommentId_userId: { questionCommentId, userId },
},
});
const [questionCommentVote] = await ctx.prisma.$transaction([
ctx.prisma.questionsQuestionCommentVote.update({
if (vote === null) {
const createdVote = await tx.questionsQuestionCommentVote.create({
data: {
vote,
questionCommentId,
userId,
vote: Vote.DOWNVOTE,
},
});
await tx.questionsQuestionComment.update({
data: {
upvotes: {
increment: -1,
},
},
where: {
id,
id: questionCommentId,
},
}),
ctx.prisma.questionsQuestionComment.update({
});
return createdVote;
}
if (vote!.userId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
});
}
if (vote!.vote === Vote.DOWNVOTE) {
return vote;
}
if (vote.vote === Vote.UPVOTE) {
tx.questionsQuestionCommentVote.delete({
where: {
id: vote.id,
},
});
const createdVote = await tx.questionsQuestionCommentVote.create({
data: {
questionCommentId,
userId,
vote: Vote.DOWNVOTE,
},
});
await tx.questionsQuestionComment.update({
data: {
upvotes: {
increment: incrementValue,
increment: -2,
},
},
where: {
id: voteToUpdate.questionCommentId,
id: questionCommentId,
},
}),
]);
});
return questionCommentVote;
return createdVote;
}
});
},
})
.mutation('deleteVote', {
.mutation('setNoVote', {
input: z.object({
id: z.string(),
questionCommentId: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const { questionCommentId } = input;
const voteToDelete =
await ctx.prisma.questionsQuestionCommentVote.findUnique({
return await ctx.prisma.$transaction(async (tx) => {
const questionCommentToUpdate =
await tx.questionsQuestionComment.findUnique({
where: {
id: input.id,
id: questionCommentId,
},
});
if (voteToDelete?.userId !== userId) {
if (questionCommentToUpdate === null) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Question Comment do not exist.',
});
}
const voteToDelete = await tx.questionsQuestionCommentVote.findUnique({
where: {
questionCommentId_userId: { questionCommentId, userId },
},
});
if (voteToDelete === null) {
return null;
}
if (voteToDelete!.userId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
});
}
const incrementValue = voteToDelete.vote === Vote.UPVOTE ? -1 : 1;
const incrementValue = voteToDelete!.vote === Vote.UPVOTE ? -1 : 1;
const [questionCommentVote] = await ctx.prisma.$transaction([
ctx.prisma.questionsQuestionCommentVote.delete({
await tx.questionsQuestionCommentVote.delete({
where: {
id: input.id,
id: voteToDelete.id,
},
}),
ctx.prisma.questionsQuestionComment.update({
});
await tx.questionsQuestionComment.update({
data: {
upvotes: {
increment: incrementValue,
},
},
where: {
id: voteToDelete.questionCommentId,
id: questionCommentId,
},
}),
]);
return questionCommentVote;
});
return voteToDelete;
});
},
});

@ -1,8 +1,8 @@
import { z } from 'zod';
import { createRouter } from '../context';
import { createAggregatedQuestionEncounter } from '~/utils/questions/server/aggregate-encounters';
import type { AggregatedQuestionEncounter } from '~/types/questions';
import { createRouter } from '../context';
export const questionsQuestionEncounterRouter = createRouter().query(
'getAggregatedEncounters',
@ -14,48 +14,17 @@ export const questionsQuestionEncounterRouter = createRouter().query(
const questionEncountersData =
await ctx.prisma.questionsQuestionEncounter.findMany({
include: {
city: true,
company: true,
country: true,
state: true,
},
where: {
...input,
},
});
const companyCounts: Record<string, number> = {};
const locationCounts: Record<string, number> = {};
const roleCounts: Record<string, number> = {};
let latestSeenAt = questionEncountersData[0].seenAt;
for (let i = 0; i < questionEncountersData.length; i++) {
const encounter = questionEncountersData[i];
latestSeenAt =
latestSeenAt < encounter.seenAt ? encounter.seenAt : latestSeenAt;
if (!(encounter.company!.name in companyCounts)) {
companyCounts[encounter.company!.name] = 1;
}
companyCounts[encounter.company!.name] += 1;
if (!(encounter.location in locationCounts)) {
locationCounts[encounter.location] = 1;
}
locationCounts[encounter.location] += 1;
if (!(encounter.role in roleCounts)) {
roleCounts[encounter.role] = 1;
}
roleCounts[encounter.role] += 1;
}
const questionEncounter: AggregatedQuestionEncounter = {
companyCounts,
latestSeenAt,
locationCounts,
roleCounts,
};
return questionEncounter;
return createAggregatedQuestionEncounter(questionEncountersData);
},
},
);

@ -1,38 +1,20 @@
import { z } from 'zod';
import { TRPCError } from '@trpc/server';
import { createAggregatedQuestionEncounter } from '~/utils/questions/server/aggregate-encounters';
import { createProtectedRouter } from '../context';
import { SortOrder } from '~/types/questions.d';
export const questionsQuestionEncounterUserRouter = createProtectedRouter()
.query('getAggregatedEncounters', {
input: z.object({
questionId: z.string(),
}),
async resolve({ ctx, input }) {
const questionEncountersData =
await ctx.prisma.questionsQuestionEncounter.findMany({
include: {
company: true,
},
where: {
...input,
},
});
return createAggregatedQuestionEncounter(questionEncountersData);
},
})
.mutation('create', {
input: z.object({
cityId: z.string().nullish(),
companyId: z.string(),
location: z.string(),
countryId: z.string(),
questionId: z.string(),
role: z.string(),
seenAt: z.date(),
stateId: z.string().nullish(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
@ -96,7 +78,7 @@ export const questionsQuestionEncounterUserRouter = createProtectedRouter()
},
});
if (questionEncounterToUpdate?.id !== userId) {
if (questionEncounterToUpdate?.userId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
@ -159,7 +141,7 @@ export const questionsQuestionEncounterUserRouter = createProtectedRouter()
},
});
if (questionEncounterToDelete?.id !== userId) {
if (questionEncounterToDelete?.userId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',

@ -11,22 +11,18 @@ import { SortOrder, SortType } from '~/types/questions.d';
export const questionsQuestionRouter = createRouter()
.query('getQuestionsByFilter', {
input: z.object({
companyNames: z.string().array(),
cursor: z
.object({
idCursor: z.string().optional(),
lastSeenCursor: z.date().nullish().optional(),
upvoteCursor: z.number().optional(),
})
.nullish(),
cityIds: z.string().array(),
companyIds: z.string().array(),
countryIds: z.string().array(),
cursor: z.string().nullish(),
endDate: z.date().default(new Date()),
limit: z.number().min(1).default(50),
locations: z.string().array(),
questionTypes: z.nativeEnum(QuestionsQuestionType).array(),
roles: z.string().array(),
sortOrder: z.nativeEnum(SortOrder),
sortType: z.nativeEnum(SortType),
startDate: z.date().optional(),
stateIds: z.string().array(),
}),
async resolve({ ctx, input }) {
const { cursor } = input;
@ -67,12 +63,7 @@ export const questionsQuestionRouter = createRouter()
}
const questionsData = await ctx.prisma.questionsQuestion.findMany({
cursor:
cursor !== undefined
? {
id: cursor ? cursor!.idCursor : undefined,
}
: undefined,
cursor: cursor ? { id: cursor } : undefined,
include: {
_count: {
select: {
@ -82,10 +73,12 @@ export const questionsQuestionRouter = createRouter()
},
encounters: {
select: {
city: true,
company: true,
location: true,
country: true,
role: true,
seenAt: true,
state: true,
},
},
user: {
@ -111,19 +104,39 @@ export const questionsQuestionRouter = createRouter()
gte: input.startDate,
lte: input.endDate,
},
...(input.companyNames.length > 0
...(input.companyIds.length > 0
? {
company: {
name: {
in: input.companyNames,
id: {
in: input.companyIds,
},
},
}
: {}),
...(input.cityIds.length > 0
? {
city: {
id: {
in: input.cityIds,
},
},
}
: {}),
...(input.locations.length > 0
...(input.countryIds.length > 0
? {
location: {
in: input.locations,
country: {
id: {
in: input.countryIds,
},
},
}
: {}),
...(input.stateIds.length > 0
? {
state: {
id: {
in: input.stateIds,
},
},
}
: {}),
@ -150,16 +163,8 @@ export const questionsQuestionRouter = createRouter()
processedQuestionsData.pop();
const nextIdCursor: string | undefined = nextItem.id;
const nextLastSeenCursor =
input.sortType === SortType.NEW ? nextItem.lastSeenAt : undefined;
const nextUpvoteCursor =
input.sortType === SortType.TOP ? nextItem.upvotes : undefined;
nextCursor = {
idCursor: nextIdCursor,
lastSeenCursor: nextLastSeenCursor,
upvoteCursor: nextUpvoteCursor,
};
nextCursor = nextIdCursor;
}
return {
@ -183,10 +188,12 @@ export const questionsQuestionRouter = createRouter()
},
encounters: {
select: {
city: true,
company: true,
location: true,
country: true,
role: true,
seenAt: true,
state: true,
},
},
user: {
@ -209,4 +216,68 @@ export const questionsQuestionRouter = createRouter()
return createQuestionWithAggregateData(questionData);
},
})
.query('getRelatedQuestions', {
input: z.object({
content: z.string(),
}),
async resolve({ ctx, input }) {
const escapeChars = /[()|&:*!]/g;
const query = input.content
.replace(escapeChars, ' ')
.trim()
.split(/\s+/)
.join(' | ');
const relatedQuestionsId: Array<{ id: string }> = await ctx.prisma
.$queryRaw`
SELECT id FROM "QuestionsQuestion"
WHERE
to_tsvector("content") @@ to_tsquery('english', ${query})
ORDER BY ts_rank_cd(to_tsvector("content"), to_tsquery('english', ${query}), 4) DESC;
`;
const relatedQuestionsIdArray = relatedQuestionsId.map(
(current) => current.id,
);
const relatedQuestionsData = await ctx.prisma.questionsQuestion.findMany({
include: {
_count: {
select: {
answers: true,
comments: true,
},
},
encounters: {
select: {
city: true,
company: true,
country: true,
role: true,
seenAt: true,
state: true,
},
},
user: {
select: {
name: true,
},
},
votes: true,
},
where: {
id: {
in: relatedQuestionsIdArray,
},
},
});
const processedQuestionsData = relatedQuestionsData.map(
createQuestionWithAggregateData,
);
return processedQuestionsData;
},
});

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save