Merge branch 'main' into jeffsieu/similar-questions

pull/468/head
Jeff Sieu 3 years ago
commit 5adc9d4a15

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

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

@ -1,8 +0,0 @@
-- AlterTable
ALTER TABLE "QuestionsQuestion" ADD COLUMN "contentSearch" TSVECTOR
GENERATED ALWAYS AS
(to_tsvector('english', coalesce(content, '')))
STORED;
-- CreateIndex
CREATE INDEX "QuestionsQuestion_contentSearch_idx" ON "QuestionsQuestion" USING GIN("contentSearch");

@ -1,8 +0,0 @@
-- DropIndex
DROP INDEX "QuestionsQuestion_contentSearch_idx";
-- AlterTable
ALTER TABLE "QuestionsQuestion" ALTER COLUMN "contentSearch" DROP DEFAULT;
-- CreateIndex
CREATE INDEX "QuestionsQuestion_contentSearch_idx" ON "QuestionsQuestion"("contentSearch");

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "ResumesResume" ADD COLUMN "isResolved" BOOLEAN NOT NULL DEFAULT false;

@ -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';

@ -106,6 +106,35 @@ model Company {
OffersOffer OffersOffer[] 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. // Start of Resumes project models.
// Add Resumes project models here, prefix all models with "Resumes", // Add Resumes project models here, prefix all models with "Resumes",
// use camelCase for field names, and try to name them consistently // use camelCase for field names, and try to name them consistently
@ -120,6 +149,7 @@ model ResumesResume {
location String @db.Text location String @db.Text
url String url String
additionalInfo String? @db.Text additionalInfo String? @db.Text
isResolved Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@ -396,6 +426,7 @@ enum QuestionsQuestionType {
CODING CODING
SYSTEM_DESIGN SYSTEM_DESIGN
BEHAVIORAL BEHAVIORAL
THEORY
} }
model QuestionsQuestion { model QuestionsQuestion {
@ -408,12 +439,12 @@ model QuestionsQuestion {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
user User? @relation(fields: [userId], references: [id], onDelete: SetNull) user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
encounters QuestionsQuestionEncounter[] encounters QuestionsQuestionEncounter[]
votes QuestionsQuestionVote[] votes QuestionsQuestionVote[]
comments QuestionsQuestionComment[] comments QuestionsQuestionComment[]
answers QuestionsAnswer[] answers QuestionsAnswer[]
QuestionsListQuestionEntry QuestionsListQuestionEntry[] questionsListQuestionEntries QuestionsListQuestionEntry[]
@@index([lastSeenAt, id]) @@index([lastSeenAt, id])
@@index([upvotes, id]) @@index([upvotes, id])
@ -423,14 +454,18 @@ model QuestionsQuestionEncounter {
id String @id @default(cuid()) id String @id @default(cuid())
questionId String questionId String
userId String? userId String?
// TODO: sync with models (location, role) companyId String?
companyId String countryId String?
location String @db.Text stateId String?
cityId String?
role String @db.Text role String @db.Text
seenAt DateTime seenAt DateTime
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt 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) company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull)
user User? @relation(fields: [userId], references: [id], onDelete: SetNull) user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
question QuestionsQuestion @relation(fields: [questionId], references: [id], onDelete: Cascade) question QuestionsQuestion @relation(fields: [questionId], references: [id], onDelete: Cascade)
@ -462,6 +497,9 @@ model QuestionsQuestionComment {
user User? @relation(fields: [userId], references: [id], onDelete: SetNull) user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
question QuestionsQuestion @relation(fields: [questionId], references: [id], onDelete: Cascade) question QuestionsQuestion @relation(fields: [questionId], references: [id], onDelete: Cascade)
votes QuestionsQuestionCommentVote[] votes QuestionsQuestionCommentVote[]
@@index([updatedAt, id])
@@index([upvotes, id])
} }
model QuestionsQuestionCommentVote { model QuestionsQuestionCommentVote {
@ -491,6 +529,9 @@ model QuestionsAnswer {
question QuestionsQuestion @relation(fields: [questionId], references: [id], onDelete: Cascade) question QuestionsQuestion @relation(fields: [questionId], references: [id], onDelete: Cascade)
votes QuestionsAnswerVote[] votes QuestionsAnswerVote[]
comments QuestionsAnswerComment[] comments QuestionsAnswerComment[]
@@index([updatedAt, id])
@@index([upvotes, id])
} }
model QuestionsAnswerVote { model QuestionsAnswerVote {
@ -519,6 +560,9 @@ model QuestionsAnswerComment {
user User? @relation(fields: [userId], references: [id], onDelete: SetNull) user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
answer QuestionsAnswer @relation(fields: [answerId], references: [id], onDelete: Cascade) answer QuestionsAnswer @relation(fields: [answerId], references: [id], onDelete: Cascade)
votes QuestionsAnswerCommentVote[] votes QuestionsAnswerCommentVote[]
@@index([updatedAt, id])
@@index([upvotes, id])
} }
model QuestionsAnswerCommentVote { model QuestionsAnswerCommentVote {

@ -0,0 +1,207 @@
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: {
location: 'Singapore',
role: selectRandomRole(),
seenAt: generateRandomDate(),
},
},
}),
);
const BEHAVIORAL_QUESTIONS: Array<QuestionCreateData> =
BEHAVIORAL_QUESTION_CONTENT.map((content) => ({
content,
questionType: 'BEHAVIORAL',
userId: null,
encounters: {
create: {
location: 'Singapore',
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.',
);
}
// 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,
} 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 { 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 prisma = new PrismaClient();
const COMPANIES = [ 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.`, 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', logoUrl: 'https://logo.clearbit.com/microsoft.com',
}, },
{
name: 'Netflix',
slug: 'netflix',
description: null,
logoUrl: 'https://logo.clearbit.com/netflix.com',
},
]; ];
async function main() { async function main() {
console.log('Seeding started...'); console.log('Seeding started...');
await Promise.all([
COMPANIES.map(async (company) => { console.info('Seeding companies');
await prisma.company.upsert({ await prisma.company.createMany({
where: { slug: company.slug }, data: COMPANIES.map((company) => ({
update: company, name: company.name,
create: company, 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.'); console.log('Seeding completed.');
} }

@ -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,10 +9,13 @@ import { Bars3BottomLeftIcon } from '@heroicons/react/24/outline';
import GlobalNavigation from '~/components/global/GlobalNavigation'; import GlobalNavigation from '~/components/global/GlobalNavigation';
import HomeNavigation from '~/components/global/HomeNavigation'; 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 QuestionsNavigation from '~/components/questions/QuestionsNavigation';
import ResumesNavigation from '~/components/resumes/ResumesNavigation'; import ResumesNavigation from '~/components/resumes/ResumesNavigation';
import GoogleAnalytics from './GoogleAnalytics';
import MobileNavigation from './MobileNavigation'; import MobileNavigation from './MobileNavigation';
import type { ProductNavigationItems } from './ProductNavigation'; import type { ProductNavigationItems } from './ProductNavigation';
import ProductNavigation from './ProductNavigation'; import ProductNavigation from './ProductNavigation';
@ -32,7 +35,7 @@ function ProfileJewel() {
if (session == null) { if (session == null) {
return ( return (
<Link <Link
className="text-sm font-medium" className="text-base"
href="/api/auth/signin" href="/api/auth/signin"
onClick={(event) => { onClick={(event) => {
event.preventDefault(); event.preventDefault();
@ -104,8 +107,11 @@ function ProfileJewel() {
export default function AppShell({ children }: Props) { export default function AppShell({ children }: Props) {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const router = useRouter(); const router = useRouter();
const { data: session } = useSession();
const currentProductNavigation: Readonly<{ const currentProductNavigation: Readonly<{
googleAnalyticsMeasurementID: string;
logo?: React.ReactNode;
navigation: ProductNavigationItems; navigation: ProductNavigationItems;
showGlobalNav: boolean; showGlobalNav: boolean;
title: string; title: string;
@ -117,7 +123,10 @@ export default function AppShell({ children }: Props) {
} }
if (path.startsWith('/offers')) { if (path.startsWith('/offers')) {
return OffersNavigation; if (session == null) {
return OffersNavigation;
}
return OffersNavigationAuthenticated;
} }
if (path.startsWith('/questions')) { if (path.startsWith('/questions')) {
@ -128,84 +137,87 @@ export default function AppShell({ children }: Props) {
})(); })();
return ( return (
<div className="flex h-full min-h-screen"> <GoogleAnalytics
{/* Narrow sidebar */} measurementID={currentProductNavigation.googleAnalyticsMeasurementID}>
{currentProductNavigation.showGlobalNav && ( <div className="flex h-full min-h-screen">
<div className="hidden w-28 overflow-y-auto border-r border-slate-200 bg-white md:block"> {/* Narrow sidebar */}
<div className="flex w-full flex-col items-center py-6"> {currentProductNavigation.showGlobalNav && (
<div className="flex flex-shrink-0 items-center"> <div className="hidden w-28 overflow-y-auto border-r border-slate-200 bg-white md:block">
<Link href="/"> <div className="flex w-full flex-col items-center py-6">
<img <div className="flex flex-shrink-0 items-center">
alt="Tech Interview Handbook" <Link href="/">
className="h-8 w-auto" <img
src="/logo.svg" alt="Tech Interview Handbook"
/> className="h-8 w-auto"
</Link> src="/logo.svg"
</div>
<div className="mt-6 w-full flex-1 space-y-1 px-2">
{GlobalNavigation.map((item) => (
<Link
key={item.name}
className={clsx(
'text-slate-700 hover:bg-slate-100',
'group flex w-full flex-col items-center rounded-md p-3 text-xs font-medium',
)}
href={item.href}>
<item.icon
aria-hidden="true"
className={clsx(
'text-slate-500 group-hover:text-slate-700',
'h-6 w-6',
)}
/> />
<span className="mt-2">{item.name}</span>
</Link> </Link>
))}
</div>
</div>
</div>
)}
{/* Mobile menu */}
<MobileNavigation
globalNavigationItems={GlobalNavigation}
isShown={mobileMenuOpen}
productNavigationItems={currentProductNavigation.navigation}
productTitle={currentProductNavigation.title}
setIsShown={setMobileMenuOpen}
/>
{/* Content area */}
<div className="flex h-screen flex-1 flex-col overflow-hidden">
<header className="w-full">
<div className="relative z-10 flex h-16 flex-shrink-0 border-b border-slate-200 bg-white shadow-sm">
<button
className="focus:ring-primary-500 border-r border-slate-200 px-4 text-slate-500 focus:outline-none focus:ring-2 focus:ring-inset md:hidden"
type="button"
onClick={() => setMobileMenuOpen(true)}>
<span className="sr-only">Open sidebar</span>
<Bars3BottomLeftIcon aria-hidden="true" className="h-6 w-6" />
</button>
<div className="flex flex-1 justify-between px-4 sm:px-6">
<div className="flex flex-1 items-center">
<ProductNavigation
items={currentProductNavigation.navigation}
title={currentProductNavigation.title}
titleHref={currentProductNavigation.titleHref}
/>
</div> </div>
<div className="ml-2 flex items-center space-x-4 sm:ml-6 sm:space-x-6"> <div className="mt-6 w-full flex-1 space-y-1 px-2">
<ProfileJewel /> {GlobalNavigation.map((item) => (
<Link
key={item.name}
className={clsx(
'text-slate-700 hover:bg-slate-100',
'group flex w-full flex-col items-center rounded-md p-3 text-xs font-medium',
)}
href={item.href}>
<item.icon
aria-hidden="true"
className={clsx(
'text-slate-500 group-hover:text-slate-700',
'h-6 w-6',
)}
/>
<span className="mt-2">{item.name}</span>
</Link>
))}
</div> </div>
</div> </div>
</div> </div>
</header> )}
{/* Mobile menu */}
<MobileNavigation
globalNavigationItems={GlobalNavigation}
isShown={mobileMenuOpen}
logo={currentProductNavigation.logo}
productNavigationItems={currentProductNavigation.navigation}
productTitle={currentProductNavigation.title}
setIsShown={setMobileMenuOpen}
/>
{/* Content area */}
<div className="flex h-screen flex-1 flex-col overflow-hidden">
<header className="w-full">
<div className="relative z-10 flex h-16 flex-shrink-0 border-b border-slate-200 bg-white shadow-sm">
<button
className="focus:ring-primary-500 border-r border-slate-200 px-4 text-slate-500 focus:outline-none focus:ring-2 focus:ring-inset md:hidden"
type="button"
onClick={() => setMobileMenuOpen(true)}>
<span className="sr-only">Open sidebar</span>
<Bars3BottomLeftIcon aria-hidden="true" className="h-6 w-6" />
</button>
<div className="flex flex-1 justify-between px-4 sm:px-6">
<div className="flex flex-1 items-center">
<ProductNavigation
items={currentProductNavigation.navigation}
logo={currentProductNavigation.logo}
title={currentProductNavigation.title}
titleHref={currentProductNavigation.titleHref}
/>
</div>
<div className="ml-2 flex items-center space-x-4 sm:ml-6 sm:space-x-6">
<ProfileJewel />
</div>
</div>
</div>
</header>
{/* Main content */} {/* Main content */}
<div className="flex flex-1 items-stretch overflow-hidden"> <div className="flex flex-1 items-stretch overflow-hidden">
{children} {children}
</div>
</div> </div>
</div> </div>
</div> </GoogleAnalytics>
); );
} }

@ -0,0 +1,103 @@
import Head from 'next/head';
import { useRouter } from 'next/router';
import { createContext, useContext, useEffect } from 'react';
type Context = Readonly<{
event: (payload: GoogleAnalyticsEventPayload) => void;
}>;
export const GoogleAnalyticsContext = createContext<Context>({
event,
});
// https://developers.google.com/analytics/devguides/collection/gtagjs/pages
function pageview(measurementID: string, url: string) {
// Don't log analytics during development.
if (process.env.NODE_ENV === 'development') {
return;
}
window.gtag('config', measurementID, {
page_path: url,
});
window.gtag('event', url, {
event_category: 'pageview',
event_label: document.title,
});
}
type GoogleAnalyticsEventPayload = Readonly<{
action: string;
category: string;
label: string;
value?: number;
}>;
// https://developers.google.com/analytics/devguides/collection/gtagjs/events
export function event({
action,
category,
label,
value,
}: GoogleAnalyticsEventPayload) {
// Don't log analytics during development.
if (process.env.NODE_ENV === 'development') {
return;
}
window.gtag('event', action, {
event_category: category,
event_label: label,
value,
});
}
type Props = Readonly<{
children: React.ReactNode;
measurementID: string;
}>;
export function useGoogleAnalytics() {
return useContext(GoogleAnalyticsContext);
}
export default function GoogleAnalytics({ children, measurementID }: Props) {
const router = useRouter();
useEffect(() => {
function handleRouteChange(url: string) {
pageview(measurementID, url);
}
router.events.on('routeChangeComplete', handleRouteChange);
return () => {
router.events.off('routeChangeComplete', handleRouteChange);
};
}, [router.events, measurementID]);
return (
<GoogleAnalyticsContext.Provider value={{ event }}>
{children}
<Head>
{/* TODO(yangshun): Change back to next/script in future. */}
{/* Global Site Tag (gtag.js) - Google Analytics */}
<script
async={true}
src={`https://www.googletagmanager.com/gtag/js?id=${measurementID}`}
/>
<script
dangerouslySetInnerHTML={{
__html: `
window.dataLayer = window.dataLayer || [];
window.gtag = function(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '${measurementID}', {
page_path: window.location.pathname,
});
`,
}}
/>
</Head>
</GoogleAnalyticsContext.Provider>
);
}

@ -14,6 +14,7 @@ const navigation: ProductNavigationItems = [
]; ];
const config = { const config = {
googleAnalyticsMeasurementID: 'G-DBLZDQ2ZZN',
navigation, navigation,
showGlobalNav: true, showGlobalNav: true,
title: 'Tech Interview Handbook', title: 'Tech Interview Handbook',

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

@ -17,21 +17,32 @@ export type ProductNavigationItems = ReadonlyArray<NavigationItem>;
type Props = Readonly<{ type Props = Readonly<{
items: ProductNavigationItems; items: ProductNavigationItems;
logo?: React.ReactNode;
title: string; title: string;
titleHref: string; titleHref: string;
}>; }>;
export default function ProductNavigation({ items, title, titleHref }: Props) { export default function ProductNavigation({
items,
logo,
title,
titleHref,
}: Props) {
const router = useRouter(); const router = useRouter();
return ( return (
<nav aria-label="Global" className="flex h-full items-center space-x-8"> <nav aria-label="Global" className="flex h-full items-center space-x-8">
<Link <Link
className="hover:text-primary-700 flex items-center gap-2 text-sm font-medium" className="hover:text-primary-700 flex items-center gap-2 text-base font-medium"
href={titleHref}> href={titleHref}>
{titleHref !== '/' && ( {titleHref !== '/' &&
<img alt="TIH" className="h-8 w-auto" src="/logo.svg" /> (logo ?? (
)} <img
alt="Tech Interview Handbook"
className="h-8 w-auto"
src="/logo.svg"
/>
))}
{title} {title}
</Link> </Link>
<div className="hidden h-full items-center space-x-8 md:flex"> <div className="hidden h-full items-center space-x-8 md:flex">
@ -39,7 +50,7 @@ export default function ProductNavigation({ items, title, titleHref }: Props) {
const isActive = router.pathname === item.href; const isActive = router.pathname === item.href;
return item.children != null && item.children.length > 0 ? ( return item.children != null && item.children.length > 0 ? (
<Menu key={item.name} as="div" className="relative text-left"> <Menu key={item.name} as="div" className="relative text-left">
<Menu.Button className="focus:ring-primary-600 flex items-center rounded-md text-sm font-medium text-slate-900 focus:outline-none focus:ring-2 focus:ring-offset-2"> <Menu.Button className="focus:ring-primary-600 flex items-center rounded-md text-base font-medium text-slate-900 focus:outline-none focus:ring-2 focus:ring-offset-2">
<span>{item.name}</span> <span>{item.name}</span>
<ChevronDownIcon <ChevronDownIcon
aria-hidden="true" aria-hidden="true"
@ -62,7 +73,7 @@ export default function ProductNavigation({ items, title, titleHref }: Props) {
<Link <Link
className={clsx( className={clsx(
active ? 'bg-slate-100' : '', active ? 'bg-slate-100' : '',
'block px-4 py-2 text-sm text-slate-700', 'block px-4 py-2 text-base text-slate-700',
)} )}
href={child.href} href={child.href}
rel={ rel={
@ -84,7 +95,7 @@ export default function ProductNavigation({ items, title, titleHref }: Props) {
<Link <Link
key={item.name} key={item.name}
className={clsx( className={clsx(
'hover:text-primary-600 inline-flex h-full items-center border-y-2 border-t-transparent text-sm text-slate-900', 'hover:text-primary-600 inline-flex h-full items-center border-y-2 border-t-transparent text-base text-slate-900',
isActive ? 'border-b-primary-500' : 'border-b-transparent', isActive ? 'border-b-primary-500' : 'border-b-transparent',
)} )}
href={item.href} href={item.href}

@ -1,21 +1,43 @@
export type BreadcrumbStep = {
label: string;
step?: number;
};
type BreadcrumbsProps = Readonly<{ type BreadcrumbsProps = Readonly<{
currentStep: number; currentStep: number;
stepLabels: Array<string>; setStep: (nextStep: number) => void;
steps: Array<BreadcrumbStep>;
}>; }>;
export function Breadcrumbs({ stepLabels, currentStep }: BreadcrumbsProps) { function getPrimaryText(text: string) {
return <p className="text-primary-700 text-sm">{text}</p>;
}
function getSlateText(text: string) {
return <p className="text-sm text-slate-400">{text}</p>;
}
function getTextWithLink(text: string, onClickHandler: () => void) {
return (
<p
className="hover:text-primary-700 cursor-pointer text-sm text-slate-400 hover:underline hover:underline-offset-2"
onClick={onClickHandler}>
{text}
</p>
);
}
export function Breadcrumbs({ steps, currentStep, setStep }: BreadcrumbsProps) {
return ( return (
<div className="flex space-x-1"> <div className="flex space-x-1">
{stepLabels.map((label, index) => ( {steps.map(({ label, step }, index) => (
<div key={label} className="flex space-x-1"> <div key={label} className="flex space-x-1">
{index === currentStep ? ( {step === currentStep
<p className="text-primary-700 text-sm">{label}</p> ? getPrimaryText(label)
) : ( : step !== undefined
<p className="text-sm text-slate-400">{label}</p> ? getTextWithLink(label, () => setStep(step))
)} : getSlateText(label)}
{index !== stepLabels.length - 1 && ( {index !== steps.length - 1 && getSlateText('>')}
<p className="text-sm text-slate-400">{'>'}</p>
)}
</div> </div>
))} ))}
</div> </div>

@ -1,15 +0,0 @@
import type { ProductNavigationItems } from '~/components/global/ProductNavigation';
const navigation: ProductNavigationItems = [
{ href: '/offers/browse', name: 'Browse' },
{ href: '/offers/submit', name: 'Analyse your offers' },
];
const config = {
navigation,
showGlobalNav: false,
title: 'Offer Profile Repository',
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 repository' },
{ 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;

@ -1,18 +0,0 @@
export default function OffersTitle() {
return (
<>
<div className="flex items-end justify-center">
<h1 className="text-primary-600 mt-16 text-center text-4xl font-bold">
Offer Profile Repository
</h1>
</div>
<div className="text-primary-500 mt-2 text-center text-2xl font-normal">
Reveal profile stories behind offers
</div>
<div className="items-top flex justify-center text-xl font-normal">
Click into offers to view profiles, benchmark your offers and profiles,
and discuss with the community
</div>
</>
);
}

@ -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>
);
}

@ -1,3 +1,5 @@
import type { StaticImageData } from 'next/image';
import Image from 'next/image';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { HOME_URL } from '~/components/offers/types'; import { HOME_URL } from '~/components/offers/types';
@ -6,7 +8,7 @@ type LeftTextCardProps = Readonly<{
description: string; description: string;
icon: ReactNode; icon: ReactNode;
imageAlt: string; imageAlt: string;
imageSrc: string; imageSrc: StaticImageData;
title: string; title: string;
}>; }>;
@ -18,7 +20,7 @@ export default function LeftTextCard({
title, title,
}: LeftTextCardProps) { }: LeftTextCardProps) {
return ( return (
<div className="lg:mx-auto lg:grid lg:max-w-7xl lg:grid-flow-col-dense lg:grid-cols-2 lg:gap-24 lg:px-8"> <div className="items-center lg:mx-auto lg:grid lg:max-w-7xl lg:grid-flow-col-dense lg:grid-cols-2 lg:gap-24 lg:px-8">
<div className="mx-auto max-w-xl px-4 sm:px-6 lg:mx-0 lg:max-w-none lg:py-16 lg:px-0"> <div className="mx-auto max-w-xl px-4 sm:px-6 lg:mx-0 lg:max-w-none lg:py-16 lg:px-0">
<div> <div>
<div> <div>
@ -42,10 +44,10 @@ export default function LeftTextCard({
</div> </div>
</div> </div>
<div className="mt-12 sm:mt-16 lg:mt-0"> <div className="mt-12 sm:mt-16 lg:mt-0">
<div className="-mr-48 pl-4 sm:pl-6 md:-mr-16 lg:relative lg:m-0 lg:h-full lg:px-0"> <div className="-mr-48 w-full rounded-xl pl-4 shadow-xl ring-1 ring-black ring-opacity-5 sm:pl-6 md:-mr-16 lg:relative lg:m-0 lg:h-full lg:px-0">
<img <Image
alt={imageAlt} alt={imageAlt}
className="w-full rounded-xl shadow-xl ring-1 ring-black ring-opacity-5 lg:absolute lg:left-0 lg:h-full lg:w-auto lg:max-w-none" className="lg:absolute lg:left-0 lg:h-full lg:w-auto lg:max-w-none"
src={imageSrc} src={imageSrc}
/> />
</div> </div>

@ -1,3 +1,5 @@
import type { StaticImageData } from 'next/image';
import Image from 'next/image';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { HOME_URL } from '~/components/offers/types'; import { HOME_URL } from '~/components/offers/types';
@ -6,7 +8,7 @@ type RightTextCarddProps = Readonly<{
description: string; description: string;
icon: ReactNode; icon: ReactNode;
imageAlt: string; imageAlt: string;
imageSrc: string; imageSrc: StaticImageData;
title: string; title: string;
}>; }>;
@ -18,7 +20,7 @@ export default function RightTextCard({
title, title,
}: RightTextCarddProps) { }: RightTextCarddProps) {
return ( return (
<div className="lg:mx-auto lg:grid lg:max-w-7xl lg:grid-flow-col-dense lg:grid-cols-2 lg:gap-24 lg:px-8"> <div className="items-center lg:mx-auto lg:grid lg:max-w-7xl lg:grid-flow-col-dense lg:grid-cols-2 lg:gap-24 lg:px-8">
<div className="mx-auto max-w-xl px-4 sm:px-6 lg:col-start-2 lg:mx-0 lg:max-w-none lg:py-32 lg:px-0"> <div className="mx-auto max-w-xl px-4 sm:px-6 lg:col-start-2 lg:mx-0 lg:max-w-none lg:py-32 lg:px-0">
<div> <div>
<div> <div>
@ -42,10 +44,10 @@ export default function RightTextCard({
</div> </div>
</div> </div>
<div className="mt-12 sm:mt-16 lg:col-start-1 lg:mt-0"> <div className="mt-12 sm:mt-16 lg:col-start-1 lg:mt-0">
<div className="-ml-48 pr-4 sm:pr-6 md:-ml-16 lg:relative lg:m-0 lg:h-full lg:px-0"> <div className="-ml-48 w-full rounded-xl pr-4 shadow-xl ring-1 ring-black ring-opacity-5 sm:pr-6 md:-ml-16 lg:relative lg:m-0 lg:h-full lg:px-0 ">
<img <Image
alt={imageAlt} alt={imageAlt}
className="w-full rounded-xl shadow-xl ring-1 ring-black ring-opacity-5 lg:absolute lg:right-0 lg:h-full lg:w-auto lg:max-w-none" className="lg:absolute lg:right-0 lg:h-full lg:w-auto lg:max-w-none"
src={imageSrc} src={imageSrc}
/> />
</div> </div>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 994 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 923 KiB

@ -19,12 +19,14 @@ type OfferAnalysisData = {
type OfferAnalysisContentProps = Readonly<{ type OfferAnalysisContentProps = Readonly<{
analysis: OfferAnalysisData; analysis: OfferAnalysisData;
isSubmission: boolean;
tab: string; tab: string;
}>; }>;
function OfferAnalysisContent({ function OfferAnalysisContent({
analysis: { offer, offerAnalysis }, analysis: { offer, offerAnalysis },
tab, tab,
isSubmission,
}: OfferAnalysisContentProps) { }: OfferAnalysisContentProps) {
if (!offerAnalysis || !offer || offerAnalysis.noOfOffers === 0) { if (!offerAnalysis || !offer || offerAnalysis.noOfOffers === 0) {
if (tab === OVERALL_TAB) { if (tab === OVERALL_TAB) {
@ -46,16 +48,30 @@ function OfferAnalysisContent({
<> <>
<OfferPercentileAnalysisText <OfferPercentileAnalysisText
companyName={offer.company.name} companyName={offer.company.name}
isSubmission={isSubmission}
offerAnalysis={offerAnalysis} offerAnalysis={offerAnalysis}
tab={tab} tab={tab}
/> />
<p className="mt-5">Here are some of the top offers relevant to you:</p> <p className="mt-5">
{isSubmission
? 'Here are some of the top offers relevant to you:'
: 'Relevant top offers:'}
</p>
{offerAnalysis.topPercentileOffers.map((topPercentileOffer) => ( {offerAnalysis.topPercentileOffers.map((topPercentileOffer) => (
<OfferProfileCard <OfferProfileCard
key={topPercentileOffer.id} key={topPercentileOffer.id}
offerProfile={topPercentileOffer} offerProfile={topPercentileOffer}
/> />
))} ))}
{/* {offerAnalysis.topPercentileOffers.length > 0 && (
<div className="mb-4 flex justify-end">
<Button
icon={EllipsisHorizontalIcon}
label="View more offers"
variant="tertiary"
/>
</div>
)} */}
</> </>
); );
} }
@ -64,12 +80,14 @@ type OfferAnalysisProps = Readonly<{
allAnalysis?: ProfileAnalysis | null; allAnalysis?: ProfileAnalysis | null;
isError: boolean; isError: boolean;
isLoading: boolean; isLoading: boolean;
isSubmission?: boolean;
}>; }>;
export default function OfferAnalysis({ export default function OfferAnalysis({
allAnalysis, allAnalysis,
isError, isError,
isLoading, isLoading,
isSubmission = false,
}: OfferAnalysisProps) { }: OfferAnalysisProps) {
const [tab, setTab] = useState(OVERALL_TAB); const [tab, setTab] = useState(OVERALL_TAB);
const [analysis, setAnalysis] = useState<OfferAnalysisData | null>(null); const [analysis, setAnalysis] = useState<OfferAnalysisData | null>(null);
@ -100,27 +118,33 @@ export default function OfferAnalysis({
]; ];
return ( return (
analysis && ( <>
<div> {isLoading && <Spinner className="m-10" display="block" size="lg" />}
{isError && ( {analysis && (
<p className="m-10 text-center"> <div>
An error occurred while generating profile analysis. {isError && (
</p> <p className="m-10 text-center">
)} An error occurred while generating profile analysis.
{isLoading && <Spinner className="m-10" display="block" size="lg" />} </p>
{!isError && !isLoading && ( )}
<div> {!isError && !isLoading && (
<Tabs <div>
label="Result Navigation" <Tabs
tabs={tabOptions} label="Result Navigation"
value={tab} tabs={tabOptions}
onChange={setTab} value={tab}
/> onChange={setTab}
<HorizontalDivider className="mb-5" /> />
<OfferAnalysisContent analysis={analysis} tab={tab} /> <HorizontalDivider className="mb-5" />
</div> <OfferAnalysisContent
)} analysis={analysis}
</div> isSubmission={isSubmission}
) tab={tab}
/>
</div>
)}
</div>
)}
</>
); );
} }

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

@ -12,6 +12,7 @@ import { convertMoneyToString } from '~/utils/offers/currency';
import { formatDate } from '~/utils/offers/time'; import { formatDate } from '~/utils/offers/time';
import ProfilePhotoHolder from '../profile/ProfilePhotoHolder'; import ProfilePhotoHolder from '../profile/ProfilePhotoHolder';
import { JobTypeLabel } from '../types';
import type { AnalysisOffer } from '~/types/offers'; import type { AnalysisOffer } from '~/types/offers';
@ -34,7 +35,12 @@ export default function OfferProfileCard({
}, },
}: OfferProfileCardProps) { }: OfferProfileCardProps) {
return ( 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 className="flex items-center gap-x-5">
<div> <div>
<ProfilePhotoHolder size="sm" /> <ProfilePhotoHolder size="sm" />
@ -58,7 +64,8 @@ export default function OfferProfileCard({
<div className="flex items-end justify-between"> <div className="flex items-end justify-between">
<div className="col-span-1 row-span-3"> <div className="col-span-1 row-span-3">
<p className="font-bold"> <p className="font-bold">
{getLabelForJobTitleType(title as JobTitleType)} {getLabelForJobTitleType(title as JobTitleType)}{' '}
{`(${JobTypeLabel[jobType]})`}
</p> </p>
<p> <p>
Company: {company.name}, {location} Company: {company.name}, {location}

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

@ -1,32 +1,19 @@
import { useRouter } from 'next/router';
import { EyeIcon } from '@heroicons/react/24/outline';
import { Button } from '~/../../../packages/ui/dist';
import { getProfilePath } from '~/utils/offers/link';
import OfferAnalysis from '../offerAnalysis/OfferAnalysis'; import OfferAnalysis from '../offerAnalysis/OfferAnalysis';
import type { ProfileAnalysis } from '~/types/offers'; import type { ProfileAnalysis } from '~/types/offers';
type Props = Readonly<{ type Props = Readonly<{
analysis?: ProfileAnalysis | null; analysis?: ProfileAnalysis | null;
isError: boolean; isError: boolean;
isLoading: boolean; isLoading: boolean;
profileId?: string;
token?: string;
}>; }>;
export default function OffersSubmissionAnalysis({ export default function OffersSubmissionAnalysis({
analysis, analysis,
isError, isError,
isLoading, isLoading,
profileId = '',
token = '',
}: Props) { }: Props) {
const router = useRouter();
return ( return (
<div> <div className="mb-8">
<h5 className="mb-8 text-center text-4xl font-bold text-slate-900"> <h5 className="mb-8 text-center text-4xl font-bold text-slate-900">
Result Result
</h5> </h5>
@ -35,15 +22,8 @@ export default function OffersSubmissionAnalysis({
allAnalysis={analysis} allAnalysis={analysis}
isError={isError} isError={isError}
isLoading={isLoading} isLoading={isLoading}
isSubmission={true}
/> />
<div className="mt-8 text-center">
<Button
icon={EyeIcon}
label="View your profile"
variant="special"
onClick={() => router.push(getProfilePath(profileId, token))}
/>
</div>
</div> </div>
); );
} }

@ -1,12 +1,14 @@
import { useRef, useState } from 'react'; import { useRouter } from 'next/router';
import { useEffect, useRef, useState } from 'react';
import type { SubmitHandler } from 'react-hook-form'; import type { SubmitHandler } from 'react-hook-form';
import { FormProvider, useForm } from 'react-hook-form'; import { FormProvider, useForm } from 'react-hook-form';
import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/20/solid'; import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/20/solid';
import { JobType } from '@prisma/client'; import { JobType } from '@prisma/client';
import { Button } from '@tih/ui'; 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 { Breadcrumbs } from '~/components/offers/Breadcrumb';
import OffersProfileSave from '~/components/offers/offersSubmission/OffersProfileSave';
import BackgroundForm from '~/components/offers/offersSubmission/submissionForm/BackgroundForm'; import BackgroundForm from '~/components/offers/offersSubmission/submissionForm/BackgroundForm';
import OfferDetailsForm from '~/components/offers/offersSubmission/submissionForm/OfferDetailsForm'; import OfferDetailsForm from '~/components/offers/offersSubmission/submissionForm/OfferDetailsForm';
import type { import type {
@ -23,13 +25,10 @@ import {
import { getCurrentMonth, getCurrentYear } from '~/utils/offers/time'; import { getCurrentMonth, getCurrentYear } from '~/utils/offers/time';
import { trpc } from '~/utils/trpc'; import { trpc } from '~/utils/trpc';
import OffersSubmissionAnalysis from './OffersSubmissionAnalysis';
import type { ProfileAnalysis } from '~/types/offers';
const defaultOfferValues = { const defaultOfferValues = {
comments: '', comments: '',
companyId: '', companyId: '',
jobTitle: '',
jobType: JobType.FULLTIME, jobType: JobType.FULLTIME,
location: '', location: '',
monthYearReceived: { monthYearReceived: {
@ -42,11 +41,38 @@ const defaultOfferValues = {
export const defaultFullTimeOfferValues = { export const defaultFullTimeOfferValues = {
...defaultOfferValues, ...defaultOfferValues,
jobType: JobType.FULLTIME, 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 = { export const defaultInternshipOfferValues = {
...defaultOfferValues, ...defaultOfferValues,
jobType: JobType.INTERN, jobType: JobType.INTERN,
offersIntern: {
internshipCycle: null,
monthlySalary: {
currency: 'SGD',
value: null,
},
startYear: null,
},
}; };
const defaultOfferProfileValues = { const defaultOfferProfileValues = {
@ -59,13 +85,6 @@ const defaultOfferProfileValues = {
offers: [defaultOfferValues], offers: [defaultOfferValues],
}; };
type FormStep = {
component: JSX.Element;
hasNext: boolean;
hasPrevious: boolean;
label: string;
};
type Props = Readonly<{ type Props = Readonly<{
initialOfferProfileValues?: OffersProfileFormData; initialOfferProfileValues?: OffersProfileFormData;
profileId?: string; profileId?: string;
@ -77,11 +96,15 @@ export default function OffersSubmissionForm({
profileId: editProfileId = '', profileId: editProfileId = '',
token: editToken = '', token: editToken = '',
}: Props) { }: Props) {
const [formStep, setFormStep] = useState(0); const [step, setStep] = useState(0);
const [profileId, setProfileId] = useState(editProfileId); const [params, setParams] = useState({
const [token, setToken] = useState(editToken); profileId: editProfileId,
const [analysis, setAnalysis] = useState<ProfileAnalysis | null>(null); token: editToken,
});
const [isSubmitted, setIsSubmitted] = useState(false);
const { event: gaEvent } = useGoogleAnalytics();
const router = useRouter();
const pageRef = useRef<HTMLDivElement>(null); const pageRef = useRef<HTMLDivElement>(null);
const scrollToTop = () => const scrollToTop = () =>
pageRef.current?.scrollTo({ behavior: 'smooth', top: 0 }); pageRef.current?.scrollTo({ behavior: 'smooth', top: 0 });
@ -89,7 +112,11 @@ export default function OffersSubmissionForm({
defaultValues: initialOfferProfileValues, defaultValues: initialOfferProfileValues,
mode: 'all', mode: 'all',
}); });
const { handleSubmit, trigger } = formMethods; const {
handleSubmit,
trigger,
formState: { isSubmitting, isSubmitSuccessful },
} = formMethods;
const generateAnalysisMutation = trpc.useMutation( const generateAnalysisMutation = trpc.useMutation(
['offers.analysis.generate'], ['offers.analysis.generate'],
@ -97,93 +124,67 @@ export default function OffersSubmissionForm({
onError(error) { onError(error) {
console.error(error.message); console.error(error.message);
}, },
onSuccess(data) { onSuccess() {
setAnalysis(data); router.push(
`/offers/submit/result/${params.profileId}?token=${params.token}`,
);
}, },
}, },
); );
const formSteps: Array<FormStep> = [ const steps = [
<OfferDetailsForm
key={0}
defaultJobType={initialOfferProfileValues.offers[0].jobType}
/>,
<BackgroundForm key={1} />,
];
const breadcrumbSteps: Array<BreadcrumbStep> = [
{ {
component: (
<OfferDetailsForm
key={0}
defaultJobType={initialOfferProfileValues.offers[0].jobType}
/>
),
hasNext: true,
hasPrevious: false,
label: 'Offers', label: 'Offers',
step: 0,
}, },
{ {
component: <BackgroundForm key={1} />,
hasNext: false,
hasPrevious: true,
label: 'Background', label: 'Background',
step: 1,
}, },
{ {
component: (
<OffersProfileSave key={2} profileId={profileId} token={token} />
),
hasNext: true,
hasPrevious: false,
label: 'Save profile', label: 'Save profile',
}, },
{ {
component: (
<OffersSubmissionAnalysis
analysis={analysis}
isError={generateAnalysisMutation.isError}
isLoading={generateAnalysisMutation.isLoading}
profileId={profileId}
token={token}
/>
),
hasNext: false,
hasPrevious: true,
label: 'Analysis', label: 'Analysis',
}, },
]; ];
const formStepsLabels = formSteps.map((step) => step.label); const goToNextStep = async (currStep: number) => {
const nextStep = async (currStep: number) => {
if (currStep === 0) { if (currStep === 0) {
const result = await trigger('offers'); const result = await trigger('offers');
if (!result) { if (!result) {
return; return;
} }
} }
setFormStep(formStep + 1); setStep(step + 1);
scrollToTop();
};
const previousStep = () => {
setFormStep(formStep - 1);
scrollToTop();
}; };
const mutationpath = const mutationpath =
profileId && token ? 'offers.profile.update' : 'offers.profile.create'; editProfileId && editToken
? 'offers.profile.update'
: 'offers.profile.create';
const createOrUpdateMutation = trpc.useMutation([mutationpath], { const createOrUpdateMutation = trpc.useMutation([mutationpath], {
onError(error) { onError(error) {
console.error(error.message); console.error(error.message);
}, },
onSuccess(data) { onSuccess(data) {
generateAnalysisMutation.mutate({ setParams({ profileId: data.id, token: data.token });
profileId: data?.id || '', setIsSubmitted(true);
});
setProfileId(data.id);
setToken(data.token);
setFormStep(formStep + 1);
scrollToTop();
}, },
}); });
const onSubmit: SubmitHandler<OffersProfileFormData> = async (data) => { const onSubmit: SubmitHandler<OffersProfileFormData> = async (data) => {
const result = await trigger(); const result = await trigger();
if (!result) { if (!result || isSubmitting || isSubmitSuccessful) {
return; return;
} }
@ -206,49 +207,117 @@ export default function OffersSubmissionForm({
), ),
})); }));
if (profileId && token) { if (params.profileId && params.token) {
createOrUpdateMutation.mutate({ createOrUpdateMutation.mutate({
background, background,
id: profileId, id: params.profileId,
offers, offers,
token, token: params.token,
}); });
} else { } else {
createOrUpdateMutation.mutate({ background, offers }); createOrUpdateMutation.mutate({ background, offers });
} }
gaEvent({
action: 'offers.submit_profile',
category: 'submission',
label: 'Submit profile',
});
}; };
useEffect(() => {
if (isSubmitted) {
generateAnalysisMutation.mutate({
profileId: params.profileId,
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSubmitted, params]);
useEffect(() => {
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 ( return (
<div ref={pageRef} className="fixed h-full w-full overflow-y-scroll"> <div ref={pageRef} className="fixed h-full w-full overflow-y-scroll">
<div className="mb-20 flex justify-center"> <div className="mb-20 flex justify-center">
<div className="my-5 block w-full max-w-screen-md rounded-lg bg-white py-10 px-10 shadow-lg"> <div className="my-5 block w-full max-w-screen-md rounded-lg bg-white py-10 px-10 shadow-lg">
<div className="mb-4 flex justify-end"> <div className="mb-4 flex justify-end">
<Breadcrumbs currentStep={formStep} stepLabels={formStepsLabels} /> <Breadcrumbs
currentStep={step}
setStep={setStep}
steps={breadcrumbSteps}
/>
</div> </div>
<FormProvider {...formMethods}> <FormProvider {...formMethods}>
<form onSubmit={handleSubmit(onSubmit)}> <form className="text-sm" onSubmit={handleSubmit(onSubmit)}>
{formSteps[formStep].component} {steps[step]}
{/* <pre>{JSON.stringify(formMethods.watch(), null, 2)}</pre> */} {/* <pre>{JSON.stringify(formMethods.watch(), null, 2)}</pre> */}
{formSteps[formStep].hasNext && ( {step === 0 && (
<div className="flex justify-end"> <div className="flex justify-end">
<Button <Button
disabled={false} disabled={false}
icon={ArrowRightIcon} icon={ArrowRightIcon}
label="Next" label="Next"
variant="secondary" variant="secondary"
onClick={() => nextStep(formStep)} onClick={() => {
goToNextStep(step);
gaEvent({
action: 'offers.profile_submission_navigate_next',
category: 'submission',
label: 'Navigate next',
});
}}
/> />
</div> </div>
)} )}
{formStep === 1 && ( {step === 1 && (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Button <Button
icon={ArrowLeftIcon} icon={ArrowLeftIcon}
label="Previous" label="Previous"
variant="secondary" variant="secondary"
onClick={previousStep} 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> </div>
)} )}
</form> </form>

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

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

@ -7,6 +7,7 @@ import {
import { HorizontalDivider } from '@tih/ui'; import { HorizontalDivider } from '@tih/ui';
import type { OfferDisplayData } from '~/components/offers/types'; import type { OfferDisplayData } from '~/components/offers/types';
import { JobTypeLabel } from '~/components/offers/types';
type Props = Readonly<{ type Props = Readonly<{
offer: OfferDisplayData; offer: OfferDisplayData;
@ -20,6 +21,7 @@ export default function OfferCard({
duration, duration,
jobTitle, jobTitle,
jobLevel, jobLevel,
jobType,
location, location,
receivedMonth, receivedMonth,
totalCompensation, totalCompensation,
@ -40,7 +42,10 @@ export default function OfferCard({
</span> </span>
</div> </div>
<div className="ml-6 flex flex-row"> <div className="ml-6 flex flex-row">
<p>{jobLevel ? `${jobTitle}, ${jobLevel}` : jobTitle}</p> <p>
{jobLevel ? `${jobTitle}, ${jobLevel}` : jobTitle}{' '}
{jobType && `(${JobTypeLabel[jobType]})`}
</p>
</div> </div>
</div> </div>
{!duration && receivedMonth && ( {!duration && receivedMonth && (

@ -9,13 +9,17 @@ import {
useToast, useToast,
} from '@tih/ui'; } from '@tih/ui';
import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics';
import ExpandableCommentCard from '~/components/offers/profile/comments/ExpandableCommentCard'; import ExpandableCommentCard from '~/components/offers/profile/comments/ExpandableCommentCard';
import Tooltip from '~/components/offers/util/Tooltip';
import { copyProfileLink } from '~/utils/offers/link'; import { copyProfileLink } from '~/utils/offers/link';
import { trpc } from '~/utils/trpc'; import { trpc } from '~/utils/trpc';
import type { OffersDiscussion, Reply } from '~/types/offers'; import type { OffersDiscussion, Reply } from '~/types/offers';
import 'react-popper-tooltip/dist/styles.css';
type ProfileHeaderProps = Readonly<{ type ProfileHeaderProps = Readonly<{
isDisabled: boolean; isDisabled: boolean;
isEditable: boolean; isEditable: boolean;
@ -37,6 +41,7 @@ export default function ProfileComments({
const [currentReply, setCurrentReply] = useState<string>(''); const [currentReply, setCurrentReply] = useState<string>('');
const [replies, setReplies] = useState<Array<Reply>>(); const [replies, setReplies] = useState<Array<Reply>>();
const { showToast } = useToast(); const { showToast } = useToast();
const { event: gaEvent } = useGoogleAnalytics();
const commentsQuery = trpc.useQuery( const commentsQuery = trpc.useQuery(
['offers.comments.getComments', { profileId }], ['offers.comments.getComments', { profileId }],
@ -107,39 +112,53 @@ export default function ProfileComments({
<div className="m-4 h-full"> <div className="m-4 h-full">
<div className="flex-end flex justify-end space-x-4"> <div className="flex-end flex justify-end space-x-4">
{isEditable && ( {isEditable && (
<Tooltip tooltipContent="Copy this link to edit your profile later">
<Button
addonPosition="start"
disabled={isDisabled}
icon={ClipboardDocumentIcon}
isLabelHidden={false}
label="Copy profile edit link"
size="sm"
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 <Button
addonPosition="start" addonPosition="start"
disabled={isDisabled} disabled={isDisabled}
icon={ClipboardDocumentIcon} icon={ShareIcon}
isLabelHidden={false} isLabelHidden={false}
label="Copy profile edit link" label="Copy public link"
size="sm" size="sm"
variant="secondary" variant="secondary"
onClick={() => { onClick={() => {
copyProfileLink(profileId, token); copyProfileLink(profileId);
gaEvent({
action: 'offers.copy_profile_public_link',
category: 'engagement',
label: 'Copy Profile Public Link',
});
showToast({ showToast({
title: `Profile edit link copied to clipboard!`, title: `Public profile link copied to clipboard!`,
variant: 'success', variant: 'success',
}); });
}} }}
/> />
)} </Tooltip>
<Button
addonPosition="start"
disabled={isDisabled}
icon={ShareIcon}
isLabelHidden={false}
label="Copy public link"
size="sm"
variant="secondary"
onClick={() => {
copyProfileLink(profileId);
showToast({
title: `Public profile link copied to clipboard!`,
variant: 'success',
});
}}
/>
</div> </div>
<h2 className="mt-2 mb-6 text-2xl font-bold">Discussions</h2> <h2 className="mt-2 mb-6 text-2xl font-bold">Discussions</h2>
{isEditable || session?.user?.name ? ( {isEditable || session?.user?.name ? (
@ -173,7 +192,13 @@ export default function ProfileComments({
<HorizontalDivider /> <HorizontalDivider />
</div> </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-full overflow-y-auto">
<div className="h-content mb-96 w-full"> <div className="h-content mb-96 w-full">

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

@ -1,7 +1,10 @@
import { signIn, useSession } from 'next-auth/react'; import { signIn, useSession } from 'next-auth/react';
import { useState } from 'react'; import { useState } from 'react';
import { ChatBubbleBottomCenterIcon } from '@heroicons/react/24/outline'; import {
import { Button, HorizontalDivider, TextArea } from '@tih/ui'; ChatBubbleBottomCenterIcon,
TrashIcon,
} from '@heroicons/react/24/outline';
import { Button, Dialog, HorizontalDivider, TextArea, useToast } from '@tih/ui';
import { timeSinceNow } from '~/utils/offers/time'; import { timeSinceNow } from '~/utils/offers/time';
@ -25,12 +28,15 @@ export default function CommentCard({
handleExpanded, handleExpanded,
isExpanded, isExpanded,
profileId, profileId,
token = '',
replyLength = 0, replyLength = 0,
token = '',
}: Props) { }: Props) {
const { data: session, status } = useSession(); const { data: session, status } = useSession();
const [isReplying, setIsReplying] = useState(false); const [isReplying, setIsReplying] = useState(false);
const [currentReply, setCurrentReply] = useState<string>(''); 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 trpcContext = trpc.useContext();
const createCommentMutation = trpc.useMutation(['offers.comments.create'], { 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 ( return (
<> <>
<div className="flex pl-2"> <div className="flex pl-2">
@ -122,6 +155,47 @@ export default function CommentCard({
/> />
</div> </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> </div>
{!disableReply && isReplying && ( {!disableReply && isReplying && (
<div className="mt-2 mr-2"> <div className="mt-2 mr-2">
@ -137,7 +211,9 @@ export default function CommentCard({
<div className="w-fit"> <div className="w-fit">
<Button <Button
disabled={ disabled={
!currentReply.length || createCommentMutation.isLoading !currentReply.length ||
createCommentMutation.isLoading ||
deleteCommentMutation.isLoading
} }
display="block" display="block"
isLabelHidden={false} isLabelHidden={false}

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

@ -1,3 +1,4 @@
import clsx from 'clsx';
import Link from 'next/link'; import Link from 'next/link';
import type { JobTitleType } from '~/components/shared/JobTitles'; import type { JobTitleType } from '~/components/shared/JobTitles';
@ -14,12 +15,8 @@ export default function OfferTableRow({
row: { company, id, income, monthYearReceived, profileId, title, totalYoe }, row: { company, id, income, monthYearReceived, profileId, title, totalYoe },
}: OfferTableRowProps) { }: OfferTableRowProps) {
return ( return (
<tr <tr key={id} className="divide-x divide-slate-200 border-b bg-white">
key={id} <th className="whitespace-nowrap py-4 px-6 font-medium" scope="row">
className="border-b bg-white hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-800 dark:hover:bg-slate-600">
<th
className="whitespace-nowrap py-4 px-6 font-medium text-slate-900 dark:text-white"
scope="row">
{company.name} {company.name}
</th> </th>
<td className="py-4 px-6"> <td className="py-4 px-6">
@ -28,7 +25,10 @@ export default function OfferTableRow({
<td className="py-4 px-6">{totalYoe}</td> <td className="py-4 px-6">{totalYoe}</td>
<td className="py-4 px-6">{convertMoneyToString(income)}</td> <td className="py-4 px-6">{convertMoneyToString(income)}</td>
<td className="py-4 px-6">{formatDate(monthYearReceived)}</td> <td className="py-4 px-6">{formatDate(monthYearReceived)}</td>
<td className="space-x-4 py-4 px-6"> <td
className={clsx(
'sticky right-0 py-4 px-6 drop-shadow md:drop-shadow-none',
)}>
<Link <Link
className="text-primary-600 dark:text-primary-500 font-medium hover:underline" className="text-primary-600 dark:text-primary-500 font-medium hover:underline"
href={`/offers/profile/${profileId}`}> href={`/offers/profile/${profileId}`}>

@ -1,6 +1,8 @@
import clsx from 'clsx';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { HorizontalDivider, Select, Spinner, Tabs } from '@tih/ui'; import { DropdownMenu, Spinner } from '@tih/ui';
import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics';
import OffersTablePagination from '~/components/offers/table/OffersTablePagination'; import OffersTablePagination from '~/components/offers/table/OffersTablePagination';
import { import {
OfferTableFilterOptions, OfferTableFilterOptions,
@ -38,6 +40,7 @@ export default function OffersTable({
const [selectedFilter, setSelectedFilter] = useState( const [selectedFilter, setSelectedFilter] = useState(
OfferTableFilterOptions[0].value, OfferTableFilterOptions[0].value,
); );
const { event: gaEvent } = useGoogleAnalytics();
useEffect(() => { useEffect(() => {
setPagination({ setPagination({
currentPage: 0, currentPage: 0,
@ -71,55 +74,89 @@ export default function OffersTable({
}, },
); );
function renderTabs() {
return (
<div className="flex justify-center">
<div className="w-fit">
<Tabs
label="Table Navigation"
tabs={OfferTableTabOptions}
value={selectedTab}
onChange={(value) => setSelectedTab(value)}
/>
</div>
</div>
);
}
function renderFilters() { function renderFilters() {
return ( return (
<div className="m-4 flex items-center justify-between"> <div className="m-4 flex items-center justify-between">
<div className="justify-left flex items-center space-x-2"> <DropdownMenu
<span>All offers in</span> align="start"
<CurrencySelector label={
handleCurrencyChange={(value: string) => setCurrency(value)} OfferTableTabOptions.filter(
selectedCurrency={currency} ({ value: itemValue }) => itemValue === selectedTab,
/> )[0].label
}
size="inherit">
{OfferTableTabOptions.map(({ label: itemLabel, value }) => (
<DropdownMenu.Item
key={value}
isSelected={value === selectedTab}
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>View all offers in</span>
<CurrencySelector
handleCurrencyChange={(value: string) => setCurrency(value)}
selectedCurrency={currency}
/>
</div>
<div className="pl-4">
<DropdownMenu
align="end"
label={
OfferTableFilterOptions.filter(
({ value: itemValue }) => itemValue === selectedFilter,
)[0].label
}
size="inherit">
{OfferTableFilterOptions.map(({ label: itemLabel, value }) => (
<DropdownMenu.Item
key={value}
isSelected={value === selectedFilter}
label={itemLabel}
onClick={() => {
setSelectedFilter(value);
}}
/>
))}
</DropdownMenu>
</div>
</div> </div>
<Select
isLabelHidden={true}
label=""
options={OfferTableFilterOptions}
value={selectedFilter}
onChange={(value) => setSelectedFilter(value)}
/>
</div> </div>
); );
} }
function renderHeader() { function renderHeader() {
const columns = [
'Company',
'Title',
'YOE',
selectedTab === YOE_CATEGORY.INTERN ? 'Monthly Salary' : 'Annual TC',
'Date Offered',
'Actions',
];
return ( return (
<thead className="bg-slate-50 text-xs uppercase text-slate-700"> <thead className="text-slate-700">
<tr> <tr className="divide-x divide-slate-200">
{[ {columns.map((header, index) => (
'Company', <th
'Title', key={header}
'YOE', className={clsx(
selectedTab === YOE_CATEGORY.INTERN ? 'Monthly Salary' : 'TC/year', 'bg-slate-100 py-3 px-6',
'Date offered', // Make last column sticky.
'Actions', index === columns.length - 1 &&
].map((header) => ( 'sticky right-0 drop-shadow md:drop-shadow-none',
<th key={header} className="py-3 px-6" scope="col"> )}
scope="col">
{header} {header}
</th> </th>
))} ))}
@ -129,30 +166,30 @@ export default function OffersTable({
} }
const handlePageChange = (currPage: number) => { const handlePageChange = (currPage: number) => {
if (0 < currPage && currPage < pagination.numOfPages) { if (0 <= currPage && currPage < pagination.numOfPages) {
setPagination({ ...pagination, currentPage: currPage }); setPagination({ ...pagination, currentPage: currPage });
} }
}; };
return ( return (
<div className="w-5/6"> <div className="w-5/6">
{renderTabs()} <div className="relative w-full border border-slate-200">
<HorizontalDivider />
<div className="relative w-full overflow-x-auto shadow-md sm:rounded-lg">
{renderFilters()} {renderFilters()}
{offersQuery.isLoading ? ( {offersQuery.isLoading ? (
<div className="col-span-10 pt-4"> <div className="col-span-10 pt-4">
<Spinner display="block" size="lg" /> <Spinner display="block" size="lg" />
</div> </div>
) : ( ) : (
<table className="w-full text-left text-sm text-slate-500"> <div className="overflow-x-auto">
{renderHeader()} <table className="w-full divide-y divide-slate-200 border-y border-slate-200 text-left text-slate-600">
<tbody> {renderHeader()}
{offers.map((offer) => ( <tbody>
<OffersRow key={offer.id} row={offer} /> {offers.map((offer) => (
))} <OffersRow key={offer.id} row={offer} />
</tbody> ))}
</table> </tbody>
</table>
</div>
)} )}
<OffersTablePagination <OffersTablePagination
endNumber={ endNumber={

@ -2,7 +2,7 @@ import type { JobType } from '@prisma/client';
import type { MonthYear } from '~/components/shared/MonthYearPicker'; import type { MonthYear } from '~/components/shared/MonthYearPicker';
export const HOME_URL = '/offers/browse'; export const HOME_URL = '/offers';
/* /*
* Offer Profile * Offer Profile
@ -45,6 +45,7 @@ export type BackgroundPostData = {
type ExperiencePostData = { type ExperiencePostData = {
companyId?: string | null; companyId?: string | null;
companyName?: string | null;
durationInMonths?: number | null; durationInMonths?: number | null;
id?: string; id?: string;
jobType?: string | null; jobType?: string | null;
@ -76,6 +77,7 @@ type SpecificYoe = SpecificYoePostData;
export type OfferPostData = { export type OfferPostData = {
comments: string; comments: string;
companyId: string; companyId: string;
companyName?: string;
id?: string; id?: string;
jobType: JobType; jobType: JobType;
location: string; location: string;
@ -129,6 +131,7 @@ export type OfferDisplayData = {
id?: string; id?: string;
jobLevel?: string | null; jobLevel?: string | null;
jobTitle?: string | null; jobTitle?: string | null;
jobType?: JobType;
location?: string | null; location?: string | null;
monthlySalary?: string | null; monthlySalary?: string | null;
negotiationStrategy?: 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>
)}
</>
);
}

@ -15,7 +15,7 @@ export default function AddToListDropdown({
questionId, questionId,
}: AddToListDropdownProps) { }: AddToListDropdownProps) {
const [menuOpened, setMenuOpened] = useState(false); const [menuOpened, setMenuOpened] = useState(false);
const ref = useRef() as React.MutableRefObject<HTMLDivElement>; const ref = useRef<HTMLDivElement>(null);
const utils = trpc.useContext(); const utils = trpc.useContext();
const { data: lists } = trpc.useQuery(['questions.lists.getListsByUser']); const { data: lists } = trpc.useQuery(['questions.lists.getListsByUser']);
@ -54,7 +54,7 @@ export default function AddToListDropdown({
}; };
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent) => {
if (!ref.current.contains(event.target as Node)) { if (ref.current && !ref.current.contains(event.target as Node)) {
setMenuOpened(false); setMenuOpened(false);
document.removeEventListener('click', handleClickOutside, true); document.removeEventListener('click', handleClickOutside, true);
} }

@ -28,7 +28,7 @@ export default function ContributeQuestionCard({
}; };
return ( return (
<div> <div className="w-full">
<button <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" 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" type="button"
@ -72,12 +72,12 @@ export default function ContributeQuestionCard({
Contribute Contribute
</h1> </h1>
</div> </div>
<ContributeQuestionDialog
show={showDraftDialog}
onCancel={handleDraftDialogCancel}
onSubmit={onSubmit}
/>
</button> </button>
<ContributeQuestionDialog
show={showDraftDialog}
onCancel={handleDraftDialogCancel}
onSubmit={onSubmit}
/>
</div> </div>
); );
} }

@ -97,6 +97,7 @@ export default function LandingComponent({
isLabelHidden={true} isLabelHidden={true}
value={company} value={company}
onSelect={(value) => { onSelect={(value) => {
// @ts-ignore TODO(questions): handle potentially null value.
handleChangeCompany(value); handleChangeCompany(value);
}} }}
/> />
@ -105,6 +106,7 @@ export default function LandingComponent({
isLabelHidden={true} isLabelHidden={true}
value={location} value={location}
onSelect={(value) => { onSelect={(value) => {
// @ts-ignore TODO(questions): handle potentially null value.
handleChangeLocation(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, AdjustmentsHorizontalIcon,
MagnifyingGlassIcon, MagnifyingGlassIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import { Button, Select, TextInput } from '@tih/ui'; import { Button, TextInput } from '@tih/ui';
export type SortOption<Value> = { import type { SortOptionsSelectProps } from './SortOptionsSelect';
label: string; import SortOptionsSelect from './SortOptionsSelect';
value: Value;
};
type SortOrderProps<SortOrder> = {
onSortOrderChange?: (sortValue: SortOrder) => void;
sortOrderOptions: ReadonlyArray<SortOption<SortOrder>>;
sortOrderValue: SortOrder;
};
type SortTypeProps<SortType> = { export type QuestionSearchBarProps = SortOptionsSelectProps & {
onSortTypeChange?: (sortType: SortType) => void; onFilterOptionsToggle: () => void;
sortTypeOptions: ReadonlyArray<SortOption<SortType>>;
sortTypeValue: SortType;
}; };
export type QuestionSearchBarProps<SortType, SortOrder> = export default function QuestionSearchBar({
SortOrderProps<SortOrder> &
SortTypeProps<SortType> & {
onFilterOptionsToggle: () => void;
};
export default function QuestionSearchBar<SortType, SortOrder>({
onSortOrderChange,
sortOrderOptions,
sortOrderValue,
onSortTypeChange,
sortTypeOptions,
sortTypeValue,
onFilterOptionsToggle, onFilterOptionsToggle,
}: QuestionSearchBarProps<SortType, SortOrder>) { ...sortOptionsSelectProps
}: QuestionSearchBarProps) {
return ( return (
<div className="flex flex-col items-stretch gap-x-4 gap-y-2 lg:flex-row lg:items-end"> <div className="flex flex-col items-stretch gap-x-4 gap-y-2 lg:flex-row lg:items-end">
<div className="flex-1 "> <div className="flex-1 ">
@ -48,38 +27,7 @@ export default function QuestionSearchBar<SortType, SortOrder>({
/> />
</div> </div>
<div className="flex items-end justify-end gap-4"> <div className="flex items-end justify-end gap-4">
<div className="flex items-center gap-2"> <SortOptionsSelect {...sortOptionsSelectProps} />
<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 className="lg:hidden"> <div className="lg:hidden">
<Button <Button
addonPosition="start" addonPosition="start"

@ -8,6 +8,8 @@ const navigation: ProductNavigationItems = [
]; ];
const config = { const config = {
// TODO: Change this to your own GA4 measurement ID.
googleAnalyticsMeasurementID: 'G-DBLZDQ2ZZN',
navigation, navigation,
showGlobalNav: false, showGlobalNav: false,
title: 'Questions Bank', 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 clsx from 'clsx';
import { useState } from 'react'; import { useMemo, useState } from 'react';
import { import {
ChatBubbleBottomCenterTextIcon, ChatBubbleBottomCenterTextIcon,
CheckIcon, CheckIcon,
@ -18,6 +18,8 @@ import QuestionAggregateBadge from '../../QuestionAggregateBadge';
import QuestionTypeBadge from '../../QuestionTypeBadge'; import QuestionTypeBadge from '../../QuestionTypeBadge';
import VotingButtons from '../../VotingButtons'; import VotingButtons from '../../VotingButtons';
import type { CountryInfo } from '~/types/questions';
type UpvoteProps = type UpvoteProps =
| { | {
showVoteButtons: true; showVoteButtons: true;
@ -51,13 +53,13 @@ type AnswerStatisticsProps =
type AggregateStatisticsProps = type AggregateStatisticsProps =
| { | {
companies: Record<string, number>; companies: Record<string, number>;
locations: Record<string, number>; countries: Record<string, CountryInfo>;
roles: Record<string, number>; roles: Record<string, number>;
showAggregateStatistics: true; showAggregateStatistics: true;
} }
| { | {
companies?: never; companies?: never;
locations?: never; countries?: never;
roles?: never; roles?: never;
showAggregateStatistics?: false; showAggregateStatistics?: false;
}; };
@ -136,7 +138,7 @@ export default function BaseQuestionCard({
upvoteCount, upvoteCount,
timestamp, timestamp,
roles, roles,
locations, countries,
showHover, showHover,
onReceivedSubmit, onReceivedSubmit,
showDeleteButton, showDeleteButton,
@ -147,6 +149,22 @@ export default function BaseQuestionCard({
const [showReceivedForm, setShowReceivedForm] = useState(false); const [showReceivedForm, setShowReceivedForm] = useState(false);
const { handleDownvote, handleUpvote, vote } = useQuestionVote(questionId); const { handleDownvote, handleUpvote, vote } = useQuestionVote(questionId);
const hoverClass = showHover ? 'hover:bg-slate-50' : ''; 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 = ( const cardContent = (
<> <>
{showVoteButtons && ( {showVoteButtons && (
@ -168,7 +186,7 @@ export default function BaseQuestionCard({
variant="primary" variant="primary"
/> />
<QuestionAggregateBadge <QuestionAggregateBadge
statistics={locations} statistics={locations!}
variant="success" variant="success"
/> />
<QuestionAggregateBadge statistics={roles} variant="danger" /> <QuestionAggregateBadge statistics={roles} variant="danger" />

@ -1,7 +1,7 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import type { UseFormRegisterReturn } from 'react-hook-form'; import type { UseFormRegisterReturn } from 'react-hook-form';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { CheckboxInput, Collapsible, RadioList } from '@tih/ui'; import { CheckboxInput, CheckboxList, Collapsible, RadioList } from '@tih/ui';
export type FilterChoice<V extends string = string> = { export type FilterChoice<V extends string = string> = {
id: string; id: string;
@ -96,7 +96,7 @@ export default function FilterSection<V extends string>({
<Collapsible defaultOpen={true} label={collapsibleLabel}> <Collapsible defaultOpen={true} label={collapsibleLabel}>
<div className="-mx-2 flex flex-col items-stretch gap-2"> <div className="-mx-2 flex flex-col items-stretch gap-2">
{!showAll && ( {!showAll && (
<div className="z-10"> <div>
{renderInput({ {renderInput({
field, field,
onOptionChange: async (option: FilterOption<V>) => { onOptionChange: async (option: FilterOption<V>) => {
@ -110,8 +110,8 @@ export default function FilterSection<V extends string>({
})} })}
</div> </div>
)} )}
{isSingleSelect ? ( <div className="px-1.5">
<div className="px-1.5"> {isSingleSelect ? (
<RadioList <RadioList
isLabelHidden={true} isLabelHidden={true}
label={label} label={label}
@ -133,26 +133,26 @@ export default function FilterSection<V extends string>({
/> />
))} ))}
</RadioList> </RadioList>
</div> ) : (
) : ( <CheckboxList isLabelHidden={true} label={label}>
<div className="px-1.5"> {options
{options .filter((option) => showAll || option.checked)
.filter((option) => showAll || option.checked) .map((option) => (
.map((option) => ( <CheckboxInput
<CheckboxInput key={option.value}
key={option.value} label={option.label}
label={option.label} value={option.checked}
value={option.checked} onChange={(checked) => {
onChange={(checked) => { onOptionChange({
onOptionChange({ ...option,
...option, checked,
checked, });
}); }}
}} />
/> ))}
))} </CheckboxList>
</div> )}
)} </div>
</div> </div>
</Collapsible> </Collapsible>
</div> </div>

@ -3,15 +3,11 @@ import { useEffect, useState } from 'react';
import { Controller, useForm } from 'react-hook-form'; import { Controller, useForm } from 'react-hook-form';
import { ArrowPathIcon } from '@heroicons/react/20/solid'; import { ArrowPathIcon } from '@heroicons/react/20/solid';
import type { QuestionsQuestionType } from '@prisma/client'; import type { QuestionsQuestionType } from '@prisma/client';
import { import type { TypeaheadOption } from '@tih/ui';
Button, import { CheckboxInput } from '@tih/ui';
CheckboxInput, import { Button, HorizontalDivider, Select, TextArea } from '@tih/ui';
HorizontalDivider,
Select,
TextArea,
} from '@tih/ui';
import { LOCATIONS, QUESTION_TYPES, ROLES } from '~/utils/questions/constants'; import { QUESTION_TYPES } from '~/utils/questions/constants';
import { import {
useFormRegister, useFormRegister,
useSelectRegister, useSelectRegister,
@ -25,14 +21,16 @@ import RoleTypeahead from '../typeahead/RoleTypeahead';
import type { Month } from '../../shared/MonthYearPicker'; import type { Month } from '../../shared/MonthYearPicker';
import MonthYearPicker from '../../shared/MonthYearPicker'; import MonthYearPicker from '../../shared/MonthYearPicker';
import type { Location } from '~/types/questions';
export type ContributeQuestionData = { export type ContributeQuestionData = {
company: string; company: string;
date: Date; date: Date;
location: string; location: Location & TypeaheadOption;
position: string; position: string;
questionContent: string; questionContent: string;
questionType: QuestionsQuestionType; questionType: QuestionsQuestionType;
role: string; role: TypeaheadOption;
}; };
export type ContributeQuestionFormProps = { export type ContributeQuestionFormProps = {
@ -58,10 +56,7 @@ export default function ContributeQuestionForm({
const [contentToCheck, setContentToCheck] = useState(''); const [contentToCheck, setContentToCheck] = useState('');
const { data: similarQuestions } = trpc.useQuery( const { data: similarQuestions } = trpc.useQuery(
[ ['questions.questions.getRelatedQuestions', { content: contentToCheck }],
'questions.questions.getRelatedQuestionsByContent',
{ content: contentToCheck },
],
{ {
keepPreviousData: true, keepPreviousData: true,
}, },
@ -113,14 +108,12 @@ export default function ContributeQuestionForm({
name="location" name="location"
render={({ field }) => ( render={({ field }) => (
<LocationTypeahead <LocationTypeahead
{...field}
required={true} required={true}
onSelect={(option) => { 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,
)}
/> />
)} )}
/> />
@ -137,9 +130,9 @@ export default function ContributeQuestionForm({
year: field.value.getFullYear(), year: field.value.getFullYear(),
}} }}
yearRequired={true} yearRequired={true}
onChange={({ month, year }) => onChange={({ month, year }) => {
field.onChange(startOfMonth(new Date(year, month - 1))) field.onChange(startOfMonth(new Date(year!, month! - 1)));
} }}
/> />
)} )}
/> />
@ -150,9 +143,11 @@ export default function ContributeQuestionForm({
<Controller <Controller
control={control} control={control}
name="company" name="company"
render={({ field }) => ( render={({ field: { value: _, ...field } }) => (
<CompanyTypeahead <CompanyTypeahead
{...field}
required={true} required={true}
// @ts-ignore TODO(questions): handle potentially null value.
onSelect={({ id }) => { onSelect={({ id }) => {
field.onChange(id); field.onChange(id);
}} }}
@ -166,12 +161,12 @@ export default function ContributeQuestionForm({
name="role" name="role"
render={({ field }) => ( render={({ field }) => (
<RoleTypeahead <RoleTypeahead
{...field}
required={true} required={true}
onSelect={(option) => { 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)}
/> />
)} )}
/> />
@ -203,12 +198,12 @@ export default function ContributeQuestionForm({
content={question.content} content={question.content}
questionId={question.id} questionId={question.id}
timestamp={ timestamp={
question.lastSeenAt?.toLocaleDateString(undefined, { question.seenAt.toLocaleDateString(undefined, {
month: 'short', month: 'short',
year: 'numeric', year: 'numeric',
}) ?? null }) ?? null
} }
type={question.questionType} type={question.type}
onSimilarQuestionClick={() => { onSimilarQuestionClick={() => {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log('hi!'); console.log('hi!');
@ -216,11 +211,13 @@ export default function ContributeQuestionForm({
/> />
))} ))}
{similarQuestions?.length === 0 && ( {similarQuestions?.length === 0 && (
<p className="text-slate-900 font-semibold">No similar questions found.</p> <p className="font-semibold text-slate-900">
No similar questions found.
</p>
)} )}
</div> </div>
<div <div
className="bg-primary-50 flex w-full flex-col gap-y-2 py-3 shadow-[0_0_0_100vmax_theme(colors.primary.50)] sm:flex-row sm:justify-between" className="bg-primary-50 flex w-full justify-end gap-y-2 py-3 shadow-[0_0_0_100vmax_theme(colors.primary.50)]"
style={{ style={{
// Hack to make the background bleed outside the container // Hack to make the background bleed outside the container
clipPath: 'inset(0 -100vmax)', clipPath: 'inset(0 -100vmax)',
@ -246,7 +243,6 @@ export default function ContributeQuestionForm({
</button> </button>
<Button <Button
className="bg-primary-600 hover:bg-primary-700 focus:ring-primary-500 inline-flex w-full justify-center rounded-md border border-transparent px-4 py-2 text-base font-medium text-white shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:bg-slate-400 sm:ml-3 sm:w-auto sm:text-sm" className="bg-primary-600 hover:bg-primary-700 focus:ring-primary-500 inline-flex w-full justify-center rounded-md border border-transparent px-4 py-2 text-base font-medium text-white shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:bg-slate-400 sm:ml-3 sm:w-auto sm:text-sm"
disabled={!checkedSimilar}
label="Contribute" label="Contribute"
type="submit" type="submit"
variant="primary"></Button> variant="primary"></Button>

@ -9,11 +9,15 @@ import CompanyTypeahead from '../typeahead/CompanyTypeahead';
import LocationTypeahead from '../typeahead/LocationTypeahead'; import LocationTypeahead from '../typeahead/LocationTypeahead';
import RoleTypeahead from '../typeahead/RoleTypeahead'; import RoleTypeahead from '../typeahead/RoleTypeahead';
import type { Location } from '~/types/questions';
export type CreateQuestionEncounterData = { export type CreateQuestionEncounterData = {
cityId?: string;
company: string; company: string;
location: string; countryId: string;
role: string; role: string;
seenAt: Date; seenAt: Date;
stateId?: string;
}; };
export type CreateQuestionEncounterFormProps = { export type CreateQuestionEncounterFormProps = {
@ -28,7 +32,9 @@ export default function CreateQuestionEncounterForm({
const [step, setStep] = useState(0); const [step, setStep] = useState(0);
const [selectedCompany, setSelectedCompany] = useState<string | null>(null); 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 [selectedRole, setSelectedRole] = useState<string | null>(null);
const [selectedDate, setSelectedDate] = useState<Date>( const [selectedDate, setSelectedDate] = useState<Date>(
startOfMonth(new Date()), startOfMonth(new Date()),
@ -43,6 +49,7 @@ export default function CreateQuestionEncounterForm({
isLabelHidden={true} isLabelHidden={true}
placeholder="Other company" placeholder="Other company"
suggestedCount={3} suggestedCount={3}
// @ts-ignore TODO(questions): handle potentially null value.
onSelect={({ value: company }) => { onSelect={({ value: company }) => {
setSelectedCompany(company); setSelectedCompany(company);
}} }}
@ -59,10 +66,11 @@ export default function CreateQuestionEncounterForm({
isLabelHidden={true} isLabelHidden={true}
placeholder="Other location" placeholder="Other location"
suggestedCount={3} suggestedCount={3}
onSelect={({ value: location }) => { // @ts-ignore TODO(questions): handle potentially null value.
onSelect={(location) => {
setSelectedLocation(location); setSelectedLocation(location);
}} }}
onSuggestionClick={({ value: location }) => { onSuggestionClick={(location) => {
setSelectedLocation(location); setSelectedLocation(location);
setStep(step + 1); setStep(step + 1);
}} }}
@ -75,6 +83,7 @@ export default function CreateQuestionEncounterForm({
isLabelHidden={true} isLabelHidden={true}
placeholder="Other role" placeholder="Other role"
suggestedCount={3} suggestedCount={3}
// @ts-ignore TODO(questions): handle potentially null value.
onSelect={({ value: role }) => { onSelect={({ value: role }) => {
setSelectedRole(role); setSelectedRole(role);
}} }}
@ -87,15 +96,17 @@ export default function CreateQuestionEncounterForm({
)} )}
{step === 3 && ( {step === 3 && (
<MonthYearPicker <MonthYearPicker
// TODO: Add label and hide label on Select instead.
monthLabel="" monthLabel=""
value={{ value={{
month: ((selectedDate?.getMonth() ?? 0) + 1) as Month, month: ((selectedDate?.getMonth() ?? 0) + 1) as Month,
year: selectedDate?.getFullYear() as number, year: selectedDate?.getFullYear() as number,
}} }}
// TODO: Add label and hide label on Select instead.
yearLabel="" yearLabel=""
onChange={(value) => { onChange={(value) => {
setSelectedDate( setSelectedDate(
startOfMonth(new Date(value.year, value.month - 1)), startOfMonth(new Date(value.year!, value.month! - 1)),
); );
}} }}
/> />
@ -125,11 +136,14 @@ export default function CreateQuestionEncounterForm({
selectedRole && selectedRole &&
selectedDate selectedDate
) { ) {
const { cityId, stateId, countryId } = selectedLocation;
onSubmit({ onSubmit({
cityId,
company: selectedCompany, company: selectedCompany,
location: selectedLocation, countryId,
role: selectedRole, role: selectedRole,
seenAt: selectedDate, seenAt: selectedDate,
stateId,
}); });
} }
}} }}

@ -8,13 +8,16 @@ import type { RequireAllOrNone } from '~/utils/questions/RequireAllOrNone';
type TypeaheadProps = ComponentProps<typeof Typeahead>; type TypeaheadProps = ComponentProps<typeof Typeahead>;
type TypeaheadOption = TypeaheadProps['options'][number]; type TypeaheadOption = TypeaheadProps['options'][number];
export type ExpandedTypeaheadProps = RequireAllOrNone<{ export type ExpandedTypeaheadProps = Omit<TypeaheadProps, 'onSelect'> &
clearOnSelect?: boolean; RequireAllOrNone<{
filterOption: (option: TypeaheadOption) => boolean; clearOnSelect?: boolean;
onSuggestionClick: (option: TypeaheadOption) => void; filterOption: (option: TypeaheadOption) => boolean;
suggestedCount: number; 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({ export default function ExpandedTypeahead({
suggestedCount = 0, suggestedCount = 0,
@ -23,6 +26,7 @@ export default function ExpandedTypeahead({
clearOnSelect = false, clearOnSelect = false,
options, options,
onSelect, onSelect,
onChange: _,
...typeaheadProps ...typeaheadProps
}: ExpandedTypeaheadProps) { }: ExpandedTypeaheadProps) {
const [key, setKey] = useState(0); const [key, setKey] = useState(0);
@ -55,7 +59,8 @@ export default function ExpandedTypeahead({
if (clearOnSelect) { if (clearOnSelect) {
setKey((key + 1) % 2); setKey((key + 1) % 2);
} }
onSelect(option); // TODO: Remove onSelect null coercion once onSelect prop is refactored
onSelect(option!);
}} }}
/> />
</div> </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 type { ExpandedTypeaheadProps } from './ExpandedTypeahead';
import ExpandedTypeahead from './ExpandedTypeahead'; import ExpandedTypeahead from './ExpandedTypeahead';
import type { Location } from '~/types/questions';
export type LocationTypeaheadProps = Omit< export type LocationTypeaheadProps = Omit<
ExpandedTypeaheadProps, 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 ( return (
<ExpandedTypeahead <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" label="Location"
options={LOCATIONS} options={locationOptions}
// eslint-disable-next-line @typescript-eslint/no-empty-function onQueryChange={setQuery}
onQueryChange={() => {}} 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 type { ExpandedTypeaheadProps } from './ExpandedTypeahead';
import ExpandedTypeahead from './ExpandedTypeahead'; import ExpandedTypeahead from './ExpandedTypeahead';
import type { FilterChoices } from '../filter/FilterSection';
export type RoleTypeaheadProps = Omit< export type RoleTypeaheadProps = Omit<
ExpandedTypeaheadProps, ExpandedTypeaheadProps,
'label' | 'onQueryChange' | 'options' 'label' | 'onQueryChange' | 'options'
>; >;
const ROLES: FilterChoices = Object.entries(JobTitleLabels).map(
([slug, label]) => ({
id: slug,
label,
value: slug,
}),
);
export default function RoleTypeahead(props: RoleTypeaheadProps) { export default function RoleTypeahead(props: RoleTypeaheadProps) {
const [query, setQuery] = useState('');
return ( return (
<ExpandedTypeahead <ExpandedTypeahead
{...(props as ExpandedTypeaheadProps)} {...(props as ExpandedTypeaheadProps)}
label="Role" label="Role"
options={ROLES} options={ROLES.filter((option) =>
// eslint-disable-next-line @typescript-eslint/no-empty-function option.label.toLowerCase().includes(query.toLowerCase()),
onQueryChange={() => {}} )}
onQueryChange={setQuery}
/> />
); );
} }

@ -24,23 +24,18 @@ export default function ResumePdf({ url }: Props) {
setNumPages(pdf.numPages); setNumPages(pdf.numPages);
}; };
useEffect(() => { const onPageResize = () => {
const onPageResize = () => { setComponentWidth(
setComponentWidth( document.querySelector('#pdfView')?.getBoundingClientRect().width ?? 780,
document.querySelector('#pdfView')?.getBoundingClientRect().width ?? );
780, };
);
};
window.addEventListener('resize', onPageResize);
return () => { useEffect(() => {
window.removeEventListener('resize', onPageResize); onPageResize();
}; }, [pageWidth]);
}, []);
return ( return (
<div id="pdfView"> <div className="w-full" id="pdfView">
<div className="group relative"> <div className="group relative">
<Document <Document
className="flex h-[calc(100vh-16rem)] flex-row justify-center overflow-auto" className="flex h-[calc(100vh-16rem)] flex-row justify-center overflow-auto"
@ -84,17 +79,15 @@ export default function ResumePdf({ url }: Props) {
</div> </div>
</Document> </Document>
</div> </div>
{numPages > 1 && ( <div className="flex justify-center p-4">
<div className="flex justify-center p-4"> <Pagination
<Pagination current={pageNumber}
current={pageNumber} end={numPages}
end={numPages} label="pagination"
label="pagination" start={1}
start={1} onSelect={(page) => setPageNumber(page)}
onSelect={(page) => setPageNumber(page)} />
/> </div>
</div>
)}
</div> </div>
); );
} }

@ -1,12 +1,12 @@
import type { ProductNavigationItems } from '~/components/global/ProductNavigation'; import type { ProductNavigationItems } from '~/components/global/ProductNavigation';
const navigation: ProductNavigationItems = [ const navigation: ProductNavigationItems = [
{ children: [], href: '/resumes/submit', name: 'Submit for review' },
{ {
children: [], children: [],
href: '/resumes/browse', href: '/resumes/features',
name: 'Browse', name: 'Features',
}, },
{ children: [], href: '/resumes/submit', name: 'Submit for review' },
{ {
children: [], children: [],
href: '/resumes/about', href: '/resumes/about',
@ -21,6 +21,7 @@ const navigation: ProductNavigationItems = [
]; ];
const config = { const config = {
googleAnalyticsMeasurementID: 'G-VFTWPMW1WK',
navigation, navigation,
showGlobalNav: false, showGlobalNav: false,
title: 'Resumes', title: 'Resumes',

@ -8,19 +8,30 @@ type Props = Readonly<{
userId: string; userId: string;
}>; }>;
const STALE_TIME = 60;
export default function ResumeUserBadges({ userId }: Props) { export default function ResumeUserBadges({ userId }: Props) {
const userReviewedResumeCountQuery = trpc.useQuery([ const userReviewedResumeCountQuery = trpc.useQuery(
'resumes.resume.findUserReviewedResumeCount', ['resumes.resume.findUserReviewedResumeCount', { userId }],
{ userId }, {
]); retry: false,
const userMaxResumeUpvoteCountQuery = trpc.useQuery([ staleTime: STALE_TIME,
'resumes.resume.findUserMaxResumeUpvoteCount', },
{ userId }, );
]); const userMaxResumeUpvoteCountQuery = trpc.useQuery(
const userTopUpvotedCommentCountQuery = trpc.useQuery([ ['resumes.resume.findUserMaxResumeUpvoteCount', { userId }],
'resumes.resume.findUserTopUpvotedCommentCount', {
{ userId }, retry: false,
]); staleTime: STALE_TIME,
},
);
const userTopUpvotedCommentCountQuery = trpc.useQuery(
['resumes.resume.findUserTopUpvotedCommentCount', { userId }],
{
retry: false,
staleTime: STALE_TIME,
},
);
const payload: BadgePayload = { const payload: BadgePayload = {
maxResumeUpvoteCount: userMaxResumeUpvoteCountQuery.data ?? 0, maxResumeUpvoteCount: userMaxResumeUpvoteCountQuery.data ?? 0,

@ -15,7 +15,9 @@ export default function ResumeFilterPill({
<button <button
className={clsx( 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', '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" type="button"
onClick={onClick}> onClick={onClick}>

@ -1,3 +1,4 @@
import clsx from 'clsx';
import formatDistanceToNow from 'date-fns/formatDistanceToNow'; import formatDistanceToNow from 'date-fns/formatDistanceToNow';
import Link from 'next/link'; import Link from 'next/link';
import type { UrlObject } from 'url'; import type { UrlObject } from 'url';
@ -9,6 +10,18 @@ import {
} from '@heroicons/react/20/solid'; } from '@heroicons/react/20/solid';
import { ChatBubbleLeftIcon, StarIcon } from '@heroicons/react/24/outline'; import { ChatBubbleLeftIcon, StarIcon } from '@heroicons/react/24/outline';
import type {
ExperienceFilter,
LocationFilter,
RoleFilter,
} from '~/utils/resumes/resumeFilters';
import {
EXPERIENCES,
getFilterLabel,
LOCATIONS,
ROLES,
} from '~/utils/resumes/resumeFilters';
import type { Resume } from '~/types/resume'; import type { Resume } from '~/types/resume';
type Props = Readonly<{ type Props = Readonly<{
@ -19,52 +32,70 @@ type Props = Readonly<{
export default function ResumeListItem({ href, resumeInfo }: Props) { export default function ResumeListItem({ href, resumeInfo }: Props) {
return ( return (
<Link href={href}> <Link href={href}>
<div className="grid grid-cols-8 gap-4 border-b border-slate-200 p-4 hover:bg-slate-100"> <div className="grid grid-cols-8">
<div className="col-span-4"> <div className="col-span-7 grid gap-4 border-b border-slate-200 p-4 hover:bg-slate-100 sm:grid-cols-7">
{resumeInfo.title} <div className="sm:col-span-4">
<div className="text-primary-500 mt-2 flex items-center justify-start text-xs"> <div className="flex items-center gap-3">
<div className="flex"> {resumeInfo.title}
<BriefcaseIcon <p
aria-hidden="true" className={clsx(
className="mr-1.5 h-4 w-4 flex-shrink-0" 'w-auto items-center space-x-4 rounded-xl border border-slate-300 px-2 py-1 text-xs font-medium text-white opacity-60',
/> resumeInfo.isResolved ? 'bg-slate-400' : 'bg-success-500',
{resumeInfo.role} )}>
<span className="opacity-100">
{resumeInfo.isResolved ? 'Reviewed' : 'Unreviewed'}
</span>
</p>
</div>
<div className="text-primary-500 mt-2 flex items-center justify-start text-xs">
<div className="flex">
<BriefcaseIcon
aria-hidden="true"
className="mr-1.5 h-4 w-4 flex-shrink-0"
/>
{getFilterLabel(ROLES, resumeInfo.role as RoleFilter)}
</div>
<div className="ml-4 flex">
<AcademicCapIcon
aria-hidden="true"
className="mr-1.5 h-4 w-4 flex-shrink-0"
/>
{getFilterLabel(
EXPERIENCES,
resumeInfo.experience as ExperienceFilter,
)}
</div>
</div> </div>
<div className="ml-4 flex"> <div className="mt-4 flex justify-start text-xs text-slate-500">
<AcademicCapIcon <div className="flex gap-2 pr-4">
aria-hidden="true" <ChatBubbleLeftIcon className="w-4" />
className="mr-1.5 h-4 w-4 flex-shrink-0" {`${resumeInfo.numComments} comment${
/> resumeInfo.numComments === 1 ? '' : 's'
{resumeInfo.experience} }`}
</div>
<div className="flex gap-2">
{resumeInfo.isStarredByUser ? (
<ColouredStarIcon className="w-4 text-yellow-400" />
) : (
<StarIcon className="w-4" />
)}
{`${resumeInfo.numStars} star${
resumeInfo.numStars === 1 ? '' : 's'
}`}
</div>
</div> </div>
</div> </div>
<div className="mt-4 flex justify-start text-xs text-slate-500"> <div className="self-center text-sm text-slate-500 sm:col-span-3">
<div className="flex gap-2 pr-4"> <div>
<ChatBubbleLeftIcon className="w-4" /> {`Uploaded ${formatDistanceToNow(resumeInfo.createdAt, {
{`${resumeInfo.numComments} comment${ addSuffix: true,
resumeInfo.numComments === 1 ? '' : 's' })} by ${resumeInfo.user}`}
}`}
</div> </div>
<div className="flex gap-2"> <div className="mt-2 text-slate-400">
{resumeInfo.isStarredByUser ? ( {getFilterLabel(LOCATIONS, resumeInfo.location as LocationFilter)}
<ColouredStarIcon className="w-4 text-yellow-400" />
) : (
<StarIcon className="w-4" />
)}
{`${resumeInfo.numStars} star${
resumeInfo.numStars === 1 ? '' : 's'
}`}
</div> </div>
</div> </div>
</div> </div>
<div className="col-span-3 self-center text-sm text-slate-500">
<div>
{`Uploaded ${formatDistanceToNow(resumeInfo.createdAt, {
addSuffix: true,
})} by ${resumeInfo.user}`}
</div>
<div className="mt-2 text-slate-400">{resumeInfo.location}</div>
</div>
<ChevronRightIcon className="col-span-1 w-8 self-center justify-self-center text-slate-400" /> <ChevronRightIcon className="col-span-1 w-8 self-center justify-self-center text-slate-400" />
</div> </div>
</Link> </Link>

@ -3,6 +3,8 @@ import type { SubmitHandler } from 'react-hook-form';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { Button, Dialog, TextArea } from '@tih/ui'; import { Button, Dialog, TextArea } from '@tih/ui';
import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics';
import { trpc } from '~/utils/trpc'; import { trpc } from '~/utils/trpc';
type ResumeCommentsFormProps = Readonly<{ type ResumeCommentsFormProps = Readonly<{
@ -25,6 +27,8 @@ export default function ResumeCommentsForm({
setShowCommentsForm, setShowCommentsForm,
}: ResumeCommentsFormProps) { }: ResumeCommentsFormProps) {
const [showDialog, setShowDialog] = useState(false); const [showDialog, setShowDialog] = useState(false);
const { event: gaEvent } = useGoogleAnalytics();
const { const {
register, register,
handleSubmit, handleSubmit,
@ -50,21 +54,49 @@ export default function ResumeCommentsForm({
trpcContext.invalidateQueries(['resumes.resume.findAll']); trpcContext.invalidateQueries(['resumes.resume.findAll']);
trpcContext.invalidateQueries(['resumes.resume.user.findUserStarred']); trpcContext.invalidateQueries(['resumes.resume.user.findUserStarred']);
trpcContext.invalidateQueries(['resumes.resume.user.findUserCreated']); trpcContext.invalidateQueries(['resumes.resume.user.findUserCreated']);
gaEvent({
action: 'resumes.comment_submit',
category: 'engagement',
label: 'Submit comment',
});
}, },
}, },
); );
const invalidateResumeQueries = () => {
trpcContext.invalidateQueries(['resumes.resume.findOne']);
trpcContext.invalidateQueries(['resumes.resume.findAll']);
trpcContext.invalidateQueries(['resumes.resume.user.findUserStarred']);
trpcContext.invalidateQueries(['resumes.resume.user.findUserCreated']);
};
const resolveMutation = trpc.useMutation('resumes.resume.user.resolve', {
onSuccess() {
invalidateResumeQueries();
},
});
// TODO: Give a feedback to the user if the action succeeds/fails // TODO: Give a feedback to the user if the action succeeds/fails
const onSubmit: SubmitHandler<IFormInput> = async (data) => { const onSubmit: SubmitHandler<IFormInput> = async (formData) => {
return await commentCreateMutation.mutate( return await commentCreateMutation.mutate(
{ {
resumeId, resumeId,
...data, ...formData,
}, },
{ {
onSuccess: () => { onSuccess: (data) => {
// Redirect back to comments section // Redirect back to comments section
setShowCommentsForm(false); setShowCommentsForm(false);
const { prevCount, newCount } = data;
// Auto mark resume as resolved once the total comments passes the 5 threshold
if (
(newCount >= 5 && prevCount < 5) ||
(newCount >= 15 && prevCount < 15)
) {
resolveMutation.mutate({
id: resumeId,
val: true,
});
}
}, },
}, },
); );

@ -48,62 +48,64 @@ export default function ResumeCommentsList({
} }
}; };
if (commentsQuery.isLoading) {
return (
<div className="col-span-10 pt-4">
<Spinner display="block" size="lg" />
</div>
);
}
return ( return (
<div className="space-y-3"> <div className="mb-8 flow-root h-[calc(100vh-13rem)] w-full flex-col space-y-10 overflow-y-auto overflow-x-hidden pb-16">
{commentsQuery.isLoading ? ( {RESUME_COMMENTS_SECTIONS.map(({ label, value }) => {
<div className="col-span-10 pt-4"> const comments = commentsQuery.data
<Spinner display="block" size="lg" /> ? commentsQuery.data.filter((comment: ResumeComment) => {
</div> return (comment.section as string) === value;
) : ( })
<div className="mb-8 flow-root h-[calc(100vh-13rem)] w-full flex-col space-y-4 overflow-y-auto overflow-x-hidden pb-16"> : [];
{RESUME_COMMENTS_SECTIONS.map(({ label, value }) => { const commentCount = comments.length;
const comments = commentsQuery.data
? commentsQuery.data.filter((comment: ResumeComment) => {
return (comment.section as string) === value;
})
: [];
const commentCount = comments.length;
return ( return (
<div key={value} className="space-y-6 pr-4"> <div key={value} className="space-y-4 pr-4">
<div className="text-primary-800 -mb-2 flex flex-row items-center space-x-2"> {/* CommentHeader Section */}
{renderIcon(value)} <div className="text-primary-800 flex items-center space-x-2">
<hr className="flex-grow border-slate-800" />
{renderIcon(value)}
<div className="w-fit text-lg font-medium">{label}</div> <span className="w-fit text-lg font-medium">{label}</span>
</div> <hr className="flex-grow border-slate-800" />
</div>
<div {/* Comment Section */}
className={clsx( <div
'space-y-2 rounded-md border-2 bg-white px-4 py-3 drop-shadow-md', className={clsx(
commentCount ? 'border-slate-300' : 'border-slate-300', 'space-y-2 rounded-md border-2 bg-white px-4 py-3 drop-shadow-md',
)}> commentCount ? 'border-slate-300' : 'border-slate-300',
{commentCount > 0 ? ( )}>
comments.map((comment) => { {commentCount > 0 ? (
return ( comments.map((comment) => {
<ResumeCommentListItem return (
key={comment.id} <ResumeCommentListItem
comment={comment} key={comment.id}
userId={sessionData?.user?.id} comment={comment}
/> userId={sessionData?.user?.id}
); />
}) );
) : ( })
<div className="flex flex-row items-center text-sm"> ) : (
<ChatBubbleLeftRightIcon className="mr-2 h-6 w-6 text-slate-500" /> <div className="flex flex-row items-center text-sm">
<ChatBubbleLeftRightIcon className="mr-2 h-6 w-6 text-slate-500" />
<div className="text-slate-500"> <div className="text-slate-500">
There are no comments for this section yet! There are no comments for this section yet!
</div> </div>
</div>
)}
</div> </div>
)}
<hr className="border-gray-300" /> </div>
</div> </div>
); );
})} })}
</div>
)}
</div> </div>
); );
} }

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

@ -1,4 +1,5 @@
import clsx from 'clsx'; import clsx from 'clsx';
import { useRouter } from 'next/router';
import { useState } from 'react'; import { useState } from 'react';
import { import {
ArrowDownCircleIcon, ArrowDownCircleIcon,
@ -6,6 +7,8 @@ import {
} from '@heroicons/react/20/solid'; } from '@heroicons/react/20/solid';
import { Vote } from '@prisma/client'; import { Vote } from '@prisma/client';
import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics';
import { trpc } from '~/utils/trpc'; import { trpc } from '~/utils/trpc';
type ResumeCommentVoteButtonsProps = { type ResumeCommentVoteButtonsProps = {
@ -19,8 +22,10 @@ export default function ResumeCommentVoteButtons({
}: ResumeCommentVoteButtonsProps) { }: ResumeCommentVoteButtonsProps) {
const [upvoteAnimation, setUpvoteAnimation] = useState(false); const [upvoteAnimation, setUpvoteAnimation] = useState(false);
const [downvoteAnimation, setDownvoteAnimation] = useState(false); const [downvoteAnimation, setDownvoteAnimation] = useState(false);
const { event: gaEvent } = useGoogleAnalytics();
const trpcContext = trpc.useContext(); const trpcContext = trpc.useContext();
const router = useRouter();
// COMMENT VOTES // COMMENT VOTES
const commentVotesQuery = trpc.useQuery([ const commentVotesQuery = trpc.useQuery([
@ -33,6 +38,11 @@ export default function ResumeCommentVoteButtons({
onSuccess: () => { onSuccess: () => {
// Comment updated, invalidate query to trigger refetch // Comment updated, invalidate query to trigger refetch
trpcContext.invalidateQueries(['resumes.comments.votes.list']); trpcContext.invalidateQueries(['resumes.comments.votes.list']);
gaEvent({
action: 'resumes.comment_vote',
category: 'engagement',
label: 'Upvote/Downvote comment',
});
}, },
}, },
); );
@ -42,11 +52,21 @@ export default function ResumeCommentVoteButtons({
onSuccess: () => { onSuccess: () => {
// Comment updated, invalidate query to trigger refetch // Comment updated, invalidate query to trigger refetch
trpcContext.invalidateQueries(['resumes.comments.votes.list']); trpcContext.invalidateQueries(['resumes.comments.votes.list']);
gaEvent({
action: 'resumes.comment_unvote',
category: 'engagement',
label: 'Unvote comment',
});
}, },
}, },
); );
const onVote = async (value: Vote, setAnimation: (_: boolean) => void) => { const onVote = async (value: Vote, setAnimation: (_: boolean) => void) => {
if (!userId) {
router.push('/api/auth/signin');
return;
}
setAnimation(true); setAnimation(true);
if (commentVotesQuery.data?.userVote?.value === value) { if (commentVotesQuery.data?.userVote?.value === value) {
@ -74,7 +94,6 @@ export default function ResumeCommentVoteButtons({
<> <>
<button <button
disabled={ disabled={
!userId ||
commentVotesQuery.isLoading || commentVotesQuery.isLoading ||
commentVotesUpsertMutation.isLoading || commentVotesUpsertMutation.isLoading ||
commentVotesDeleteMutation.isLoading commentVotesDeleteMutation.isLoading
@ -103,7 +122,6 @@ export default function ResumeCommentVoteButtons({
<button <button
disabled={ disabled={
!userId ||
commentVotesQuery.isLoading || commentVotesQuery.isLoading ||
commentVotesUpsertMutation.isLoading || commentVotesUpsertMutation.isLoading ||
commentVotesDeleteMutation.isLoading commentVotesDeleteMutation.isLoading

@ -1,4 +1,4 @@
import Link from 'next/link'; import { Button } from '@tih/ui';
import { Container } from './Container'; import { Container } from './Container';
@ -14,13 +14,12 @@ export function CallToAction() {
It's free! Take charge of your resume game by learning from the top It's free! Take charge of your resume game by learning from the top
engineers in the field. engineers in the field.
</p> </p>
<Link href="/resumes/browse"> <Button
<button className="mt-4"
className="bg-primary-500 mt-4 rounded-md py-2 px-3 text-sm font-medium text-white" href="/resumes"
type="button"> label="Start browsing now"
Start browsing now variant="primary"
</button> />
</Link>
</div> </div>
</Container> </Container>
</section> </section>

@ -1,4 +1,5 @@
import Link from 'next/link'; import Link from 'next/link';
import { Button } from '@tih/ui';
import { Container } from './Container'; import { Container } from './Container';
@ -24,13 +25,8 @@ export function Hero() {
your fellow engineers your fellow engineers
</p> </p>
<div className="mt-10 flex justify-center gap-x-4"> <div className="mt-10 flex justify-center gap-x-4">
<Link href="/resumes/browse"> <Button href="/resumes" label="Start browsing now" variant="primary" />
<button {/* TODO: Update video */}
className="bg-primary-500 rounded-md py-2 px-3 text-sm font-medium text-white"
type="button">
Start browsing now
</button>
</Link>
<Link href="https://www.youtube.com/watch?v=dQw4w9WgXcQ"> <Link href="https://www.youtube.com/watch?v=dQw4w9WgXcQ">
<button <button
className="focus-visible:outline-primary-600 group inline-flex items-center justify-center rounded-md py-2 px-4 text-sm ring-1 ring-slate-200 hover:text-slate-900 hover:ring-slate-300 focus:outline-none focus-visible:ring-slate-300 active:bg-slate-100 active:text-slate-600" className="focus-visible:outline-primary-600 group inline-flex items-center justify-center rounded-md py-2 px-4 text-sm ring-1 ring-slate-200 hover:text-slate-900 hover:ring-slate-300 focus:outline-none focus-visible:ring-slate-300 active:bg-slate-100 active:text-slate-600"

@ -69,7 +69,7 @@ export function PrimaryFeatures() {
<div <div
key={feature.title} key={feature.title}
className={clsx( className={clsx(
'group relative rounded-full py-1 px-4 lg:rounded-r-none lg:rounded-l-xl lg:p-6', 'group relative rounded-full lg:rounded-r-none lg:rounded-l-xl lg:p-6',
selectedIndex === featureIndex selectedIndex === featureIndex
? 'bg-white lg:bg-white/10 lg:ring-1 lg:ring-inset lg:ring-white/10' ? 'bg-white lg:bg-white/10 lg:ring-1 lg:ring-inset lg:ring-white/10'
: 'hover:bg-white/10 lg:hover:bg-white/5', : 'hover:bg-white/10 lg:hover:bg-white/5',
@ -77,6 +77,7 @@ export function PrimaryFeatures() {
<h3> <h3>
<Tab <Tab
className={clsx( className={clsx(
'rounded-full py-1 px-4',
'font-display text-lg [&:not(:focus-visible)]:focus:outline-none', 'font-display text-lg [&:not(:focus-visible)]:focus:outline-none',
selectedIndex === featureIndex selectedIndex === featureIndex
? 'text-blue-600 lg:text-white' ? 'text-blue-600 lg:text-white'
@ -88,7 +89,7 @@ export function PrimaryFeatures() {
</h3> </h3>
<p <p
className={clsx( className={clsx(
'mt-2 hidden text-sm lg:block', 'mt-2 hidden px-4 text-sm lg:block',
selectedIndex === featureIndex selectedIndex === featureIndex
? 'text-white' ? 'text-white'
: 'text-blue-100 group-hover:text-white', : 'text-blue-100 group-hover:text-white',

@ -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}
/>
);
}

@ -6,18 +6,21 @@ import { trpc } from '~/utils/trpc';
type Props = Readonly<{ type Props = Readonly<{
disabled?: boolean; disabled?: boolean;
errorMessage?: string;
isLabelHidden?: boolean; isLabelHidden?: boolean;
onSelect: (option: TypeaheadOption) => void; onSelect: (option: TypeaheadOption | null) => void;
placeHolder?: string; placeholder?: string;
required?: boolean; required?: boolean;
value?: TypeaheadOption | null;
}>; }>;
export default function CompaniesTypeahead({ export default function CompaniesTypeahead({
disabled, disabled,
onSelect, onSelect,
isLabelHidden, isLabelHidden,
placeHolder, placeholder,
required, required,
value,
}: Props) { }: Props) {
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const companies = trpc.useQuery([ const companies = trpc.useQuery([
@ -43,8 +46,10 @@ export default function CompaniesTypeahead({
value: id, value: id,
})) ?? [] })) ?? []
} }
placeholder={placeHolder} placeholder={placeholder}
required={required} required={required}
textSize="inherit"
value={value}
onQueryChange={setQuery} onQueryChange={setQuery}
onSelect={onSelect} 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<{ type Props = Readonly<{
disabled?: boolean; disabled?: boolean;
isLabelHidden?: boolean; isLabelHidden?: boolean;
onSelect: (option: TypeaheadOption) => void; onSelect: (option: TypeaheadOption | null) => void;
placeHolder?: string; placeholder?: string;
required?: boolean; required?: boolean;
value?: TypeaheadOption | null;
}>; }>;
export default function JobTitlesTypeahead({ export default function JobTitlesTypeahead({
disabled, disabled,
onSelect, onSelect,
isLabelHidden, isLabelHidden,
placeHolder, placeholder,
required, required,
value,
}: Props) { }: Props) {
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const options = Object.entries(JobTitleLabels) const options = Object.entries(JobTitleLabels)
@ -39,8 +41,10 @@ export default function JobTitlesTypeahead({
noResultsMessage="No available job titles." noResultsMessage="No available job titles."
nullable={true} nullable={true}
options={options} options={options}
placeholder={placeHolder} placeholder={placeholder}
required={required} required={required}
textSize="inherit"
value={value}
onQueryChange={setQuery} onQueryChange={setQuery}
onSelect={onSelect} onSelect={onSelect}
/> />

@ -1,4 +1,4 @@
import { useId } from 'react'; import { useEffect, useId, useState } from 'react';
import { Select } from '@tih/ui'; import { Select } from '@tih/ui';
export type Month = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12; export type Month = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
@ -8,12 +8,17 @@ export type MonthYear = Readonly<{
year: number; year: number;
}>; }>;
export type MonthYearOptional = Readonly<{
month: Month | null;
year: number | null;
}>;
type Props = Readonly<{ type Props = Readonly<{
errorMessage?: string; errorMessage?: string;
monthLabel?: string; monthLabel?: string;
monthRequired?: boolean; monthRequired?: boolean;
onChange: (value: MonthYear) => void; onChange: (value: MonthYearOptional) => void;
value: MonthYear; value: MonthYearOptional;
yearLabel?: string; yearLabel?: string;
yearRequired?: boolean; yearRequired?: boolean;
}>; }>;
@ -89,29 +94,45 @@ export default function MonthYearPicker({
}: Props) { }: Props) {
const hasError = errorMessage != null; const hasError = errorMessage != null;
const errorId = useId(); const errorId = useId();
const [monthCounter, setMonthCounter] = useState<number>(0);
const [yearCounter, setYearCounter] = useState<number>(0);
useEffect(() => {
if (value.month == null) {
setMonthCounter((val) => val + 1);
}
if (value.year == null) {
setYearCounter((val) => val + 1);
}
}, [value.month, value.year]);
return ( return (
<div <div aria-describedby={hasError ? errorId : undefined}>
aria-describedby={hasError ? errorId : undefined} <div className="flex items-end space-x-2">
className="flex items-end space-x-4"> <Select
<Select key={`month:${monthCounter}`}
label={monthLabel} label={monthLabel}
options={MONTH_OPTIONS} options={MONTH_OPTIONS}
required={monthRequired} placeholder="Select month"
value={value.month} required={monthRequired}
onChange={(newMonth) => value={value.month}
onChange({ month: Number(newMonth) as Month, year: value.year }) onChange={(newMonth) =>
} onChange({ month: Number(newMonth) as Month, year: value.year })
/> }
<Select />
label={yearLabel} <Select
options={YEAR_OPTIONS} key={`year:${yearCounter}`}
required={yearRequired} label={yearLabel}
value={value.year} options={YEAR_OPTIONS}
onChange={(newYear) => placeholder="Select year"
onChange({ month: value.month, year: Number(newYear) }) required={yearRequired}
} value={value.year}
/> onChange={(newYear) =>
onChange({ month: value.month, year: Number(newYear) })
}
/>
</div>
{errorMessage && ( {errorMessage && (
<p className="text-danger-600 mt-2 text-sm" id={errorId}> <p className="text-danger-600 mt-2 text-sm" id={errorId}>
{errorMessage} {errorMessage}

@ -33,6 +33,8 @@ import type {
ProfileAnalysis, ProfileAnalysis,
ProfileOffer, ProfileOffer,
SpecificYoe, SpecificYoe,
UserProfile,
UserProfileOffer,
Valuation, Valuation,
} from '~/types/offers'; } from '~/types/offers';
@ -526,8 +528,10 @@ export const profileDtoMapper = (
offersIntern: (OffersIntern & { monthlySalary: OffersCurrency }) | null; offersIntern: (OffersIntern & { monthlySalary: OffersCurrency }) | null;
} }
>; >;
user: User | null;
}, },
inputToken: string | undefined, inputToken: string | undefined,
inputUserId: string | null | undefined,
) => { ) => {
const profileDto: Profile = { const profileDto: Profile = {
analysis: profileAnalysisDtoMapper(profile.analysis), analysis: profileAnalysisDtoMapper(profile.analysis),
@ -535,6 +539,7 @@ export const profileDtoMapper = (
editToken: null, editToken: null,
id: profile.id, id: profile.id,
isEditable: false, isEditable: false,
isSaved: false,
offers: profile.offers.map((offer) => profileOfferDtoMapper(offer)), offers: profile.offers.map((offer) => profileOfferDtoMapper(offer)),
profileName: profile.profileName, profileName: profile.profileName,
}; };
@ -542,6 +547,20 @@ export const profileDtoMapper = (
if (inputToken === profile.editToken) { if (inputToken === profile.editToken) {
profileDto.editToken = profile.editToken ?? null; profileDto.editToken = profile.editToken ?? null;
profileDto.isEditable = true; 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
// }
// }
// TODO: REMOVE THIS ONCE U CHANGE THE SCHEMA
if (users?.id === inputUserId) {
profileDto.isSaved = true;
}
} }
return profileDto; return profileDto;
@ -626,3 +645,98 @@ export const getOffersResponseMapper = (
}; };
return getOffersResponse; return getOffersResponse;
}; };
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> => {
if (res) {
return res.OffersProfile.map((profile) => {
return {
createdAt: profile.createdAt,
id: profile.id,
offers: profile.offers.map((offer) => {
return userProfileOfferDtoMapper(offer);
}),
profileName: profile.profileName,
token: profile.editToken,
};
}).sort((a, b) => {
return b.createdAt > a.createdAt ? 1 : -1;
});
}
return [];
};
const userProfileOfferDtoMapper = (
offer: OffersOffer & {
company: Company;
offersFullTime:
| (OffersFullTime & { totalCompensation: OffersCurrency })
| null;
offersIntern: (OffersIntern & { monthlySalary: OffersCurrency }) | null;
},
): UserProfileOffer => {
const mappedOffer: UserProfileOffer = {
company: offersCompanyDtoMapper(offer.company),
id: offer.id,
income: {
baseCurrency: '',
baseValue: -1,
currency: '',
id: '',
value: -1,
},
jobType: offer.jobType,
level: offer.offersFullTime?.level ?? '',
location: offer.location,
monthYearReceived: offer.monthYearReceived,
title:
offer.jobType === JobType.FULLTIME
? offer.offersFullTime?.title ?? ''
: offer.offersIntern?.title ?? '',
};
if (offer.offersFullTime?.totalCompensation) {
mappedOffer.income.value = offer.offersFullTime.totalCompensation.value;
mappedOffer.income.currency =
offer.offersFullTime.totalCompensation.currency;
mappedOffer.income.id = offer.offersFullTime.totalCompensation.id;
mappedOffer.income.baseValue =
offer.offersFullTime.totalCompensation.baseValue;
mappedOffer.income.baseCurrency =
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.id = offer.offersIntern.monthlySalary.id;
mappedOffer.income.baseValue = offer.offersIntern.monthlySalary.baseValue;
mappedOffer.income.baseCurrency =
offer.offersIntern.monthlySalary.baseCurrency;
} else {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Total Compensation or Salary not found',
});
}
return mappedOffer;
};

@ -1,45 +0,0 @@
import { useState } from 'react';
import OffersTitle from '~/components/offers/OffersTitle';
import OffersTable from '~/components/offers/table/OffersTable';
import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead';
import JobTitlesTypeahead from '~/components/shared/JobTitlesTypahead';
export default function OffersHomePage() {
const [jobTitleFilter, setjobTitleFilter] = useState('software-engineer');
const [companyFilter, setCompanyFilter] = useState('');
return (
<main className="flex-1 overflow-y-auto">
<div className="grid-rows grid h-1/2 bg-slate-100">
<OffersTitle />
<div className="flex items-start justify-center">
<div className="mt-4 flex items-center">
Viewing offers for
<div className="mx-4">
<JobTitlesTypeahead
isLabelHidden={true}
placeHolder="Software Engineer"
onSelect={({ value }) => setjobTitleFilter(value)}
/>
</div>
in
<div className="ml-4">
<CompaniesTypeahead
isLabelHidden={true}
placeHolder="All Companies"
onSelect={({ value }) => setCompanyFilter(value)}
/>
</div>
</div>
</div>
</div>
<div className="flex justify-center bg-white pb-20 pt-10">
<OffersTable
companyFilter={companyFilter}
jobTitleFilter={jobTitleFilter}
/>
</div>
</main>
);
}

@ -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 repository
</h1>
<p className="mx-auto w-3/4 text-start text-xl text-slate-900">
Save your offer profiles to respository 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>
)}
</>
);
}

@ -0,0 +1,251 @@
import type { SVGProps } from 'react';
import {
BookmarkSquareIcon,
ChartBarSquareIcon,
InformationCircleIcon,
ShareIcon,
TableCellsIcon,
UsersIcon,
} from '@heroicons/react/24/outline';
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 = [
{
description:
'Profile names are randomly generated to keep your offers strictly anonymous.',
icon: UsersIcon,
name: 'Anonymized Profile Name',
},
{
description:
'Only users with the edit link can edit that profile. Share profiles to others using a public link without giving edit permission.',
icon: ShareIcon,
name: 'Edit Link v.s. Public Link',
},
{
description:
"Offer profiles will not be automatically saved under creators' account in our database unless explicit permission is given.",
icon: BookmarkSquareIcon,
name: 'Save with Permission',
},
];
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: 'https://github.com/yangshun/tech-interview-handbook',
icon: (props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) => (
<svg fill="currentColor" viewBox="0 0 24 24" {...props}>
<path
clipRule="evenodd"
d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"
fillRule="evenodd"
/>
</svg>
),
name: 'GitHub',
},
],
};
export default function LandingPage() {
return (
<div className="mx-auto w-full overflow-y-auto bg-white">
<main>
{/* 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">
made easier
</span>
</h1>
<p className="mx-auto mt-6 max-w-lg text-center text-xl sm:max-w-3xl">
Analyze your offers using profiles from fellow software engineers.
</p>
<div className="mx-auto mt-10 max-w-sm sm:flex sm:max-w-none sm:justify-center">
<div className="space-y-4 sm:mx-auto sm:inline-grid sm:grid-cols-1 sm:gap-5 sm:space-y-0">
<a
className="border-grey-600 flex items-center justify-center rounded-md border bg-white px-4 py-3 text-base font-medium text-indigo-700 shadow-sm hover:bg-indigo-50 sm:px-8"
href={HOME_URL}>
Get started
</a>
{/* <a
className="bg-primary-500 flex items-center justify-center rounded-md border border-transparent px-4 py-3 text-base font-medium text-white shadow-sm hover:bg-opacity-70 sm:px-8"
href="#">
Live demo
</a> */}
</div>
</div>
</div>
</div>
{/* Alternating Feature Sections */}
<div className="relative overflow-hidden pt-16 pb-32">
<div
aria-hidden="true"
className="absolute inset-x-0 top-0 h-48 bg-gradient-to-b from-gray-100"
/>
<div className="relative">
<LeftTextCard
description="Filter relevant offers by job title, company, submission date, salary and more."
icon={
<TableCellsIcon
aria-hidden="true"
className="h-6 w-6 text-white"
/>
}
imageAlt="Offer table page"
imageSrc={offersBrowse}
title="Stay informed of recent offers"
/>
</div>
<div className="mt-36">
<RightTextCard
description="With our offer engine analysis, you can compare your offers with other offers on the market and make an informed decision."
icon={
<ChartBarSquareIcon
aria-hidden="true"
className="h-6 w-6 text-white"
/>
}
imageAlt="Customer profile user interface"
imageSrc={offersAnalysis}
title="Better understand your offers"
/>
</div>
<div className="mt-36">
<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."
icon={
<InformationCircleIcon
aria-hidden="true"
className="h-6 w-6 text-white"
/>
}
imageAlt="Offer table page"
imageSrc={offersProfile}
title="Choosing an offer needs context"
/>
</div>
</div>
{/* Gradient Feature Section */}
<div className="to-primary-600 bg-gradient-to-r from-purple-800">
<div className="mx-auto max-w-4xl px-4 py-16 sm:px-6 sm:pt-20 sm:pb-24 lg:max-w-7xl lg:px-8 lg:pt-24">
<h2 className="flex justify-center text-4xl font-bold tracking-tight text-white">
Your privacy is our priority.
</h2>
<p className="text-primary-100 mt-4 flex flex-row justify-center text-lg">
All offer profiles are anonymized and we do not store information
about your personal identity.
</p>
<div className="mt-12 grid grid-cols-1 gap-x-6 gap-y-12 sm:grid-cols-2 lg:mt-16 lg:grid-cols-3 lg:gap-x-8 lg:gap-y-16">
{features.map((feature) => (
<div key={feature.name}>
<div>
<span className="flex h-12 w-12 items-center justify-center rounded-md bg-white bg-opacity-10">
<feature.icon
aria-hidden="true"
className="h-6 w-6 text-white"
/>
</span>
</div>
<div className="mt-6">
<h3 className="text-lg font-medium text-white">
{feature.name}
</h3>
<p className="text-primary-100 mt-2 text-base">
{feature.description}
</p>
</div>
</div>
))}
</div>
</div>
</div>
{/* CTA Section */}
<div className="bg-white">
<div className="mx-auto max-w-4xl py-16 px-4 sm:px-6 sm:py-24 lg:flex lg:max-w-7xl lg:items-center lg:justify-between lg:px-8">
<h2 className="text-4xl font-bold tracking-tight text-gray-900 sm:text-4xl">
<span className="block">Ready to get started?</span>
<span className="to-primary-600 -mb-1 block bg-gradient-to-r from-purple-600 bg-clip-text pb-1 text-transparent">
Create your own offer profile today.
</span>
</h2>
<div className="mt-6 space-y-4 sm:flex sm:space-y-0 sm:space-x-5">
<a
className="to-primary-500 flex items-center justify-center rounded-md border border-transparent bg-gradient-to-r from-purple-600 bg-origin-border px-4 py-3 text-base font-medium text-white shadow-sm hover:from-purple-700 hover:to-indigo-700"
href={HOME_URL}>
Get Started
</a>
</div>
</div>
</div>
</main>
<footer aria-labelledby="footer-heading" className="bg-gray-50">
<h2 className="sr-only" id="footer-heading">
Footer
</h2>
<div className="mx-auto max-w-7xl px-4 pt-0 pb-8 sm:px-6 lg:px-8">
<div className="mt-12 border-t border-gray-200 pt-8 md:flex md:items-center md:justify-between lg:mt-16">
<div className="flex space-x-6 md:order-2">
{footerNavigation.social.map((item) => (
<a
key={item.name}
className="text-gray-400 hover:text-gray-500"
href={item.href}>
<span className="sr-only">{item.name}</span>
<item.icon aria-hidden="true" className="h-6 w-6" />
</a>
))}
</div>
<p className="mt-8 text-base text-gray-400 md:order-1 md:mt-0">
&copy; 2022 Tech Offers Repo. All rights reserved.
</p>
</div>
</div>
</footer>
</div>
);
}

@ -1,244 +1,79 @@
import type { SVGProps } from 'react'; import Link from 'next/link';
import { import { useState } from 'react';
BookmarkSquareIcon, import { Banner } from '@tih/ui';
ChartBarSquareIcon,
InformationCircleIcon,
ShareIcon,
TableCellsIcon,
UsersIcon,
} from '@heroicons/react/24/outline';
import LeftTextCard from '~/components/offers/landing/LeftTextCard'; import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics';
import RightTextCard from '~/components/offers/landing/RightTextCard'; import OffersTable from '~/components/offers/table/OffersTable';
import { HOME_URL } from '~/components/offers/types'; import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead';
import JobTitlesTypeahead from '~/components/shared/JobTitlesTypahead';
const features = [ export default function OffersHomePage() {
{ const [jobTitleFilter, setjobTitleFilter] = useState('software-engineer');
description: const [companyFilter, setCompanyFilter] = useState('');
'Profile names are randomly generated to keep your offers strictly anonymous.', const { event: gaEvent } = useGoogleAnalytics();
icon: UsersIcon,
name: 'Anonymized Profile Name',
},
{
description:
'Only users with the edit link can edit that profile. Share profiles to others using a public link without giving edit permission.',
icon: ShareIcon,
name: 'Edit Link v.s. Public Link',
},
{
description:
"Offer profiles will not be automatically saved under creators' account in our database unless explicit permission is given.",
icon: BookmarkSquareIcon,
name: 'Save with Permission',
},
];
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: 'https://github.com/yangshun/tech-interview-handbook',
icon: (props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) => (
<svg fill="currentColor" viewBox="0 0 24 24" {...props}>
<path
clipRule="evenodd"
d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"
fillRule="evenodd"
/>
</svg>
),
name: 'GitHub',
},
],
};
export default function LandingPage() {
return ( return (
<div className="mx-auto w-full overflow-y-auto bg-white"> <main className="flex-1 overflow-y-auto">
<main> <Banner size="sm">
{/* Hero section */} Check if your offer is competitive by submitting it{' '}
<div className="relative h-full"> <Link className="underline" href="/offers/submit">
<div className="relative px-4 py-16 sm:px-6 sm:py-24 lg:py-32 lg:px-8"> here
<h1 className="text-center text-4xl font-bold tracking-tight sm:text-5xl lg:text-6xl"> </Link>
<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"> </Banner>
made easier <div className="bg-slate-100 py-16 px-4">
</span> <div>
<div>
<h1 className="text-primary-600 text-center text-4xl font-bold sm:text-5xl">
Tech Offers Repo
</h1> </h1>
<p className="mx-auto mt-6 max-w-lg text-center text-xl sm:max-w-3xl">
Analyze your offers using profiles from fellow software engineers.
</p>
<div className="mx-auto mt-10 max-w-sm sm:flex sm:max-w-none sm:justify-center">
<div className="space-y-4 sm:mx-auto sm:inline-grid sm:grid-cols-2 sm:gap-5 sm:space-y-0">
<a
className="border-grey-600 flex items-center justify-center rounded-md border bg-white bg-white px-4 py-3 text-base font-medium text-indigo-700 shadow-sm hover:bg-indigo-50 sm:px-8"
href={HOME_URL}>
Get started
</a>
<a
className="bg-primary-500 flex items-center justify-center rounded-md border border-transparent px-4 py-3 text-base font-medium text-white shadow-sm hover:bg-opacity-70 sm:px-8"
href="#">
Live demo
</a>
</div>
</div>
</div> </div>
</div> <div className="mt-4 text-center text-lg text-slate-600 sm:text-2xl">
Find out how good your offer is. Discover how others got their
{/* Alternating Feature Sections */} offers.
<div className="relative overflow-hidden pt-16 pb-32">
<div
aria-hidden="true"
className="absolute inset-x-0 top-0 h-48 bg-gradient-to-b from-gray-100"
/>
<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."
icon={
<InformationCircleIcon
aria-hidden="true"
className="h-6 w-6 text-white"
/>
}
imageAlt="Offer table page"
imageSrc="https://tailwindui.com/img/component-images/inbox-app-screenshot-1.jpg"
title="Choosing an offer needs context"
/>
</div> </div>
<div className="mt-36"> </div>
<RightTextCard <div className="mt-6 flex flex-col items-center justify-center space-y-2 text-base text-slate-700 sm:mt-10 sm:flex-row sm:space-y-0 sm:space-x-4 sm:text-lg">
description="With our offer engine analysis, you can compare your offers with other offers on the market and make an informed decision." <span>Viewing offers for</span>
icon={ <div className="flex items-center space-x-4">
<ChartBarSquareIcon <JobTitlesTypeahead
aria-hidden="true" isLabelHidden={true}
className="h-6 w-6 text-white" placeholder="Software Engineer"
/> onSelect={(option) => {
} if (option) {
imageAlt="Customer profile user interface" setjobTitleFilter(option.value);
imageSrc="https://tailwindui.com/img/component-images/inbox-app-screenshot-2.jpg" gaEvent({
title="Better understand your offers" action: `offers.table_filter_job_title_${option.value}`,
category: 'engagement',
label: 'Filter by job title',
});
}
}}
/> />
</div> <span>in</span>
<div className="mt-36"> <CompaniesTypeahead
<LeftTextCard isLabelHidden={true}
description="Filter relevant offers by job title, company, submission date, salary and more." placeholder="All Companies"
icon={ onSelect={(option) => {
<TableCellsIcon if (option) {
aria-hidden="true" setCompanyFilter(option.value);
className="h-6 w-6 text-white" gaEvent({
/> action: 'offers.table_filter_company',
} category: 'engagement',
imageAlt="Offer table page" label: 'Filter by company',
imageSrc="https://tailwindui.com/img/component-images/inbox-app-screenshot-1.jpg" });
title="Stay informed of recent offers" }
}}
/> />
</div> </div>
</div> </div>
</div>
{/* Gradient Feature Section */} <div className="flex justify-center bg-white pb-20 pt-10">
<div className="to-primary-600 bg-gradient-to-r from-purple-800"> <OffersTable
<div className="mx-auto max-w-4xl px-4 py-16 sm:px-6 sm:pt-20 sm:pb-24 lg:max-w-7xl lg:px-8 lg:pt-24"> companyFilter={companyFilter}
<h2 className="flex justify-center text-4xl font-bold tracking-tight text-white"> jobTitleFilter={jobTitleFilter}
Your privacy is our priority. />
</h2> </div>
<p className="text-primary-100 mt-4 flex flex-row justify-center text-lg"> </main>
All offer profiles are anonymized and we do not store information
about your personal identity.
</p>
<div className="mt-12 grid grid-cols-1 gap-x-6 gap-y-12 sm:grid-cols-2 lg:mt-16 lg:grid-cols-3 lg:gap-x-8 lg:gap-y-16">
{features.map((feature) => (
<div key={feature.name}>
<div>
<span className="flex h-12 w-12 items-center justify-center rounded-md bg-white bg-opacity-10">
<feature.icon
aria-hidden="true"
className="h-6 w-6 text-white"
/>
</span>
</div>
<div className="mt-6">
<h3 className="text-lg font-medium text-white">
{feature.name}
</h3>
<p className="text-primary-100 mt-2 text-base">
{feature.description}
</p>
</div>
</div>
))}
</div>
</div>
</div>
{/* CTA Section */}
<div className="bg-white">
<div className="mx-auto max-w-4xl py-16 px-4 sm:px-6 sm:py-24 lg:flex lg:max-w-7xl lg:items-center lg:justify-between lg:px-8">
<h2 className="text-4xl font-bold tracking-tight text-gray-900 sm:text-4xl">
<span className="block">Ready to get started?</span>
<span className="to-primary-600 -mb-1 block bg-gradient-to-r from-purple-600 bg-clip-text pb-1 text-transparent">
Create your own offer profile today.
</span>
</h2>
<div className="mt-6 space-y-4 sm:flex sm:space-y-0 sm:space-x-5">
<a
className="to-primary-500 flex items-center justify-center rounded-md border border-transparent bg-gradient-to-r from-purple-600 bg-origin-border px-4 py-3 text-base font-medium text-white shadow-sm hover:from-purple-700 hover:to-indigo-700"
href={HOME_URL}>
Get Started
</a>
</div>
</div>
</div>
</main>
<footer aria-labelledby="footer-heading" className="bg-gray-50">
<h2 className="sr-only" id="footer-heading">
Footer
</h2>
<div className="mx-auto max-w-7xl px-4 pt-0 pb-8 sm:px-6 lg:px-8">
<div className="mt-12 border-t border-gray-200 pt-8 md:flex md:items-center md:justify-between lg:mt-16">
<div className="flex space-x-6 md:order-2">
{footerNavigation.social.map((item) => (
<a
key={item.name}
className="text-gray-400 hover:text-gray-500"
href={item.href}>
<span className="sr-only">{item.name}</span>
<item.icon aria-hidden="true" className="h-6 w-6" />
</a>
))}
</div>
<p className="mt-8 text-base text-gray-400 md:order-1 md:mt-0">
&copy; 2022 Tech Interview Handbook Offer Profile Repository. All
rights reserved.
</p>
</div>
</div>
</footer>
</div>
); );
} }

@ -1,6 +1,7 @@
import Error from 'next/error'; import Error from 'next/error';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useState } from 'react'; import { useState } from 'react';
import { Spinner, useToast } from '@tih/ui';
import { ProfileDetailTab } from '~/components/offers/constants'; import { ProfileDetailTab } from '~/components/offers/constants';
import ProfileComments from '~/components/offers/profile/ProfileComments'; import ProfileComments from '~/components/offers/profile/ProfileComments';
@ -14,7 +15,6 @@ import { HOME_URL } from '~/components/offers/types';
import type { JobTitleType } from '~/components/shared/JobTitles'; import type { JobTitleType } from '~/components/shared/JobTitles';
import { getLabelForJobTitleType } from '~/components/shared/JobTitles'; import { getLabelForJobTitleType } from '~/components/shared/JobTitles';
import { useToast } from '~/../../../packages/ui/dist';
import { convertMoneyToString } from '~/utils/offers/currency'; import { convertMoneyToString } from '~/utils/offers/currency';
import { getProfilePath } from '~/utils/offers/link'; import { getProfilePath } from '~/utils/offers/link';
import { formatDate } from '~/utils/offers/time'; import { formatDate } from '~/utils/offers/time';
@ -24,9 +24,6 @@ import type { Profile, ProfileAnalysis, ProfileOffer } from '~/types/offers';
export default function OfferProfile() { export default function OfferProfile() {
const { showToast } = useToast(); const { showToast } = useToast();
const ErrorPage = (
<Error statusCode={404} title="Requested profile does not exist." />
);
const router = useRouter(); const router = useRouter();
const { offerProfileId, token = '' } = router.query; const { offerProfileId, token = '' } = router.query;
const [isEditable, setIsEditable] = useState(false); const [isEditable, setIsEditable] = useState(false);
@ -50,11 +47,11 @@ export default function OfferProfile() {
router.push(HOME_URL); router.push(HOME_URL);
} }
// If the profile is not editable with a wrong token, redirect to the profile page // If the profile is not editable with a wrong token, redirect to the profile page
if (!data?.isEditable && token !== '') { if (!data.isEditable && token !== '') {
router.push(getProfilePath(offerProfileId as string)); router.push(getProfilePath(offerProfileId as string));
} }
setIsEditable(data?.isEditable ?? false); setIsEditable(data.isEditable);
const filteredOffers: Array<OfferDisplayData> = data const filteredOffers: Array<OfferDisplayData> = data
? data?.offers.map((res: ProfileOffer) => { ? data?.offers.map((res: ProfileOffer) => {
@ -126,6 +123,7 @@ export default function OfferProfile() {
jobTitle: experience.title jobTitle: experience.title
? getLabelForJobTitleType(experience.title as JobTitleType) ? getLabelForJobTitleType(experience.title as JobTitleType)
: null, : null,
jobType: experience.jobType || undefined,
monthlySalary: experience.monthlySalary monthlySalary: experience.monthlySalary
? convertMoneyToString(experience.monthlySalary) ? convertMoneyToString(experience.monthlySalary)
: null, : null,
@ -177,8 +175,20 @@ export default function OfferProfile() {
return ( 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="mb-4 flex flex h-screen w-screen items-center justify-center divide-x">
<div className="h-full w-2/3 divide-y"> <div className="h-full w-2/3 divide-y">
<ProfileHeader <ProfileHeader
@ -186,6 +196,7 @@ export default function OfferProfile() {
handleDelete={handleDelete} handleDelete={handleDelete}
isEditable={isEditable} isEditable={isEditable}
isLoading={getProfileQuery.isLoading} isLoading={getProfileQuery.isLoading}
isSaved={getProfileQuery.data?.isSaved}
selectedTab={selectedTab} selectedTab={selectedTab}
setSelectedTab={setSelectedTab} setSelectedTab={setSelectedTab}
/> />

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

@ -0,0 +1,5 @@
import OffersSubmissionForm from '~/components/offers/offersSubmission/OffersSubmissionForm';
export default function OffersSubmissionPage() {
return <OffersSubmissionForm />;
}

@ -0,0 +1,129 @@
import { useRouter } from 'next/router';
import { useEffect, useRef, useState } from 'react';
import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/20/solid';
import { EyeIcon } from '@heroicons/react/24/outline';
import { Button, Spinner } from '@tih/ui';
import type { BreadcrumbStep } from '~/components/offers/Breadcrumb';
import { Breadcrumbs } from '~/components/offers/Breadcrumb';
import OffersProfileSave from '~/components/offers/offersSubmission/OffersProfileSave';
import OffersSubmissionAnalysis from '~/components/offers/offersSubmission/OffersSubmissionAnalysis';
import { getProfilePath } from '~/utils/offers/link';
import { trpc } from '~/utils/trpc';
import type { ProfileAnalysis } from '~/types/offers';
export default function OffersSubmissionResult() {
const router = useRouter();
let { offerProfileId, token = '' } = router.query;
offerProfileId = offerProfileId as string;
token = token as string;
const [step, setStep] = useState(0);
const [analysis, setAnalysis] = useState<ProfileAnalysis | null>(null);
const pageRef = useRef<HTMLDivElement>(null);
const scrollToTop = () =>
pageRef.current?.scrollTo({ behavior: 'smooth', top: 0 });
// TODO: Check if the token is valid before showing this page
const getAnalysis = trpc.useQuery(
['offers.analysis.get', { profileId: offerProfileId }],
{
onSuccess(data) {
setAnalysis(data);
},
},
);
const steps = [
<OffersProfileSave key={0} profileId={offerProfileId} token={token} />,
<OffersSubmissionAnalysis
key={1}
analysis={analysis}
isError={getAnalysis.isError}
isLoading={getAnalysis.isLoading}
/>,
];
const breadcrumbSteps: Array<BreadcrumbStep> = [
{
label: 'Offers',
},
{
label: 'Background',
},
{
label: 'Save profile',
step: 0,
},
{
label: 'Analysis',
step: 1,
},
];
useEffect(() => {
scrollToTop();
}, [step]);
return (
<>
{getAnalysis.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>
)}
{!getAnalysis.isLoading && (
<div ref={pageRef} className="fixed h-full w-full overflow-y-scroll">
<div className="mb-20 flex justify-center">
<div className="my-5 block w-full max-w-screen-md rounded-lg bg-white py-10 px-10 shadow-lg">
<div className="mb-4 flex justify-end">
<Breadcrumbs
currentStep={step}
setStep={setStep}
steps={breadcrumbSteps}
/>
</div>
{steps[step]}
{step === 0 && (
<div className="flex justify-end">
<Button
disabled={false}
icon={ArrowRightIcon}
label="Next"
variant="secondary"
onClick={() => setStep(step + 1)}
/>
</div>
)}
{step === 1 && (
<div className="flex items-center justify-between">
<Button
addonPosition="start"
icon={ArrowLeftIcon}
label="Previous"
variant="secondary"
onClick={() => setStep(step - 1)}
/>
<Button
href={getProfilePath(
offerProfileId as string,
token as string,
)}
icon={EyeIcon}
label="View your profile"
variant="primary"
/>
</div>
)}
</div>
</div>
</div>
)}
</>
);
}

@ -16,7 +16,7 @@ function Test() {
}); });
const addToUserProfileMutation = trpc.useMutation( const addToUserProfileMutation = trpc.useMutation(
['offers.profile.addToUserProfile'], ['offers.user.profile.addToUserProfile'],
{ {
onError(err) { onError(err) {
alert(err); alert(err);
@ -85,7 +85,7 @@ function Test() {
addToUserProfileMutation.mutate({ addToUserProfileMutation.mutate({
profileId: 'cl9efyn9p004ww3u42mjgl1vn', profileId: 'cl9efyn9p004ww3u42mjgl1vn',
token: '24bafa6fef803f447d7f2e229b14cb8ee43f0c22dffbe41ee1c1e5e6e870f117', token: '24bafa6fef803f447d7f2e229b14cb8ee43f0c22dffbe41ee1c1e5e6e870f117',
userId: 'cl9ehvpng0000w3ec2mpx0bdd', // UserId: 'cl9ehvpng0000w3ec2mpx0bdd',
}); });
}; };
@ -205,6 +205,22 @@ function Test() {
}, },
); );
trpc.useQuery(
[
`offers.profile.isValidToken`,
{
profileId: 'cl9scdzuh0000tt727ipone1k',
token:
'aa628d0db3ad7a5f84895537d4cca38edd0a9b8b96d869cddeb967fccf068c08',
},
],
{
onError(err) {
setError(err.shape?.message || '');
},
},
);
const replies = trpc.useQuery( const replies = trpc.useQuery(
['offers.comments.getComments', { profileId }], ['offers.comments.getComments', { profileId }],
{ {

@ -1,17 +1,22 @@
import Head from 'next/head'; import Head from 'next/head';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useState } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { ArrowSmallLeftIcon } from '@heroicons/react/24/outline'; 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 AnswerCommentListItem from '~/components/questions/AnswerCommentListItem';
import FullAnswerCard from '~/components/questions/card/FullAnswerCard'; import FullAnswerCard from '~/components/questions/card/FullAnswerCard';
import FullScreenSpinner from '~/components/questions/FullScreenSpinner'; 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 { APP_TITLE } from '~/utils/questions/constants';
import { useFormRegister } from '~/utils/questions/useFormRegister'; import { useFormRegister } from '~/utils/questions/useFormRegister';
import { trpc } from '~/utils/trpc'; import { trpc } from '~/utils/trpc';
import { SortOrder, SortType } from '~/types/questions.d';
export type AnswerCommentData = { export type AnswerCommentData = {
commentContent: string; commentContent: string;
}; };
@ -19,6 +24,13 @@ export type AnswerCommentData = {
export default function QuestionPage() { export default function QuestionPage() {
const router = useRouter(); const router = useRouter();
const [commentSortOrder, setCommentSortOrder] = useState<SortOrder>(
SortOrder.DESC,
);
const [commentSortType, setCommentSortType] = useState<SortType>(
SortType.NEW,
);
const { const {
register: comRegister, register: comRegister,
reset: resetComment, reset: resetComment,
@ -36,18 +48,35 @@ export default function QuestionPage() {
{ answerId: answerId as string }, { answerId: answerId as string },
]); ]);
const { data: comments } = trpc.useQuery([ const answerCommentInfiniteQuery = trpc.useInfiniteQuery(
'questions.answers.comments.getAnswerComments', [
{ answerId: answerId as string }, 'questions.answers.comments.getAnswerComments',
]); {
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( const { mutate: addComment } = trpc.useMutation(
'questions.answers.comments.create', 'questions.answers.comments.user.create',
{ {
onSuccess: () => { onSuccess: () => {
utils.invalidateQueries([ utils.invalidateQueries([
'questions.answers.comments.getAnswerComments', '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} rows={2}
/> />
<div className="my-3 flex justify-between"> <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 <Button
disabled={!isCommentDirty || !isCommentValid} disabled={!isCommentDirty || !isCommentValid}
label="Post" label="Post"
@ -142,18 +145,35 @@ export default function QuestionPage() {
/> />
</div> </div>
</form> </form>
<div className="flex flex-col gap-2">
{(comments ?? []).map((comment) => ( <div className="flex items-center justify-between gap-2">
<AnswerCommentListItem <p className="text-lg">Comments</p>
key={comment.id} <div className="flex items-end gap-2">
answerCommentId={comment.id} <SortOptionsSelect
authorImageUrl={comment.userImage} sortOrderValue={commentSortOrder}
authorName={comment.user} sortTypeValue={commentSortType}
content={comment.content} onSortOrderChange={setCommentSortOrder}
createdAt={comment.createdAt} onSortTypeChange={setCommentSortType}
upvoteCount={comment.numVotes} />
/> </div>
))} </div>
{/* TODO: Allow to load more pages */}
{(answerCommentsData?.pages ?? []).flatMap(
({ processedQuestionAnswerCommentsData: comments }) =>
comments.map((comment) => (
<AnswerCommentListItem
key={comment.id}
answerCommentId={comment.id}
authorImageUrl={comment.userImage}
authorName={comment.user}
content={comment.content}
createdAt={comment.createdAt}
upvoteCount={comment.numVotes}
/>
)),
)}
<PaginationLoadMoreButton query={answerCommentInfiniteQuery} />
</div>
</div> </div>
</div> </div>
</div> </div>

@ -1,19 +1,25 @@
import Head from 'next/head'; import Head from 'next/head';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useMemo, useState } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { ArrowSmallLeftIcon } from '@heroicons/react/24/outline'; 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 AnswerCommentListItem from '~/components/questions/AnswerCommentListItem';
import FullQuestionCard from '~/components/questions/card/question/FullQuestionCard'; import FullQuestionCard from '~/components/questions/card/question/FullQuestionCard';
import QuestionAnswerCard from '~/components/questions/card/QuestionAnswerCard'; import QuestionAnswerCard from '~/components/questions/card/QuestionAnswerCard';
import FullScreenSpinner from '~/components/questions/FullScreenSpinner'; 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 { APP_TITLE } from '~/utils/questions/constants';
import createSlug from '~/utils/questions/createSlug'; import createSlug from '~/utils/questions/createSlug';
import relabelQuestionAggregates from '~/utils/questions/relabelQuestionAggregates';
import { useFormRegister } from '~/utils/questions/useFormRegister'; import { useFormRegister } from '~/utils/questions/useFormRegister';
import { trpc } from '~/utils/trpc'; import { trpc } from '~/utils/trpc';
import { SortOrder, SortType } from '~/types/questions.d';
export type AnswerQuestionData = { export type AnswerQuestionData = {
answerContent: string; answerContent: string;
}; };
@ -24,6 +30,19 @@ export type QuestionCommentData = {
export default function QuestionPage() { export default function QuestionPage() {
const router = useRouter(); 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 { const {
register: ansRegister, register: ansRegister,
handleSubmit, handleSubmit,
@ -52,15 +71,36 @@ export default function QuestionPage() {
{ questionId: questionId as string }, { questionId: questionId as string },
]); ]);
const relabeledAggregatedEncounters = useMemo(() => {
if (!aggregatedEncounters) {
return aggregatedEncounters;
}
return relabelQuestionAggregates(aggregatedEncounters);
}, [aggregatedEncounters]);
const utils = trpc.useContext(); const utils = trpc.useContext();
const { data: comments } = trpc.useQuery([ const commentInfiniteQuery = trpc.useInfiniteQuery(
'questions.questions.comments.getQuestionComments', [
{ questionId: questionId as string }, 'questions.questions.comments.getQuestionComments',
]); {
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( const { mutate: addComment } = trpc.useMutation(
'questions.questions.comments.create', 'questions.questions.comments.user.create',
{ {
onSuccess: () => { onSuccess: () => {
utils.invalidateQueries( utils.invalidateQueries(
@ -70,19 +110,35 @@ export default function QuestionPage() {
}, },
); );
const { data: answers } = trpc.useQuery([ const answerInfiniteQuery = trpc.useInfiniteQuery(
'questions.answers.getAnswers', [
{ questionId: questionId as string }, 'questions.answers.getAnswers',
]); {
limit: 5,
questionId: questionId as string,
sortOrder: answerSortOrder,
sortType: answerSortType,
},
],
{
getNextPageParam: (lastPage) => lastPage.nextCursor,
keepPreviousData: true,
},
);
const { mutate: addAnswer } = trpc.useMutation('questions.answers.create', { const { data: answerData } = answerInfiniteQuery;
onSuccess: () => {
utils.invalidateQueries('questions.answers.getAnswers'); const { mutate: addAnswer } = trpc.useMutation(
'questions.answers.user.create',
{
onSuccess: () => {
utils.invalidateQueries('questions.answers.getAnswers');
},
}, },
}); );
const { mutate: addEncounter } = trpc.useMutation( const { mutate: addEncounter } = trpc.useMutation(
'questions.questions.encounters.create', 'questions.questions.encounters.user.create',
{ {
onSuccess: () => { onSuccess: () => {
utils.invalidateQueries( utils.invalidateQueries(
@ -131,15 +187,15 @@ export default function QuestionPage() {
variant="secondary" variant="secondary"
/> />
</div> </div>
<div className="flex w-full justify-center overflow-y-auto py-4 px-5"> <div className="flex w-full justify-center overflow-y-auto py-4 px-5">
<div className="flex max-w-7xl flex-1 flex-col gap-2"> <div className="flex max-w-7xl flex-1 flex-col gap-2">
<FullQuestionCard <FullQuestionCard
{...question} {...question}
companies={aggregatedEncounters?.companyCounts ?? {}} companies={relabeledAggregatedEncounters?.companyCounts ?? {}}
locations={aggregatedEncounters?.locationCounts ?? {}} countries={relabeledAggregatedEncounters?.countryCounts ?? {}}
questionId={question.id} questionId={question.id}
receivedCount={undefined} receivedCount={undefined}
roles={aggregatedEncounters?.roleCounts ?? {}} roles={relabeledAggregatedEncounters?.roleCounts ?? {}}
timestamp={question.seenAt.toLocaleDateString(undefined, { timestamp={question.seenAt.toLocaleDateString(undefined, {
month: 'short', month: 'short',
year: 'numeric', year: 'numeric',
@ -147,78 +203,74 @@ export default function QuestionPage() {
upvoteCount={question.numVotes} upvoteCount={question.numVotes}
onReceivedSubmit={(data) => { onReceivedSubmit={(data) => {
addEncounter({ addEncounter({
cityId: data.cityId,
companyId: data.company, companyId: data.company,
location: data.location, countryId: data.countryId,
questionId: questionId as string, questionId: questionId as string,
role: data.role, role: data.role,
seenAt: data.seenAt, seenAt: data.seenAt,
stateId: data.stateId,
}); });
}} }}
/> />
<div className="mx-2"> <div className="mx-2">
<Collapsible label={`${(comments ?? []).length} comment(s)`}> <Collapsible label={`${question.numComments} comment(s)`}>
<form <div className="mt-4 px-4">
className="mb-2" <form
onSubmit={handleCommentSubmit(handleSubmitComment)}> className="mb-2"
<TextArea onSubmit={handleCommentSubmit(handleSubmitComment)}>
{...commentRegister('commentContent', { <TextArea
minLength: 1, {...commentRegister('commentContent', {
required: true, minLength: 1,
})} required: true,
label="Post a comment" })}
required={true} label="Post a comment"
resize="vertical" required={true}
rows={2} resize="vertical"
/> rows={2}
<div className="my-3 flex justify-between"> />
<div className="flex items-baseline gap-2"> <div className="my-3 flex justify-between">
<span aria-hidden={true} className="text-sm"> <Button
Sort by: disabled={!isCommentDirty || !isCommentValid}
</span> label="Post"
<Select type="submit"
display="inline" variant="primary"
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>
</form>
<Button {/* TODO: Add button to load more */}
disabled={!isCommentDirty || !isCommentValid} <div className="flex flex-col gap-2">
label="Post" <div className="flex items-center justify-between gap-2">
type="submit" <p className="text-lg">Comments</p>
variant="primary" <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}
authorImageUrl={comment.userImage}
authorName={comment.user}
content={comment.content}
createdAt={comment.createdAt}
upvoteCount={comment.numVotes}
/>
)),
)}
<PaginationLoadMoreButton query={commentInfiniteQuery} />
</div> </div>
</form> </div>
{(comments ?? []).map((comment) => (
<AnswerCommentListItem
key={comment.id}
answerCommentId={comment.id}
authorImageUrl={comment.userImage}
authorName={comment.user}
content={comment.content}
createdAt={comment.createdAt}
upvoteCount={comment.numVotes}
/>
))}
</Collapsible> </Collapsible>
</div> </div>
<HorizontalDivider />
<form onSubmit={handleSubmit(handleSubmitAnswer)}> <form onSubmit={handleSubmit(handleSubmitAnswer)}>
<TextArea <TextArea
{...answerRegister('answerContent', { {...answerRegister('answerContent', {
@ -231,34 +283,6 @@ export default function QuestionPage() {
rows={5} rows={5}
/> />
<div className="mt-3 mb-1 flex justify-between"> <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 <Button
disabled={!isDirty || !isValid} disabled={!isDirty || !isValid}
label="Contribute" label="Contribute"
@ -267,21 +291,37 @@ export default function QuestionPage() {
/> />
</div> </div>
</form> </form>
{(answers ?? []).map((answer) => ( <div className="flex items-center justify-between gap-2">
<QuestionAnswerCard <p className="text-xl">{question.numAnswers} answers</p>
key={answer.id} <div className="flex items-end gap-2">
answerId={answer.id} <SortOptionsSelect
authorImageUrl={answer.userImage} sortOrderValue={answerSortOrder}
authorName={answer.user} sortTypeValue={answerSortType}
commentCount={answer.numComments} onSortOrderChange={setAnswerSortOrder}
content={answer.content} onSortTypeChange={setAnswerSortType}
createdAt={answer.createdAt} />
href={`${router.asPath}/answer/${answer.id}/${createSlug( </div>
answer.content, </div>
)}`} {/* TODO: Add button to load more */}
upvoteCount={answer.numVotes} {(answerData?.pages ?? []).flatMap(
/> ({ processedAnswersData: answers }) =>
))} answers.map((answer) => (
<QuestionAnswerCard
key={answer.id}
answerId={answer.id}
authorImageUrl={answer.userImage}
authorName={answer.user}
commentCount={answer.numComments}
content={answer.content}
createdAt={answer.createdAt}
href={`${router.asPath}/answer/${answer.id}/${createSlug(
answer.content,
)}`}
upvoteCount={answer.numVotes}
/>
)),
)}
<PaginationLoadMoreButton query={answerInfiniteQuery} />
</div> </div>
</div> </div>
</div> </div>

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

@ -15,6 +15,7 @@ import DeleteListDialog from '~/components/questions/DeleteListDialog';
import { Button } from '~/../../../packages/ui/dist'; import { Button } from '~/../../../packages/ui/dist';
import { APP_TITLE } from '~/utils/questions/constants'; import { APP_TITLE } from '~/utils/questions/constants';
import createSlug from '~/utils/questions/createSlug'; import createSlug from '~/utils/questions/createSlug';
import relabelQuestionAggregates from '~/utils/questions/relabelQuestionAggregates';
import { trpc } from '~/utils/trpc'; import { trpc } from '~/utils/trpc';
export default function ListPage() { export default function ListPage() {
@ -172,37 +173,38 @@ export default function ListPage() {
{lists?.[selectedListIndex] && ( {lists?.[selectedListIndex] && (
<div className="flex flex-col gap-4 pb-4"> <div className="flex flex-col gap-4 pb-4">
{lists[selectedListIndex].questionEntries.map( {lists[selectedListIndex].questionEntries.map(
({ question, id: entryId }) => ( ({ question, id: entryId }) => {
<QuestionListCard const { companyCounts, countryCounts, roleCounts } =
key={question.id} relabelQuestionAggregates(
companies={ question.aggregatedQuestionEncounters,
question.aggregatedQuestionEncounters.companyCounts );
}
content={question.content} return (
href={`/questions/${question.id}/${createSlug( <QuestionListCard
question.content, key={question.id}
)}`} companies={companyCounts}
locations={ content={question.content}
question.aggregatedQuestionEncounters.locationCounts countries={countryCounts}
} href={`/questions/${question.id}/${createSlug(
questionId={question.id} question.content,
receivedCount={question.receivedCount} )}`}
roles={ questionId={question.id}
question.aggregatedQuestionEncounters.roleCounts receivedCount={question.receivedCount}
} roles={roleCounts}
timestamp={question.seenAt.toLocaleDateString( timestamp={question.seenAt.toLocaleDateString(
undefined, undefined,
{ {
month: 'short', month: 'short',
year: 'numeric', year: 'numeric',
}, },
)} )}
type={question.type} type={question.type}
onDelete={() => { onDelete={() => {
deleteQuestionEntry({ id: entryId }); deleteQuestionEntry({ id: entryId });
}} }}
/> />
), );
},
)} )}
{lists[selectedListIndex].questionEntries?.length === 0 && ( {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"> <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">

@ -3,12 +3,13 @@ import formatDistanceToNow from 'date-fns/formatDistanceToNow';
import Error from 'next/error'; import Error from 'next/error';
import Head from 'next/head'; import Head from 'next/head';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { signIn, useSession } from 'next-auth/react'; import { useSession } from 'next-auth/react';
import { useState } from 'react'; import { useState } from 'react';
import { import {
AcademicCapIcon, AcademicCapIcon,
BriefcaseIcon, BriefcaseIcon,
CalendarIcon, CalendarIcon,
CheckCircleIcon,
InformationCircleIcon, InformationCircleIcon,
MapPinIcon, MapPinIcon,
PencilSquareIcon, PencilSquareIcon,
@ -16,18 +17,22 @@ import {
} from '@heroicons/react/20/solid'; } from '@heroicons/react/20/solid';
import { Button, Spinner } from '@tih/ui'; import { Button, Spinner } from '@tih/ui';
import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics';
import ResumeCommentsForm from '~/components/resumes/comments/ResumeCommentsForm'; import ResumeCommentsForm from '~/components/resumes/comments/ResumeCommentsForm';
import ResumeCommentsList from '~/components/resumes/comments/ResumeCommentsList'; import ResumeCommentsList from '~/components/resumes/comments/ResumeCommentsList';
import ResumePdf from '~/components/resumes/ResumePdf'; import ResumePdf from '~/components/resumes/ResumePdf';
import ResumeExpandableText from '~/components/resumes/shared/ResumeExpandableText'; import ResumeExpandableText from '~/components/resumes/shared/ResumeExpandableText';
import type { import type {
ExperienceFilter,
FilterOption, FilterOption,
LocationFilter, LocationFilter,
RoleFilter,
} from '~/utils/resumes/resumeFilters'; } from '~/utils/resumes/resumeFilters';
import { import {
BROWSE_TABS_VALUES, BROWSE_TABS_VALUES,
EXPERIENCES, EXPERIENCES,
getFilterLabel,
INITIAL_FILTER_STATE, INITIAL_FILTER_STATE,
LOCATIONS, LOCATIONS,
ROLES, ROLES,
@ -35,10 +40,6 @@ import {
import { trpc } from '~/utils/trpc'; import { trpc } from '~/utils/trpc';
import SubmitResumeForm from './submit'; import SubmitResumeForm from './submit';
import type {
ExperienceFilter,
RoleFilter,
} from '../../utils/resumes/resumeFilters';
export default function ResumeReviewPage() { export default function ResumeReviewPage() {
const ErrorPage = ( const ErrorPage = (
@ -48,6 +49,8 @@ export default function ResumeReviewPage() {
const router = useRouter(); const router = useRouter();
const { resumeId } = router.query; const { resumeId } = router.query;
const utils = trpc.useContext(); const utils = trpc.useContext();
const { event: gaEvent } = useGoogleAnalytics();
// Safe to assert resumeId type as string because query is only sent if so // Safe to assert resumeId type as string because query is only sent if so
const detailsQuery = trpc.useQuery( const detailsQuery = trpc.useQuery(
['resumes.resume.findOne', { resumeId: resumeId as string }], ['resumes.resume.findOne', { resumeId: resumeId as string }],
@ -57,24 +60,48 @@ export default function ResumeReviewPage() {
); );
const starMutation = trpc.useMutation('resumes.resume.star', { const starMutation = trpc.useMutation('resumes.resume.star', {
onSuccess() { onSuccess() {
utils.invalidateQueries(['resumes.resume.findOne']); invalidateResumeQueries();
utils.invalidateQueries(['resumes.resume.findAll']); gaEvent({
utils.invalidateQueries(['resumes.resume.user.findUserStarred']); action: 'resumes.star_button_click',
utils.invalidateQueries(['resumes.resume.user.findUserCreated']); category: 'engagement',
label: 'Star Resume',
});
}, },
}); });
const unstarMutation = trpc.useMutation('resumes.resume.unstar', { const unstarMutation = trpc.useMutation('resumes.resume.unstar', {
onSuccess() { onSuccess() {
utils.invalidateQueries(['resumes.resume.findOne']); invalidateResumeQueries();
utils.invalidateQueries(['resumes.resume.findAll']); gaEvent({
utils.invalidateQueries(['resumes.resume.user.findUserStarred']); action: 'resumes.star_button_click',
utils.invalidateQueries(['resumes.resume.user.findUserCreated']); category: 'engagement',
label: 'Unstar Resume',
});
}, },
}); });
const resolveMutation = trpc.useMutation('resumes.resume.user.resolve', {
onSuccess() {
invalidateResumeQueries();
gaEvent({
action: 'resumes.resolve_button_click',
category: 'engagement',
label: 'Resolve Resume',
});
},
});
const invalidateResumeQueries = () => {
utils.invalidateQueries(['resumes.resume.findOne']);
utils.invalidateQueries(['resumes.resume.findAll']);
utils.invalidateQueries(['resumes.resume.user.findUserStarred']);
utils.invalidateQueries(['resumes.resume.user.findUserCreated']);
};
const userIsOwner = const userIsOwner =
session?.user?.id !== undefined && session?.user?.id !== undefined &&
session.user.id === detailsQuery.data?.userId; session.user.id === detailsQuery.data?.userId;
const isResumeResolved = detailsQuery.data?.isResolved;
const [isEditMode, setIsEditMode] = useState(false); const [isEditMode, setIsEditMode] = useState(false);
const [showCommentsForm, setShowCommentsForm] = useState(false); const [showCommentsForm, setShowCommentsForm] = useState(false);
@ -112,9 +139,14 @@ export default function ResumeReviewPage() {
) => filterOptions.find((option) => option.label === label)?.value; ) => filterOptions.find((option) => option.label === label)?.value;
router.push({ router.push({
pathname: '/resumes/browse', pathname: '/resumes',
query: { query: {
currentPage: JSON.stringify(1), currentPage: JSON.stringify(1),
isFiltersOpen: JSON.stringify({
experience: experienceLabel !== undefined,
location: locationLabel !== undefined,
role: roleLabel !== undefined,
}),
searchValue: JSON.stringify(''), searchValue: JSON.stringify(''),
shortcutSelected: JSON.stringify('all'), shortcutSelected: JSON.stringify('all'),
sortOrder: JSON.stringify('latest'), sortOrder: JSON.stringify('latest'),
@ -139,27 +171,31 @@ export default function ResumeReviewPage() {
setIsEditMode(true); setIsEditMode(true);
}; };
const onResolveButtonClick = () => {
resolveMutation.mutate({
id: resumeId as string,
val: !isResumeResolved,
});
};
const renderReviewButton = () => { const renderReviewButton = () => {
if (session === null) { if (session === null) {
return ( return (
<div className=" flex h-10 justify-center rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-[400] hover:cursor-pointer hover:bg-slate-50"> <Button
<a className="h-10 shadow-md"
href="/api/auth/signin" display="block"
onClick={(event) => { href="/api/auth/signin"
event.preventDefault(); label="Sign in to join discussion"
signIn(); variant="primary"
}}> />
Sign in to join discussion
</a>
</div>
); );
} }
return ( return (
<Button <Button
className="h-10 py-2 shadow-md" className="h-10 shadow-md"
display="block" display="block"
label="Add your review" label="Add your review"
variant="tertiary" variant="primary"
onClick={() => setShowCommentsForm(true)} onClick={() => setShowCommentsForm(true)}
/> />
); );
@ -178,10 +214,7 @@ export default function ResumeReviewPage() {
url: detailsQuery.data.url, url: detailsQuery.data.url,
}} }}
onClose={() => { onClose={() => {
utils.invalidateQueries(['resumes.resume.findOne']); invalidateResumeQueries();
utils.invalidateQueries(['resumes.resume.findAll']);
utils.invalidateQueries(['resumes.resume.user.findUserStarred']);
utils.invalidateQueries(['resumes.resume.user.findUserCreated']);
setIsEditMode(false); setIsEditMode(false);
}} }}
/> />
@ -202,50 +235,73 @@ export default function ResumeReviewPage() {
<Head> <Head>
<title>{detailsQuery.data.title}</title> <title>{detailsQuery.data.title}</title>
</Head> </Head>
<main className="h-[calc(100vh-2rem)] flex-1 space-y-2 overflow-y-auto py-4 px-8 xl:px-12 2xl:pr-16"> <main className="h-full flex-1 space-y-2 overflow-y-auto py-4 px-8 xl:px-12 2xl:pr-16">
<div className="flex justify-between"> <div className="flex justify-between">
<h1 className="pr-2 text-2xl font-semibold leading-7 text-slate-900 sm:truncate sm:text-3xl sm:tracking-tight"> <h1 className="w-[60%] pr-2 text-2xl font-semibold leading-7 text-slate-900">
{detailsQuery.data.title} {detailsQuery.data.title}
</h1> </h1>
<div className="flex gap-3 xl:pr-4"> <div className="flex gap-3 xl:pr-4">
{userIsOwner && ( {userIsOwner && (
<button <>
className="h-10 rounded-md border border-slate-300 bg-white py-1 px-2 text-center shadow-md hover:bg-slate-50" <Button
type="button" addonPosition="start"
onClick={onEditButtonClick}> className="h-10 shadow-md"
<PencilSquareIcon className="text-primary-600 h-6 w-6" /> icon={PencilSquareIcon}
</button> label="Edit"
variant="tertiary"
onClick={onEditButtonClick}
/>
<button
className="isolate inline-flex h-10 items-center space-x-4 rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-md hover:bg-slate-50 focus:ring-slate-600 disabled:hover:bg-white"
disabled={resolveMutation.isLoading}
type="button"
onClick={onResolveButtonClick}>
<div className="-ml-1 mr-2 h-5 w-5">
{resolveMutation.isLoading ? (
<Spinner className="mt-0.5" size="xs" />
) : (
<CheckCircleIcon
aria-hidden="true"
className={
isResumeResolved
? 'text-slate-500'
: 'text-success-600'
}
/>
)}
</div>
{isResumeResolved
? 'Reopen for review'
: 'Mark as reviewed'}
</button>
</>
)} )}
<button <button
className="isolate inline-flex h-10 items-center space-x-4 rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-md hover:bg-slate-50 disabled:hover:bg-white" className="isolate inline-flex h-10 items-center space-x-4 rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-md hover:bg-slate-50 focus:ring-slate-600 disabled:hover:bg-white"
disabled={starMutation.isLoading || unstarMutation.isLoading} disabled={starMutation.isLoading || unstarMutation.isLoading}
type="button" type="button"
onClick={onStarButtonClick}> onClick={onStarButtonClick}>
<span className="relative inline-flex"> <div className="-ml-1 mr-2 h-5 w-5">
<div className="-ml-1 mr-2 h-5 w-5"> {starMutation.isLoading ||
{starMutation.isLoading || unstarMutation.isLoading ||
unstarMutation.isLoading || detailsQuery.isLoading ? (
detailsQuery.isLoading ? ( <Spinner className="mt-0.5" size="xs" />
<Spinner className="mt-0.5" size="xs" /> ) : (
) : ( <StarIcon
<StarIcon aria-hidden="true"
aria-hidden="true" className={clsx(
className={clsx( detailsQuery.data?.stars.length
detailsQuery.data?.stars.length ? 'text-orange-400'
? 'text-orange-400' : 'text-slate-400',
: 'text-slate-400', )}
)} />
/> )}
)} </div>
</div> {detailsQuery.data?.stars.length ? 'Starred' : 'Star'}
Star
</span>
<span className="relative -ml-px inline-flex"> <span className="relative -ml-px inline-flex">
{detailsQuery.data?._count.stars} {detailsQuery.data?._count.stars}
</span> </span>
</button> </button>
<div className="hidden xl:block">{renderReviewButton()}</div> <div className="hidden xl:block">{renderReviewButton()}</div>
</div> </div>
</div> </div>
@ -263,7 +319,7 @@ export default function ResumeReviewPage() {
roleLabel: detailsQuery.data?.role, roleLabel: detailsQuery.data?.role,
}) })
}> }>
{detailsQuery.data.role} {getFilterLabel(ROLES, detailsQuery.data.role as RoleFilter)}
</button> </button>
</div> </div>
<div className="flex items-center pt-2 text-sm text-slate-600 xl:pt-1"> <div className="flex items-center pt-2 text-sm text-slate-600 xl:pt-1">
@ -279,7 +335,10 @@ export default function ResumeReviewPage() {
locationLabel: detailsQuery.data?.location, locationLabel: detailsQuery.data?.location,
}) })
}> }>
{detailsQuery.data.location} {getFilterLabel(
LOCATIONS,
detailsQuery.data.location as LocationFilter,
)}
</button> </button>
</div> </div>
<div className="flex items-center pt-2 text-sm text-slate-600 xl:pt-1"> <div className="flex items-center pt-2 text-sm text-slate-600 xl:pt-1">
@ -295,7 +354,10 @@ export default function ResumeReviewPage() {
experienceLabel: detailsQuery.data?.experience, experienceLabel: detailsQuery.data?.experience,
}) })
}> }>
{detailsQuery.data.experience} {getFilterLabel(
EXPERIENCES,
detailsQuery.data.experience as ExperienceFilter,
)}
</button> </button>
</div> </div>
<div className="flex items-center pt-2 text-sm text-slate-600 xl:pt-1"> <div className="flex items-center pt-2 text-sm text-slate-600 xl:pt-1">
@ -326,21 +388,17 @@ export default function ResumeReviewPage() {
<ResumePdf url={detailsQuery.data.url} /> <ResumePdf url={detailsQuery.data.url} />
</div> </div>
<div className="grow"> <div className="grow">
<div className="relative p-2 xl:hidden"> <div className="mb-6 space-y-4 xl:hidden">
<div {renderReviewButton()}
aria-hidden="true" <div className="flex items-center space-x-2">
className="absolute inset-0 flex items-center"> <hr className="flex-grow border-slate-300" />
<div className="w-full border-t border-slate-300" />
</div>
<div className="relative flex justify-center">
<span className="bg-slate-50 px-3 text-lg font-medium text-slate-900"> <span className="bg-slate-50 px-3 text-lg font-medium text-slate-900">
Reviews Reviews
</span> </span>
<hr className="flex-grow border-slate-300" />
</div> </div>
</div> </div>
<div className="mb-4 xl:hidden">{renderReviewButton()}</div>
{showCommentsForm ? ( {showCommentsForm ? (
<ResumeCommentsForm <ResumeCommentsForm
resumeId={resumeId as string} resumeId={resumeId as string}

@ -1,6 +1,6 @@
export default function AboutUsPage() { export default function AboutUsPage() {
return ( return (
<div className="my-10 flex w-full flex-col items-center overflow-y-auto"> <div className="flex w-full flex-col items-center overflow-y-auto py-10">
<div className="flex justify-center text-4xl font-bold"> <div className="flex justify-center text-4xl font-bold">
Resume Review Portal Resume Review Portal
</div> </div>
@ -52,6 +52,7 @@ export default function AboutUsPage() {
target="_blank"> target="_blank">
here here
</a> </a>
.
</div> </div>
</div> </div>
); );

@ -1,644 +0,0 @@
import Head from 'next/head';
import Router, { useRouter } from 'next/router';
import { useSession } from 'next-auth/react';
import { Fragment, useEffect, useMemo, useState } from 'react';
import { Dialog, Disclosure, Transition } from '@headlessui/react';
import { FunnelIcon, MinusIcon, PlusIcon } from '@heroicons/react/20/solid';
import {
MagnifyingGlassIcon,
NewspaperIcon,
XMarkIcon,
} from '@heroicons/react/24/outline';
import {
CheckboxInput,
CheckboxList,
DropdownMenu,
Pagination,
Spinner,
Tabs,
TextInput,
} from '@tih/ui';
import ResumeFilterPill from '~/components/resumes/browse/ResumeFilterPill';
import ResumeListItems from '~/components/resumes/browse/ResumeListItems';
import ResumeSignInButton from '~/components/resumes/shared/ResumeSignInButton';
import type { Filter, FilterId, Shortcut } from '~/utils/resumes/resumeFilters';
import {
BROWSE_TABS_VALUES,
EXPERIENCES,
INITIAL_FILTER_STATE,
isInitialFilterState,
LOCATIONS,
ROLES,
SHORTCUTS,
SORT_OPTIONS,
} from '~/utils/resumes/resumeFilters';
import useDebounceValue from '~/utils/resumes/useDebounceValue';
import useSearchParams from '~/utils/resumes/useSearchParams';
import { trpc } from '~/utils/trpc';
import type { FilterState, SortOrder } from '../../utils/resumes/resumeFilters';
const STALE_TIME = 5 * 60 * 1000;
const DEBOUNCE_DELAY = 800;
const PAGE_LIMIT = 10;
const filters: Array<Filter> = [
{
id: 'role',
label: 'Role',
options: ROLES,
},
{
id: 'experience',
label: 'Experience',
options: EXPERIENCES,
},
{
id: 'location',
label: 'Location',
options: LOCATIONS,
},
];
const getLoggedOutText = (tabsValue: string) => {
switch (tabsValue) {
case BROWSE_TABS_VALUES.STARRED:
return 'to view starred resumes!';
case BROWSE_TABS_VALUES.MY:
return 'to view your submitted resumes!';
default:
return '';
}
};
const getEmptyDataText = (
tabsValue: string,
searchValue: string,
userFilters: FilterState,
) => {
if (searchValue.length > 0) {
return 'Try tweaking your search text to see more resumes.';
}
if (!isInitialFilterState(userFilters)) {
return 'Try tweaking your filters to see more resumes.';
}
switch (tabsValue) {
case BROWSE_TABS_VALUES.ALL:
return 'Looks like SWEs are feeling lucky!';
case BROWSE_TABS_VALUES.STARRED:
return 'You have not starred any resumes. Star one to see it here!';
case BROWSE_TABS_VALUES.MY:
return 'Upload a resume to see it here!';
default:
return '';
}
};
export default function ResumeHomePage() {
const { data: sessionData } = useSession();
const router = useRouter();
const [tabsValue, setTabsValue, isTabsValueInit] = useSearchParams(
'tabsValue',
BROWSE_TABS_VALUES.ALL,
);
const [sortOrder, setSortOrder, isSortOrderInit] = useSearchParams<SortOrder>(
'sortOrder',
'latest',
);
const [searchValue, setSearchValue, isSearchValueInit] = useSearchParams(
'searchValue',
'',
);
const [shortcutSelected, setShortcutSelected, isShortcutInit] =
useSearchParams('shortcutSelected', 'All');
const [currentPage, setCurrentPage, isCurrentPageInit] = useSearchParams(
'currentPage',
1,
);
const [userFilters, setUserFilters, isUserFiltersInit] = useSearchParams(
'userFilters',
INITIAL_FILTER_STATE,
);
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false);
const skip = (currentPage - 1) * PAGE_LIMIT;
const isSearchOptionsInit = useMemo(() => {
return (
isTabsValueInit &&
isSortOrderInit &&
isSearchValueInit &&
isShortcutInit &&
isCurrentPageInit &&
isUserFiltersInit
);
}, [
isTabsValueInit,
isSortOrderInit,
isSearchValueInit,
isShortcutInit,
isCurrentPageInit,
isUserFiltersInit,
]);
useEffect(() => {
setCurrentPage(1);
}, [userFilters, sortOrder, setCurrentPage, searchValue]);
useEffect(() => {
// Router.replace used instead of router.replace to avoid
// the page reloading itself since the router.replace
// callback changes on every page load
if (!isSearchOptionsInit) {
return;
}
Router.replace({
pathname: router.pathname,
query: {
currentPage: JSON.stringify(currentPage),
searchValue: JSON.stringify(searchValue),
shortcutSelected: JSON.stringify(shortcutSelected),
sortOrder: JSON.stringify(sortOrder),
tabsValue: JSON.stringify(tabsValue),
userFilters: JSON.stringify(userFilters),
},
});
}, [
tabsValue,
sortOrder,
searchValue,
userFilters,
shortcutSelected,
currentPage,
router.pathname,
isSearchOptionsInit,
]);
const allResumesQuery = trpc.useQuery(
[
'resumes.resume.findAll',
{
experienceFilters: userFilters.experience,
locationFilters: userFilters.location,
numComments: userFilters.numComments,
roleFilters: userFilters.role,
searchValue: useDebounceValue(searchValue, DEBOUNCE_DELAY),
skip,
sortOrder,
take: PAGE_LIMIT,
},
],
{
enabled: tabsValue === BROWSE_TABS_VALUES.ALL,
staleTime: STALE_TIME,
},
);
const starredResumesQuery = trpc.useQuery(
[
'resumes.resume.user.findUserStarred',
{
experienceFilters: userFilters.experience,
locationFilters: userFilters.location,
numComments: userFilters.numComments,
roleFilters: userFilters.role,
searchValue: useDebounceValue(searchValue, DEBOUNCE_DELAY),
skip,
sortOrder,
take: PAGE_LIMIT,
},
],
{
enabled: tabsValue === BROWSE_TABS_VALUES.STARRED,
retry: false,
staleTime: STALE_TIME,
},
);
const myResumesQuery = trpc.useQuery(
[
'resumes.resume.user.findUserCreated',
{
experienceFilters: userFilters.experience,
locationFilters: userFilters.location,
numComments: userFilters.numComments,
roleFilters: userFilters.role,
searchValue: useDebounceValue(searchValue, DEBOUNCE_DELAY),
skip,
sortOrder,
take: PAGE_LIMIT,
},
],
{
enabled: tabsValue === BROWSE_TABS_VALUES.MY,
retry: false,
staleTime: STALE_TIME,
},
);
const onSubmitResume = () => {
if (sessionData === null) {
router.push('/api/auth/signin');
} else {
router.push('/resumes/submit');
}
};
const onFilterCheckboxChange = (
isChecked: boolean,
filterSection: FilterId,
filterValue: string,
) => {
if (isChecked) {
setUserFilters({
...userFilters,
[filterSection]: [...userFilters[filterSection], filterValue],
});
} else {
setUserFilters({
...userFilters,
[filterSection]: userFilters[filterSection].filter(
(value) => value !== filterValue,
),
});
}
};
const onShortcutChange = ({
sortOrder: shortcutSortOrder,
filters: shortcutFilters,
name: shortcutName,
}: Shortcut) => {
setShortcutSelected(shortcutName);
setSortOrder(shortcutSortOrder);
setUserFilters(shortcutFilters);
};
const onTabChange = (tab: string) => {
setTabsValue(tab);
setCurrentPage(1);
};
const getTabQueryData = () => {
switch (tabsValue) {
case BROWSE_TABS_VALUES.ALL:
return allResumesQuery.data;
case BROWSE_TABS_VALUES.STARRED:
return starredResumesQuery.data;
case BROWSE_TABS_VALUES.MY:
return myResumesQuery.data;
default:
return null;
}
};
const getTabResumes = () => {
return getTabQueryData()?.mappedResumeData ?? [];
};
const getTabTotalPages = () => {
const numRecords = getTabQueryData()?.totalRecords ?? 0;
return numRecords % PAGE_LIMIT === 0
? numRecords / PAGE_LIMIT
: Math.floor(numRecords / PAGE_LIMIT) + 1;
};
const isFetchingResumes =
allResumesQuery.isFetching ||
starredResumesQuery.isFetching ||
myResumesQuery.isFetching;
return (
<>
<Head>
<title>Resume Review Portal</title>
</Head>
{/* Mobile Filters */}
<div>
<Transition.Root as={Fragment} show={mobileFiltersOpen}>
<Dialog
as="div"
className="relative z-40 lg:hidden"
onClose={setMobileFiltersOpen}>
<Transition.Child
as={Fragment}
enter="transition-opacity ease-linear duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-opacity ease-linear duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0">
<div className="fixed inset-0 bg-black bg-opacity-25" />
</Transition.Child>
<div className="fixed inset-0 z-40 flex">
<Transition.Child
as={Fragment}
enter="transition ease-in-out duration-300 transform"
enterFrom="translate-x-full"
enterTo="translate-x-0"
leave="transition ease-in-out duration-300 transform"
leaveFrom="translate-x-0"
leaveTo="translate-x-full">
<Dialog.Panel className="relative ml-auto flex h-full w-full max-w-xs flex-col overflow-y-scroll bg-white py-4 pb-12 shadow-xl">
<div className="flex items-center justify-between px-4">
<h2 className="text-lg font-medium text-slate-900">
Shortcuts
</h2>
<button
className="-mr-2 flex h-10 w-10 items-center justify-center rounded-md bg-white p-2 text-slate-400"
type="button"
onClick={() => setMobileFiltersOpen(false)}>
<span className="sr-only">Close menu</span>
<XMarkIcon aria-hidden="true" className="h-6 w-6" />
</button>
</div>
<form className="mt-4 border-t border-slate-200">
<ul
className="flex flex-wrap justify-start gap-4 px-4 py-3 font-medium text-slate-900"
role="list">
{SHORTCUTS.map((shortcut) => (
<li key={shortcut.name}>
<ResumeFilterPill
isSelected={shortcutSelected === shortcut.name}
title={shortcut.name}
onClick={() => onShortcutChange(shortcut)}
/>
</li>
))}
</ul>
{filters.map((filter) => (
<Disclosure
key={filter.id}
as="div"
className="border-t border-slate-200 px-4 py-6">
{({ 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">
<span className="font-medium text-slate-900">
{filter.label}
</span>
<span className="ml-6 flex items-center">
{open ? (
<MinusIcon
aria-hidden="true"
className="h-5 w-5"
/>
) : (
<PlusIcon
aria-hidden="true"
className="h-5 w-5"
/>
)}
</span>
</Disclosure.Button>
</h3>
<Disclosure.Panel className="pt-6">
<div className="space-y-6">
{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">
<CheckboxInput
label={option.label}
value={userFilters[filter.id].includes(
option.value,
)}
onChange={(isChecked) =>
onFilterCheckboxChange(
isChecked,
filter.id,
option.value,
)
}
/>
</div>
))}
</div>
</Disclosure.Panel>
</>
)}
</Disclosure>
))}
</form>
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition.Root>
</div>
<main className="h-[calc(100vh-4rem)] flex-auto px-8 pb-4">
<div className="flex justify-start">
<div className="fixed top-0 bottom-0 mt-24 hidden w-64 overflow-auto lg:block">
<h3 className="text-md font-medium tracking-tight text-gray-900">
Shortcuts
</h3>
<div className="w-100 pt-4 sm:pr-0 md:pr-4">
<form>
<ul
className="flex w-11/12 flex-wrap justify-start gap-2 pb-6 text-sm font-medium text-slate-900"
role="list">
{SHORTCUTS.map((shortcut) => (
<li key={shortcut.name}>
<ResumeFilterPill
isSelected={shortcutSelected === shortcut.name}
title={shortcut.name}
onClick={() => onShortcutChange(shortcut)}
/>
</li>
))}
</ul>
<h3 className="text-md font-medium tracking-tight text-slate-900">
Explore these filters
</h3>
{filters.map((filter) => (
<Disclosure
key={filter.id}
as="div"
className="border-b border-slate-200 py-6">
{({ 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">
<span className="font-medium text-slate-900">
{filter.label}
</span>
<span className="ml-6 flex items-center">
{open ? (
<MinusIcon
aria-hidden="true"
className="h-5 w-5"
/>
) : (
<PlusIcon
aria-hidden="true"
className="h-5 w-5"
/>
)}
</span>
</Disclosure.Button>
</h3>
<Disclosure.Panel className="pt-4">
<CheckboxList
description=""
isLabelHidden={true}
label=""
orientation="vertical">
{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">
<CheckboxInput
label={option.label}
value={userFilters[filter.id].includes(
option.value,
)}
onChange={(isChecked) =>
onFilterCheckboxChange(
isChecked,
filter.id,
option.value,
)
}
/>
</div>
))}
</CheckboxList>
</Disclosure.Panel>
</>
)}
</Disclosure>
))}
</form>
</div>
</div>
<div className="relative lg:left-64 lg:w-[calc(100%-16rem)]">
<div className="lg:border-grey-200 sticky top-0 z-0 flex flex-wrap items-center justify-between pt-6 pb-2 lg:border-b">
<div className="border-grey-200 mb-4 flex w-full justify-between border-b pb-2 lg:mb-0 lg:w-auto lg:border-none lg:pb-0">
<div>
<Tabs
label="Resume Browse Tabs"
tabs={[
{
label: 'All Resumes',
value: BROWSE_TABS_VALUES.ALL,
},
{
label: 'Starred Resumes',
value: BROWSE_TABS_VALUES.STARRED,
},
{
label: 'My Resumes',
value: BROWSE_TABS_VALUES.MY,
},
]}
value={tabsValue}
onChange={onTabChange}
/>
</div>
<div>
<button
className="bg-primary-500 ml-4 rounded-md py-2 px-3 text-sm font-medium text-white lg:hidden"
type="button"
onClick={onSubmitResume}>
Submit Resume
</button>
</div>
</div>
<div className="flex flex-wrap items-center justify-start gap-8">
<div className="w-64">
<TextInput
isLabelHidden={true}
label="search"
placeholder="Search Resumes"
startAddOn={MagnifyingGlassIcon}
startAddOnType="icon"
type="text"
value={searchValue}
onChange={setSearchValue}
/>
</div>
<div>
<DropdownMenu
align="end"
label={
SORT_OPTIONS.find(({ value }) => value === sortOrder)
?.label
}>
{SORT_OPTIONS.map(({ label, value }) => (
<DropdownMenu.Item
key={value}
isSelected={sortOrder === value}
label={label}
onClick={() => setSortOrder(value)}></DropdownMenu.Item>
))}
</DropdownMenu>
</div>
<button
className="-m-2 text-slate-400 hover:text-slate-500 lg:hidden"
type="button"
onClick={() => setMobileFiltersOpen(true)}>
<span className="sr-only">Filters</span>
<FunnelIcon aria-hidden="true" className="h-6 w-6" />
</button>
<div>
<button
className="bg-primary-500 hidden w-36 rounded-md py-2 px-3 text-sm font-medium text-white lg:block"
type="button"
onClick={onSubmitResume}>
Submit Resume
</button>
</div>
</div>
</div>
{isFetchingResumes ? (
<div className="w-full pt-4">
{' '}
<Spinner display="block" size="lg" />{' '}
</div>
) : sessionData === null && tabsValue !== BROWSE_TABS_VALUES.ALL ? (
<ResumeSignInButton
className="mt-8"
text={getLoggedOutText(tabsValue)}
/>
) : getTabResumes().length === 0 ? (
<div className="mt-24 flex flex-wrap justify-center">
<NewspaperIcon
className="mb-12 basis-full"
height={196}
width={196}
/>
{getEmptyDataText(tabsValue, searchValue, userFilters)}
</div>
) : (
<div className="h-[calc(100vh-9rem)] pb-10 lg:h-[calc(100vh-6rem)]">
<div className="h-[85%] overflow-y-auto">
<div>
<ResumeListItems resumes={getTabResumes()} />
</div>
</div>
<div className="flex h-[15%] items-center justify-center">
{getTabTotalPages() > 1 && (
<div>
<Pagination
current={currentPage}
end={getTabTotalPages()}
label="pagination"
start={1}
onSelect={(page) => setCurrentPage(page)}
/>
</div>
)}
</div>
</div>
)}
</div>
</div>
</main>
</>
);
}

@ -0,0 +1,21 @@
import Head from 'next/head';
import { CallToAction } from '~/components/resumes/landing/CallToAction';
import { Hero } from '~/components/resumes/landing/Hero';
import { PrimaryFeatures } from '~/components/resumes/landing/PrimaryFeatures';
export default function Home() {
return (
<>
<Head>
<title>Resume Review Portal</title>
</Head>
<main className="h-full w-full overflow-y-auto">
<Hero />
<PrimaryFeatures />
<CallToAction />
</main>
</>
);
}

@ -1,20 +1,708 @@
import Head from 'next/head'; import Head from 'next/head';
import Router, { useRouter } from 'next/router';
import { useSession } from 'next-auth/react';
import { Fragment, useEffect, useMemo, useState } from 'react';
import { Dialog, Disclosure, Transition } from '@headlessui/react';
import { FunnelIcon, MinusIcon, PlusIcon } from '@heroicons/react/20/solid';
import {
MagnifyingGlassIcon,
NewspaperIcon,
XMarkIcon,
} from '@heroicons/react/24/outline';
import {
Button,
CheckboxInput,
CheckboxList,
DropdownMenu,
Pagination,
Spinner,
Tabs,
TextInput,
} from '@tih/ui';
import { CallToAction } from '~/components/resumes/landing/CallToAction'; import ResumeFilterPill from '~/components/resumes/browse/ResumeFilterPill';
import { Hero } from '~/components/resumes/landing/Hero'; import ResumeListItems from '~/components/resumes/browse/ResumeListItems';
import { PrimaryFeatures } from '~/components/resumes/landing/PrimaryFeatures'; import ResumeSignInButton from '~/components/resumes/shared/ResumeSignInButton';
import type {
Filter,
FilterId,
FilterLabel,
Shortcut,
} from '~/utils/resumes/resumeFilters';
import {
BROWSE_TABS_VALUES,
EXPERIENCES,
getFilterLabel,
INITIAL_FILTER_STATE,
isInitialFilterState,
LOCATIONS,
ROLES,
SHORTCUTS,
SORT_OPTIONS,
} from '~/utils/resumes/resumeFilters';
import useDebounceValue from '~/utils/resumes/useDebounceValue';
import useSearchParams from '~/utils/resumes/useSearchParams';
import { trpc } from '~/utils/trpc';
import type { FilterState, SortOrder } from '../../utils/resumes/resumeFilters';
const STALE_TIME = 5 * 60 * 1000;
const DEBOUNCE_DELAY = 800;
const PAGE_LIMIT = 10;
const filters: Array<Filter> = [
{
id: 'role',
label: 'Role',
options: ROLES,
},
{
id: 'experience',
label: 'Experience',
options: EXPERIENCES,
},
{
id: 'location',
label: 'Location',
options: LOCATIONS,
},
];
const getLoggedOutText = (tabsValue: string) => {
switch (tabsValue) {
case BROWSE_TABS_VALUES.STARRED:
return 'to view starred resumes!';
case BROWSE_TABS_VALUES.MY:
return 'to view your submitted resumes!';
default:
return '';
}
};
const getEmptyDataText = (
tabsValue: string,
searchValue: string,
userFilters: FilterState,
) => {
if (searchValue.length > 0) {
return 'Try tweaking your search text to see more resumes.';
}
if (!isInitialFilterState(userFilters)) {
return 'Try tweaking your filters to see more resumes.';
}
switch (tabsValue) {
case BROWSE_TABS_VALUES.ALL:
return "There's nothing to see here...";
case BROWSE_TABS_VALUES.STARRED:
return 'You have not starred any resumes. Star one to see it here!';
case BROWSE_TABS_VALUES.MY:
return 'Upload a resume to see it here!';
default:
return '';
}
};
export default function ResumeHomePage() {
const { data: sessionData } = useSession();
const router = useRouter();
const [tabsValue, setTabsValue, isTabsValueInit] = useSearchParams(
'tabsValue',
BROWSE_TABS_VALUES.ALL,
);
const [sortOrder, setSortOrder, isSortOrderInit] = useSearchParams<SortOrder>(
'sortOrder',
'latest',
);
const [searchValue, setSearchValue, isSearchValueInit] = useSearchParams(
'searchValue',
'',
);
const [shortcutSelected, setShortcutSelected, isShortcutInit] =
useSearchParams('shortcutSelected', 'Unreviewed');
const [currentPage, setCurrentPage, isCurrentPageInit] = useSearchParams(
'currentPage',
1,
);
const [userFilters, setUserFilters, isUserFiltersInit] = useSearchParams(
'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;
const isSearchOptionsInit = useMemo(() => {
return (
isTabsValueInit &&
isSortOrderInit &&
isSearchValueInit &&
isShortcutInit &&
isCurrentPageInit &&
isUserFiltersInit &&
isFiltersOpenInit
);
}, [
isTabsValueInit,
isSortOrderInit,
isSearchValueInit,
isShortcutInit,
isCurrentPageInit,
isUserFiltersInit,
isFiltersOpenInit,
]);
useEffect(() => {
setCurrentPage(1);
}, [userFilters, sortOrder, setCurrentPage, searchValue]);
useEffect(() => {
// Router.replace used instead of router.replace to avoid
// the page reloading itself since the router.replace
// callback changes on every page load
if (!isSearchOptionsInit) {
return;
}
Router.replace({
pathname: router.pathname,
query: {
currentPage: JSON.stringify(currentPage),
isFiltersOpen: JSON.stringify(isFiltersOpen),
searchValue: JSON.stringify(searchValue),
shortcutSelected: JSON.stringify(shortcutSelected),
sortOrder: JSON.stringify(sortOrder),
tabsValue: JSON.stringify(tabsValue),
userFilters: JSON.stringify(userFilters),
},
});
}, [
tabsValue,
sortOrder,
searchValue,
userFilters,
shortcutSelected,
currentPage,
router.pathname,
isSearchOptionsInit,
isFiltersOpen,
]);
const allResumesQuery = trpc.useQuery(
[
'resumes.resume.findAll',
{
experienceFilters: userFilters.experience,
isUnreviewed: userFilters.isUnreviewed,
locationFilters: userFilters.location,
roleFilters: userFilters.role,
searchValue: useDebounceValue(searchValue, DEBOUNCE_DELAY),
skip,
sortOrder,
take: PAGE_LIMIT,
},
],
{
enabled: tabsValue === BROWSE_TABS_VALUES.ALL,
staleTime: STALE_TIME,
},
);
const starredResumesQuery = trpc.useQuery(
[
'resumes.resume.user.findUserStarred',
{
experienceFilters: userFilters.experience,
isUnreviewed: userFilters.isUnreviewed,
locationFilters: userFilters.location,
roleFilters: userFilters.role,
searchValue: useDebounceValue(searchValue, DEBOUNCE_DELAY),
skip,
sortOrder,
take: PAGE_LIMIT,
},
],
{
enabled: tabsValue === BROWSE_TABS_VALUES.STARRED,
retry: false,
staleTime: STALE_TIME,
},
);
const myResumesQuery = trpc.useQuery(
[
'resumes.resume.user.findUserCreated',
{
experienceFilters: userFilters.experience,
isUnreviewed: userFilters.isUnreviewed,
locationFilters: userFilters.location,
roleFilters: userFilters.role,
searchValue: useDebounceValue(searchValue, DEBOUNCE_DELAY),
skip,
sortOrder,
take: PAGE_LIMIT,
},
],
{
enabled: tabsValue === BROWSE_TABS_VALUES.MY,
retry: false,
staleTime: STALE_TIME,
},
);
const onSubmitResume = () => {
if (sessionData === null) {
router.push('/api/auth/signin');
} else {
router.push('/resumes/submit');
}
};
const onFilterCheckboxChange = (
isChecked: boolean,
filterSection: FilterId,
filterValue: string,
) => {
if (isChecked) {
setUserFilters({
...userFilters,
[filterSection]: [...userFilters[filterSection], filterValue],
});
} else {
setUserFilters({
...userFilters,
[filterSection]: userFilters[filterSection].filter(
(value) => value !== filterValue,
),
});
}
};
const onClearFilterClick = (filterSection: FilterId) => {
setUserFilters({
...userFilters,
[filterSection]: [],
});
};
const onShortcutChange = ({
sortOrder: shortcutSortOrder,
filters: shortcutFilters,
name: shortcutName,
}: Shortcut) => {
setShortcutSelected(shortcutName);
setSortOrder(shortcutSortOrder);
setUserFilters(shortcutFilters);
};
const onTabChange = (tab: string) => {
setTabsValue(tab);
setCurrentPage(1);
};
const getTabQueryData = () => {
switch (tabsValue) {
case BROWSE_TABS_VALUES.ALL:
return allResumesQuery.data;
case BROWSE_TABS_VALUES.STARRED:
return starredResumesQuery.data;
case BROWSE_TABS_VALUES.MY:
return myResumesQuery.data;
default:
return null;
}
};
const getTabResumes = () => {
return getTabQueryData()?.mappedResumeData ?? [];
};
const getTabTotalPages = () => {
const numRecords = getTabQueryData()?.totalRecords ?? 0;
return numRecords % PAGE_LIMIT === 0
? numRecords / PAGE_LIMIT
: Math.floor(numRecords / PAGE_LIMIT) + 1;
};
const isFetchingResumes =
allResumesQuery.isFetching ||
starredResumesQuery.isFetching ||
myResumesQuery.isFetching;
const getTabFilterCounts = () => {
return getTabQueryData()?.filterCounts;
};
const getFilterCount = (filter: FilterLabel, value: string) => {
const filterCountsData = getTabFilterCounts();
if (!filterCountsData) {
return 0;
}
return filterCountsData[filter][value];
};
export default function Home() {
return ( return (
<> <>
<Head> <Head>
<title>Resume Review</title> <title>Resume Review Portal</title>
</Head> </Head>
<main className="h-[calc(100vh-2rem)] w-full overflow-y-auto"> {/* Mobile Filters */}
<Hero /> <div>
<PrimaryFeatures /> <Transition.Root as={Fragment} show={mobileFiltersOpen}>
<CallToAction /> <Dialog
as="div"
className="relative z-40 lg:hidden"
onClose={setMobileFiltersOpen}>
<Transition.Child
as={Fragment}
enter="transition-opacity ease-linear duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-opacity ease-linear duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0">
<div className="fixed inset-0 bg-black bg-opacity-25" />
</Transition.Child>
<div className="fixed inset-0 z-40 flex">
<Transition.Child
as={Fragment}
enter="transition ease-in-out duration-300 transform"
enterFrom="translate-x-full"
enterTo="translate-x-0"
leave="transition ease-in-out duration-300 transform"
leaveFrom="translate-x-0"
leaveTo="translate-x-full">
<Dialog.Panel className="relative ml-auto flex h-full w-full max-w-xs flex-col overflow-y-scroll bg-white py-4 pb-12 shadow-xl">
<div className="flex items-center justify-between px-4">
<h2 className="text-lg font-medium text-slate-900">
Quick access
</h2>
<button
className="-mr-2 flex h-10 w-10 items-center justify-center rounded-md bg-white p-2 text-slate-400"
type="button"
onClick={() => setMobileFiltersOpen(false)}>
<span className="sr-only">Close menu</span>
<XMarkIcon aria-hidden="true" className="h-6 w-6" />
</button>
</div>
<form className="mt-4 border-t border-slate-200">
<ul
className="flex w-11/12 flex-wrap justify-start gap-2 px-4 py-4 font-medium text-slate-900"
role="list">
{SHORTCUTS.map((shortcut) => (
<li key={shortcut.name}>
<ResumeFilterPill
isSelected={shortcutSelected === shortcut.name}
title={shortcut.name}
onClick={() => onShortcutChange(shortcut)}
/>
</li>
))}
</ul>
{filters.map((filter) => (
<Disclosure
key={filter.id}
as="div"
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"
onClick={() =>
setIsFiltersOpen({
...isFiltersOpen,
[filter.id]: !isFiltersOpen[filter.id],
})
}>
<span className="font-medium text-slate-900">
{filter.label}
</span>
<span className="ml-6 flex items-center">
{open ? (
<MinusIcon
aria-hidden="true"
className="h-5 w-5"
/>
) : (
<PlusIcon
aria-hidden="true"
className="h-5 w-5"
/>
)}
</span>
</Disclosure.Button>
</h3>
<Disclosure.Panel className="space-y-4 pt-6">
<div className="space-y-3">
{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 flex items-center px-1 text-sm [&>div>div:nth-child(2)>label]:font-normal">
<CheckboxInput
label={option.label}
value={userFilters[filter.id].includes(
option.value,
)}
onChange={(isChecked) =>
onFilterCheckboxChange(
isChecked,
filter.id,
option.value,
)
}
/>
<span className="ml-1 text-slate-500">
(
{getFilterCount(
filter.label,
option.label,
)}
)
</span>
</div>
))}
</div>
<p
className="inline-block cursor-pointer text-sm text-slate-500 underline hover:text-slate-700"
onClick={() => onClearFilterClick(filter.id)}>
Clear
</p>
</Disclosure.Panel>
</>
)}
</Disclosure>
))}
</form>
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition.Root>
</div>
<main className="h-full flex-auto px-8 pb-4">
<div className="flex justify-start">
<div className="fixed top-0 bottom-0 mt-24 hidden w-64 overflow-auto lg:block">
{/* Quick Access Section */}
<h3 className="text-md font-medium tracking-tight text-gray-900">
Quick access
</h3>
<div className="w-100 pt-4 sm:pr-0 md:pr-4">
<form>
<ul
className="flex w-11/12 flex-wrap justify-start gap-2 pb-6 text-sm font-medium text-slate-900"
role="list">
{SHORTCUTS.map((shortcut) => (
<li key={shortcut.name}>
<ResumeFilterPill
isSelected={shortcutSelected === shortcut.name}
title={shortcut.name}
onClick={() => onShortcutChange(shortcut)}
/>
</li>
))}
</ul>
{/* Filter Section */}
<h3 className="text-md font-medium tracking-tight text-slate-900">
Explore these filters
</h3>
{isFiltersOpenInit &&
filters.map((filter) => (
<Disclosure
key={filter.id}
as="div"
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"
onClick={() =>
setIsFiltersOpen({
...isFiltersOpen,
[filter.id]: !isFiltersOpen[filter.id],
})
}>
<span className="font-medium text-slate-900">
{filter.label}
</span>
<span className="ml-6 flex items-center">
{open ? (
<MinusIcon
aria-hidden="true"
className="h-5 w-5"
/>
) : (
<PlusIcon
aria-hidden="true"
className="h-5 w-5"
/>
)}
</span>
</Disclosure.Button>
</h3>
<Disclosure.Panel className="space-y-4 pt-4">
<CheckboxList
description=""
isLabelHidden={true}
label=""
orientation="vertical">
{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 flex items-center px-1 text-sm [&>div>div:nth-child(2)>label]:font-normal">
<CheckboxInput
label={option.label}
value={userFilters[filter.id].includes(
option.value,
)}
onChange={(isChecked) =>
onFilterCheckboxChange(
isChecked,
filter.id,
option.value,
)
}
/>
<span className="ml-1 text-slate-500">
(
{getFilterCount(filter.label, option.label)}
)
</span>
</div>
))}
</CheckboxList>
<p
className="inline-block cursor-pointer text-sm text-slate-500 underline hover:text-slate-700"
onClick={() => onClearFilterClick(filter.id)}>
Clear
</p>
</Disclosure.Panel>
</>
)}
</Disclosure>
))}
</form>
</div>
</div>
<div className="relative lg:left-64 lg:w-[calc(100%-16rem)]">
<div className="lg:border-grey-200 sticky top-0 z-10 flex flex-wrap items-center justify-between pt-6 pb-2 lg:border-b">
<div className="border-grey-200 mb-4 flex w-full justify-between border-b pb-2 lg:mb-0 lg:w-auto lg:border-none xl:pb-0">
<div>
<Tabs
label="Resume Browse Tabs"
tabs={[
{
label: 'All Resumes',
value: BROWSE_TABS_VALUES.ALL,
},
{
label: 'Starred Resumes',
value: BROWSE_TABS_VALUES.STARRED,
},
{
label: 'My Resumes',
value: BROWSE_TABS_VALUES.MY,
},
]}
value={tabsValue}
onChange={onTabChange}
/>
</div>
</div>
<div className="flex flex-wrap items-center justify-start gap-4 lg:gap-6">
<div className="w-64">
<TextInput
isLabelHidden={true}
label="search"
placeholder="Search Resumes"
startAddOn={MagnifyingGlassIcon}
startAddOnType="icon"
type="text"
value={searchValue}
onChange={setSearchValue}
/>
</div>
<DropdownMenu
align="end"
label={getFilterLabel(SORT_OPTIONS, sortOrder)}>
{SORT_OPTIONS.map(({ label, value }) => (
<DropdownMenu.Item
key={value}
isSelected={sortOrder === value}
label={label}
onClick={() => setSortOrder(value)}></DropdownMenu.Item>
))}
</DropdownMenu>
<Button
className="lg:hidden"
icon={FunnelIcon}
isLabelHidden={true}
label="Filters"
variant="tertiary"
onClick={() => setMobileFiltersOpen(true)}
/>
<Button
className="whitespace-pre-wrap px-2 lg:block"
label="Submit Resume"
variant="primary"
onClick={onSubmitResume}
/>
</div>
</div>
{isFetchingResumes ? (
<div className="w-full pt-4">
{' '}
<Spinner display="block" size="lg" />{' '}
</div>
) : sessionData === null && tabsValue !== BROWSE_TABS_VALUES.ALL ? (
<ResumeSignInButton
className="mt-8"
text={getLoggedOutText(tabsValue)}
/>
) : getTabResumes().length === 0 ? (
<div className="mt-24 flex flex-wrap justify-center">
<NewspaperIcon
className="mb-12 basis-full"
height={196}
width={196}
/>
{getEmptyDataText(tabsValue, searchValue, userFilters)}
</div>
) : (
<div className="h-[calc(100vh-9rem)] pb-10 lg:h-[calc(100vh-6rem)]">
<div className="h-[85%] overflow-y-auto">
<div>
<ResumeListItems resumes={getTabResumes()} />
</div>
</div>
<div className="flex h-[15%] items-center justify-center">
{getTabTotalPages() > 1 && (
<div>
<Pagination
current={currentPage}
end={getTabTotalPages()}
label="pagination"
start={1}
onSelect={(page) => setCurrentPage(page)}
/>
</div>
)}
</div>
</div>
)}
</div>
</div>
</main> </main>
</> </>
); );

@ -19,6 +19,7 @@ import {
TextInput, TextInput,
} from '@tih/ui'; } from '@tih/ui';
import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics';
import SubmissionGuidelines from '~/components/resumes/submit-form/SubmissionGuidelines'; import SubmissionGuidelines from '~/components/resumes/submit-form/SubmissionGuidelines';
import { RESUME_STORAGE_KEY } from '~/constants/file-storage-keys'; import { RESUME_STORAGE_KEY } from '~/constants/file-storage-keys';
@ -74,6 +75,8 @@ export default function SubmitResumeForm({
const router = useRouter(); const router = useRouter();
const trpcContext = trpc.useContext(); const trpcContext = trpc.useContext();
const resumeUpsertMutation = trpc.useMutation('resumes.resume.user.upsert'); const resumeUpsertMutation = trpc.useMutation('resumes.resume.user.upsert');
const { event: gaEvent } = useGoogleAnalytics();
const isNewForm = initFormDetails == null; const isNewForm = initFormDetails == null;
const { const {
@ -168,7 +171,12 @@ export default function SubmitResumeForm({
onSuccess() { onSuccess() {
if (isNewForm) { if (isNewForm) {
trpcContext.invalidateQueries('resumes.resume.findAll'); trpcContext.invalidateQueries('resumes.resume.findAll');
router.push('/resumes/browse'); router.push('/resumes');
gaEvent({
action: 'resumes.submit_button_click',
category: 'engagement',
label: 'Submit Resume',
});
} else { } else {
onClose(); onClose();
} }

@ -4,17 +4,28 @@ import { Button } from '@tih/ui';
import { useToast } from '@tih/ui'; import { useToast } from '@tih/ui';
import { HorizontalDivider } from '@tih/ui'; import { HorizontalDivider } from '@tih/ui';
import CitiesTypeahead from '~/components/shared/CitiesTypeahead';
import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead'; import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead';
import CountriesTypeahead from '~/components/shared/CountriesTypeahead';
import JobTitlesTypeahead from '~/components/shared/JobTitlesTypahead'; import JobTitlesTypeahead from '~/components/shared/JobTitlesTypahead';
import type { Month, MonthYear } from '~/components/shared/MonthYearPicker'; import type {
Month,
MonthYearOptional,
} from '~/components/shared/MonthYearPicker';
import MonthYearPicker from '~/components/shared/MonthYearPicker'; import MonthYearPicker from '~/components/shared/MonthYearPicker';
export default function HomePage() { export default function HomePage() {
const [selectedCompany, setSelectedCompany] = const [selectedCompany, setSelectedCompany] =
useState<TypeaheadOption | null>(null); useState<TypeaheadOption | null>(null);
const [selectedCountry, setSelectedCountry] =
useState<TypeaheadOption | null>(null);
const [selectedCity, setSelectedCity] = useState<TypeaheadOption | null>(
null,
);
const [selectedJobTitle, setSelectedJobTitle] = const [selectedJobTitle, setSelectedJobTitle] =
useState<TypeaheadOption | null>(null); useState<TypeaheadOption | null>(null);
const [monthYear, setMonthYear] = useState<MonthYear>({
const [monthYear, setMonthYear] = useState<MonthYearOptional>({
month: (new Date().getMonth() + 1) as Month, month: (new Date().getMonth() + 1) as Month,
year: new Date().getFullYear(), year: new Date().getFullYear(),
}); });
@ -22,12 +33,12 @@ export default function HomePage() {
const { showToast } = useToast(); const { showToast } = useToast();
return ( return (
<main className="flex-1 overflow-y-auto"> <main className="mx-auto max-w-5xl flex-1 overflow-y-auto py-24">
<div className="flex h-full items-center justify-center"> <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"> <div className="space-y-4">
<h1 className="text-primary-600 text-center text-4xl font-bold">
Test Page
</h1>
<CompaniesTypeahead <CompaniesTypeahead
onSelect={(option) => setSelectedCompany(option)} onSelect={(option) => setSelectedCompany(option)}
/> />
@ -38,7 +49,33 @@ export default function HomePage() {
/> />
<pre>{JSON.stringify(selectedJobTitle, null, 2)}</pre> <pre>{JSON.stringify(selectedJobTitle, null, 2)}</pre>
<HorizontalDivider /> <HorizontalDivider />
<MonthYearPicker value={monthYear} onChange={setMonthYear} /> <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
? 'Incomplete date'
: undefined
}
value={monthYear}
onChange={setMonthYear}
/>
<Button
label="Clear dates"
size="sm"
variant="tertiary"
onClick={() => {
setMonthYear({ month: null, year: null });
}}
/>
<pre>{JSON.stringify(monthYear, null, 2)}</pre>
<HorizontalDivider /> <HorizontalDivider />
<Button <Button
label="Add toast" label="Add toast"

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

Loading…
Cancel
Save