Merge branch 'main' into stuart/seed-db

* main: (55 commits)
  [offers][feat] add isSaved endpoint
  [questions][ui] minor ui fixes (#473)
  [offers][fix] Prevent user's offers from appearing in his own analysis
  [questions][feat] add useProtectedCallback hook (#472)
  [offers][fix] Fix save failure message (#474)
  [questions][feat] add encounters sorting (#458)
  [resumes][chore] add GA for resumes review page
  [offers][feat] Add multiple company analysis
  [questions][ui] Minor UI cleanup (#470)
  [offers][style] fix offer card style (#469)
  [questions][feat] add similar questions check (#468)
  [questions][fix] fix seed questions script (#467)
  [questions][ui] add temporary logo (#466)
  [questions][feat] sort answers, comments (#457)
  [offers][feat] add event tracking and save to profile in submisison page (#465)
  [offers][fix] sort profiles reverse chronological order
  [offers][feat] delete comment (#464)
  [offers][fix] fix error bug in delete comment
  [offers][fix] Fix comments UI (#463)
  [offers][feat] save to user profile (#462)
  ...

# Conflicts:
#	apps/portal/package.json
#	apps/portal/prisma/seed.ts
#	apps/portal/src/components/offers/offerAnalysis/OfferAnalysis.tsx
#	apps/portal/src/components/offers/offerAnalysis/OfferPercentileAnalysisText.tsx
#	apps/portal/src/mappers/offers-mappers.ts
#	apps/portal/src/pages/offers/submit/result/[offerProfileId].tsx
#	apps/portal/src/server/router/offers/offers-analysis-router.ts
#	apps/portal/src/server/router/offers/offers-profile-router.ts
#	apps/portal/src/server/router/offers/offers-user-profile-router.ts
pull/501/head^2
Bryann Yeap Kok Keong 3 years ago
commit 9ff636ae98

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

@ -10,7 +10,8 @@
"tsc": "tsc", "tsc": "tsc",
"postinstall": "prisma generate", "postinstall": "prisma generate",
"seed": "ts-node prisma/seed.ts", "seed": "ts-node prisma/seed.ts",
"seedSalaries": "ts-node prisma/readSheet.ts" "seedSalaries": "ts-node prisma/readSheet.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

@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "QuestionsQuestion" ADD COLUMN "numEncounters" INTEGER NOT NULL DEFAULT 0;
-- CreateIndex
CREATE INDEX "QuestionsQuestion_numEncounters_id_idx" ON "QuestionsQuestion"("numEncounters", "id");

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

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

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

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

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

@ -1,19 +1,98 @@
import { COMPANIES } from '../src/utils/companySeed'; import { COMPANIES } from '../src/utils/companySeed';
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();
<<<<<<< HEAD
=======
const COMPANIES = [
{
name: 'Meta',
slug: 'meta',
description: `Meta Platforms, Inc., doing business as Meta and formerly named Facebook, Inc., and TheFacebook, Inc., is an American multinational technology conglomerate based in Menlo Park, California. The company owns Facebook, Instagram, and WhatsApp, among other products and services.`,
logoUrl: 'https://logo.clearbit.com/meta.com',
},
{
name: 'Google',
slug: 'google',
description: `Google LLC is an American multinational technology company that focuses on search engine technology, online advertising, cloud computing, computer software, quantum computing, e-commerce, artificial intelligence, and consumer electronics.`,
logoUrl: 'https://logo.clearbit.com/google.com',
},
{
name: 'Apple',
slug: 'apple',
description: `Apple Inc. is an American multinational technology company that specializes in consumer electronics, software and online services headquartered in Cupertino, California, United States.`,
logoUrl: 'https://logo.clearbit.com/apple.com',
},
{
name: 'Amazon',
slug: 'amazon',
description: `Amazon.com, Inc. is an American multinational technology company that focuses on e-commerce, cloud computing, digital streaming, and artificial intelligence.`,
logoUrl: 'https://logo.clearbit.com/amazon.com',
},
{
name: 'Microsoft',
slug: 'microsoft',
description: `Microsoft Corporation is an American multinational technology corporation which produces computer software, consumer electronics, personal computers, and related services headquartered at the Microsoft Redmond campus located in Redmond, Washington, United States.`,
logoUrl: 'https://logo.clearbit.com/microsoft.com',
},
{
name: 'Netflix',
slug: 'netflix',
description: null,
logoUrl: 'https://logo.clearbit.com/netflix.com',
},
];
>>>>>>> main
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.');
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 351 KiB

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

After

Width:  |  Height:  |  Size: 10 KiB

@ -9,7 +9,9 @@ import { Bars3BottomLeftIcon } from '@heroicons/react/24/outline';
import GlobalNavigation from '~/components/global/GlobalNavigation'; import 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';
@ -105,9 +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; googleAnalyticsMeasurementID: string;
logo?: React.ReactNode;
navigation: ProductNavigationItems; navigation: ProductNavigationItems;
showGlobalNav: boolean; showGlobalNav: boolean;
title: string; title: string;
@ -119,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')) {
@ -173,6 +180,7 @@ export default function AppShell({ children }: Props) {
<MobileNavigation <MobileNavigation
globalNavigationItems={GlobalNavigation} globalNavigationItems={GlobalNavigation}
isShown={mobileMenuOpen} isShown={mobileMenuOpen}
logo={currentProductNavigation.logo}
productNavigationItems={currentProductNavigation.navigation} productNavigationItems={currentProductNavigation.navigation}
productTitle={currentProductNavigation.title} productTitle={currentProductNavigation.title}
setIsShown={setMobileMenuOpen} setIsShown={setMobileMenuOpen}
@ -192,6 +200,7 @@ export default function AppShell({ children }: Props) {
<div className="flex flex-1 items-center"> <div className="flex flex-1 items-center">
<ProductNavigation <ProductNavigation
items={currentProductNavigation.navigation} items={currentProductNavigation.navigation}
logo={currentProductNavigation.logo}
title={currentProductNavigation.title} title={currentProductNavigation.title}
titleHref={currentProductNavigation.titleHref} titleHref={currentProductNavigation.titleHref}
/> />

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

@ -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,11 +17,17 @@ 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 (
@ -29,13 +35,14 @@ export default function ProductNavigation({ items, title, titleHref }: Props) {
<Link <Link
className="hover:text-primary-700 flex items-center gap-2 text-base font-medium" className="hover:text-primary-700 flex items-center gap-2 text-base font-medium"
href={titleHref}> href={titleHref}>
{titleHref !== '/' && ( {titleHref !== '/' &&
<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"
)} />
))}
{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">

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

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

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

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

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

@ -9,15 +9,17 @@ import { OVERALL_TAB } from '../constants';
import type { AnalysisUnit, ProfileAnalysis } from '~/types/offers'; import type { AnalysisUnit, ProfileAnalysis } from '~/types/offers';
type OfferAnalysisContentProps = Readonly<{ type OfferAnalysisContentProps = Readonly<{
offerAnalysis: AnalysisUnit; analysis: AnalysisUnit;
isSubmission: boolean;
tab: string; tab: string;
}>; }>;
function OfferAnalysisContent({ function OfferAnalysisContent({
offerAnalysis, analysis,
tab, tab,
isSubmission,
}: OfferAnalysisContentProps) { }: OfferAnalysisContentProps) {
if (!offerAnalysis || offerAnalysis.noOfOffers === 0) { if (!analysis || analysis.noOfOffers === 0) {
if (tab === OVERALL_TAB) { if (tab === OVERALL_TAB) {
return ( return (
<p className="m-10"> <p className="m-10">
@ -35,14 +37,31 @@ function OfferAnalysisContent({
} }
return ( return (
<> <>
<OfferPercentileAnalysisText offerAnalysis={offerAnalysis} tab={tab} /> <OfferPercentileAnalysisText
<p className="mt-5">Here are some of the top offers relevant to you:</p> analysis={analysis}
{offerAnalysis.topPercentileOffers.map((topPercentileOffer) => ( isSubmission={isSubmission}
tab={tab}
/>
<p className="mt-5">
{isSubmission
? 'Here are some of the top offers relevant to you:'
: 'Relevant top offers:'}
</p>
{analysis.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>
)} */}
</> </>
); );
} }
@ -51,12 +70,14 @@ type OfferAnalysisProps = Readonly<{
allAnalysis: ProfileAnalysis; allAnalysis: ProfileAnalysis;
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<AnalysisUnit>( const [analysis, setAnalysis] = useState<AnalysisUnit>(
@ -101,7 +122,11 @@ export default function OfferAnalysis({
onChange={setTab} onChange={setTab}
/> />
<HorizontalDivider className="mb-5" /> <HorizontalDivider className="mb-5" />
<OfferAnalysisContent offerAnalysis={analysis} tab={tab} /> <OfferAnalysisContent
analysis={analysis}
isSubmission={isSubmission}
tab={tab}
/>
</div> </div>
)} )}
</div> </div>

@ -3,25 +3,29 @@ import { OVERALL_TAB } from '../constants';
import type { AnalysisUnit } from '~/types/offers'; import type { AnalysisUnit } from '~/types/offers';
type OfferPercentileAnalysisTextProps = Readonly<{ type OfferPercentileAnalysisTextProps = Readonly<{
offerAnalysis: AnalysisUnit; analysis: AnalysisUnit;
isSubmission: boolean;
tab: string; tab: string;
}>; }>;
export default function OfferPercentileAnalysisText({ export default function OfferPercentileAnalysisText({
tab, tab,
offerAnalysis: { noOfOffers, percentile, companyName }, analysis: { noOfOffers, percentile, companyName },
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" />
@ -60,12 +66,13 @@ 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}
</p> </p>
<p>Level: {level}</p> {level && <p>Level: {level}</p>}
</div> </div>
<div className="col-span-1 row-span-3"> <div className="col-span-1 row-span-3">
<p className="text-end">{formatDate(monthYearReceived)}</p> <p className="text-end">{formatDate(monthYearReceived)}</p>

@ -1,9 +1,12 @@
// Import { useState } from 'react'; import { useState } from 'react';
// import { setTimeout } from 'timers';
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 +18,40 @@ 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({
subtitle: 'Please check that you are logged in.',
title: `Failed to saved to dashboard!`,
variant: 'failure',
});
},
onSuccess: () => {
setSaved(true);
showToast({
title: `Saved to your dashboard!`,
variant: 'success',
});
},
},
);
// Const saveProfile = () => { const handleSave = () => {
// setSaving(true); saveMutation.mutate({
// setTimeout(() => { profileId,
// setSaving(false); token: token as string,
// setSaved(true); });
// }, 5); 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 +84,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 dashboard. It will still only be editable
you. 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>
); );

@ -6,6 +6,7 @@ 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 type { BreadcrumbStep } from '~/components/offers/Breadcrumb';
import { Breadcrumbs } from '~/components/offers/Breadcrumb'; import { Breadcrumbs } from '~/components/offers/Breadcrumb';
import BackgroundForm from '~/components/offers/offersSubmission/submissionForm/BackgroundForm'; import BackgroundForm from '~/components/offers/offersSubmission/submissionForm/BackgroundForm';
@ -27,6 +28,7 @@ import { trpc } from '~/utils/trpc';
const defaultOfferValues = { const defaultOfferValues = {
comments: '', comments: '',
companyId: '', companyId: '',
jobTitle: '',
jobType: JobType.FULLTIME, jobType: JobType.FULLTIME,
location: '', location: '',
monthYearReceived: { monthYearReceived: {
@ -39,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 = {
@ -73,6 +102,7 @@ export default function OffersSubmissionForm({
token: editToken, token: editToken,
}); });
const [isSubmitted, setIsSubmitted] = useState(false); const [isSubmitted, setIsSubmitted] = useState(false);
const { event: gaEvent } = useGoogleAnalytics();
const router = useRouter(); const router = useRouter();
const pageRef = useRef<HTMLDivElement>(null); const pageRef = useRef<HTMLDivElement>(null);
@ -82,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'],
@ -150,7 +184,7 @@ export default function OffersSubmissionForm({
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;
} }
@ -183,6 +217,11 @@ export default function OffersSubmissionForm({
} else { } else {
createOrUpdateMutation.mutate({ background, offers }); createOrUpdateMutation.mutate({ background, offers });
} }
gaEvent({
action: 'offers.submit_profile',
category: 'submission',
label: 'Submit profile',
});
}; };
useEffect(() => { useEffect(() => {
@ -198,6 +237,32 @@ export default function OffersSubmissionForm({
scrollToTop(); scrollToTop();
}, [step]); }, [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">
@ -210,7 +275,7 @@ export default function OffersSubmissionForm({
/> />
</div> </div>
<FormProvider {...formMethods}> <FormProvider {...formMethods}>
<form onSubmit={handleSubmit(onSubmit)}> <form className="text-sm" onSubmit={handleSubmit(onSubmit)}>
{steps[step]} {steps[step]}
{/* <pre>{JSON.stringify(formMethods.watch(), null, 2)}</pre> */} {/* <pre>{JSON.stringify(formMethods.watch(), null, 2)}</pre> */}
{step === 0 && ( {step === 0 && (
@ -220,7 +285,14 @@ export default function OffersSubmissionForm({
icon={ArrowRightIcon} icon={ArrowRightIcon}
label="Next" label="Next"
variant="secondary" variant="secondary"
onClick={() => goToNextStep(step)} onClick={() => {
goToNextStep(step);
gaEvent({
action: 'offers.profile_submission_navigate_next',
category: 'submission',
label: 'Navigate next',
});
}}
/> />
</div> </div>
)} )}
@ -230,9 +302,22 @@ export default function OffersSubmissionForm({
icon={ArrowLeftIcon} icon={ArrowLeftIcon}
label="Previous" label="Previous"
variant="secondary" variant="secondary"
onClick={() => setStep(step - 1)} onClick={() => {
setStep(step - 1);
gaEvent({
action: 'offers.profile_submission_navigation_back',
category: 'submission',
label: 'Navigate back',
});
}}
/>
<Button
disabled={isSubmitting || isSubmitSuccessful}
isLoading={isSubmitting || isSubmitSuccessful}
label="Submit"
type="submit"
variant="primary"
/> />
<Button label="Submit" type="submit" variant="primary" />{' '}
</div> </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,
@ -34,13 +36,18 @@ export default function OfferCard({
<div className="flex justify-between px-8"> <div className="flex justify-between px-8">
<div className="flex flex-col"> <div className="flex flex-col">
<div className="flex flex-row"> <div className="flex flex-row">
<BuildingOffice2Icon className="mr-1 h-5" /> <span>
<BuildingOffice2Icon className="mr-3 h-5" />
</span>
<span className="font-bold"> <span className="font-bold">
{location ? `${companyName}, ${location}` : companyName} {location ? `${companyName}, ${location}` : companyName}
</span> </span>
</div> </div>
<div className="ml-6 flex flex-row"> <div className="ml-8 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 && (
@ -74,15 +81,19 @@ export default function OfferCard({
<div className="flex flex-col py-2"> <div className="flex flex-col py-2">
{(totalCompensation || monthlySalary) && ( {(totalCompensation || monthlySalary) && (
<div className="flex flex-row"> <div className="flex flex-row">
<CurrencyDollarIcon className="mr-1 h-5" /> <span>
<p> <CurrencyDollarIcon className="mr-3 h-5" />
{totalCompensation && `TC: ${totalCompensation}`} </span>
{monthlySalary && `Monthly Salary: ${monthlySalary}`} <span>
</p> <p>
{totalCompensation && `TC: ${totalCompensation}`}
{monthlySalary && `Monthly Salary: ${monthlySalary}`}
</p>
</span>
</div> </div>
)} )}
{totalCompensation && ( {totalCompensation && (
<div className="ml-6 flex flex-row font-light"> <div className="ml-8 flex flex-row font-light">
<p> <p>
Base / year: {base} Stocks / year: {stocks} Bonus / year:{' '} Base / year: {base} Stocks / year: {stocks} Bonus / year:{' '}
{bonus} {bonus}
@ -93,7 +104,9 @@ export default function OfferCard({
{negotiationStrategy && ( {negotiationStrategy && (
<div className="flex flex-col py-2"> <div className="flex flex-col py-2">
<div className="flex flex-row"> <div className="flex flex-row">
<ScaleIcon className="h-5 w-5" /> <span>
<ScaleIcon className="h-5 w-5" />
</span>
<span className="overflow-wrap ml-2"> <span className="overflow-wrap ml-2">
"{negotiationStrategy}" "{negotiationStrategy}"
</span> </span>
@ -103,7 +116,9 @@ export default function OfferCard({
{otherComment && ( {otherComment && (
<div className="flex flex-col py-2"> <div className="flex flex-col py-2">
<div className="flex flex-row"> <div className="flex flex-row">
<ChatBubbleBottomCenterTextIcon className="h-5 w-5" /> <span>
<ChatBubbleBottomCenterTextIcon className="h-5 w-5" />
</span>
<span className="overflow-wrap ml-2">"{otherComment}"</span> <span className="overflow-wrap ml-2">"{otherComment}"</span>
</div> </div>
</div> </div>

@ -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,113 @@ 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({
subtitle: 'Please check that you are logged in.',
title: `Failed to saved to dashboard!`,
variant: 'failure',
});
},
onSuccess: () => {
setSaved(true);
showToast({
title: `Saved to dashboard!`,
variant: 'success',
});
},
},
);
const unsaveMutation = trpc.useMutation(
['offers.user.profile.removeFromUserProfile'],
{
onError: () => {
showToast({
title: `Failed to saved to dashboard!`,
variant: 'failure',
});
},
onSuccess: () => {
setSaved(false);
showToast({
title: `Removed from dashboard!`,
variant: 'success',
});
trpcContext.invalidateQueries(['offers.profile.listOne']);
},
},
);
const toggleSaved = () => {
if (saved) {
unsaveMutation.mutate({ profileId: offerProfileId as string });
} else {
saveMutation.mutate({
profileId: offerProfileId as string,
token: token as string,
});
}
};
function renderActionList() { 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={saved ? BookmarkIconSolid : BookmarkIconOutline}
disabled={isLoading} isLabelHidden={true}
icon={PencilSquareIcon} isLoading={saveMutation.isLoading || unsaveMutation.isLoading}
isLabelHidden={true} label={saved ? '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 +168,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 +217,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>

@ -27,7 +27,7 @@ export default function OfferTableRow({
<td className="py-4 px-6">{formatDate(monthYearReceived)}</td> <td className="py-4 px-6">{formatDate(monthYearReceived)}</td>
<td <td
className={clsx( className={clsx(
'sticky right-0 bg-white py-4 px-6 drop-shadow md:drop-shadow-none', 'sticky right-0 py-4 px-6 drop-shadow md:drop-shadow-none',
)}> )}>
<Link <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"

@ -2,6 +2,7 @@ import clsx from 'clsx';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { DropdownMenu, Spinner } 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,
@ -39,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,
@ -90,13 +92,18 @@ export default function OffersTable({
label={itemLabel} label={itemLabel}
onClick={() => { onClick={() => {
setSelectedTab(value); setSelectedTab(value);
gaEvent({
action: `offers.table_filter_yoe_category_${value}`,
category: 'engagement',
label: 'Filter by YOE category',
});
}} }}
/> />
))} ))}
</DropdownMenu> </DropdownMenu>
<div className="divide-x-slate-200 flex items-center space-x-4 divide-x"> <div className="divide-x-slate-200 flex items-center space-x-4 divide-x">
<div className="justify-left flex items-center space-x-2"> <div className="justify-left flex items-center space-x-2">
<span>All offers in</span> <span>View all offers in</span>
<CurrencySelector <CurrencySelector
handleCurrencyChange={(value: string) => setCurrency(value)} handleCurrencyChange={(value: string) => setCurrency(value)}
selectedCurrency={currency} selectedCurrency={currency}
@ -159,7 +166,7 @@ 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 });
} }
}; };

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

@ -5,6 +5,7 @@ import { Fragment, useRef, useState } from 'react';
import { Menu, Transition } from '@headlessui/react'; import { Menu, Transition } from '@headlessui/react';
import { CheckIcon, HeartIcon } from '@heroicons/react/20/solid'; import { CheckIcon, HeartIcon } from '@heroicons/react/20/solid';
import { useProtectedCallback } from '~/utils/questions/useProtectedCallback';
import { trpc } from '~/utils/trpc'; import { trpc } from '~/utils/trpc';
export type AddToListDropdownProps = { export type AddToListDropdownProps = {
@ -85,14 +86,16 @@ export default function AddToListDropdown({
}); });
}; };
const handleMenuButtonClick = useProtectedCallback(() => {
addClickOutsideListener();
setMenuOpened(!menuOpened);
});
const CustomMenuButton = ({ children }: PropsWithChildren<unknown>) => ( const CustomMenuButton = ({ children }: PropsWithChildren<unknown>) => (
<button <button
className="focus:ring-primary-500 inline-flex w-full justify-center rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-slate-100" className="focus:ring-primary-500 inline-flex w-full justify-center rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-slate-100"
type="button" type="button"
onClick={() => { onClick={handleMenuButtonClick}>
addClickOutsideListener();
setMenuOpened(!menuOpened);
}}>
{children} {children}
</button> </button>
); );

@ -6,6 +6,8 @@ import {
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import { TextInput } from '@tih/ui'; import { TextInput } from '@tih/ui';
import { useProtectedCallback } from '~/utils/questions/useProtectedCallback';
import ContributeQuestionDialog from './ContributeQuestionDialog'; import ContributeQuestionDialog from './ContributeQuestionDialog';
import type { ContributeQuestionFormProps } from './forms/ContributeQuestionForm'; import type { ContributeQuestionFormProps } from './forms/ContributeQuestionForm';
@ -23,61 +25,59 @@ export default function ContributeQuestionCard({
setShowDraftDialog(false); setShowDraftDialog(false);
}; };
const handleOpenContribute = () => { const handleOpenContribute = useProtectedCallback(() => {
setShowDraftDialog(true); setShowDraftDialog(true);
}; });
return ( return (
<div> <button
<button className="flex w-full 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" onClick={handleOpenContribute}>
onClick={handleOpenContribute}> <TextInput
<TextInput disabled={true}
disabled={true} isLabelHidden={true}
isLabelHidden={true} label="Question"
label="Question" placeholder="Contribute a question"
placeholder="Contribute a question" onChange={handleOpenContribute}
onChange={handleOpenContribute} />
/> <div className="flex flex-wrap items-end justify-start gap-2">
<div className="flex flex-wrap items-end justify-center gap-x-2"> <div className="min-w-[150px] flex-1">
<div className="min-w-[150px] flex-1"> <TextInput
<TextInput disabled={true}
disabled={true} label="Company"
label="Company" startAddOn={BuildingOffice2Icon}
startAddOn={BuildingOffice2Icon} startAddOnType="icon"
startAddOnType="icon" onChange={handleOpenContribute}
onChange={handleOpenContribute} />
/> </div>
</div> <div className="min-w-[150px] flex-1">
<div className="min-w-[150px] flex-1"> <TextInput
<TextInput disabled={true}
disabled={true} label="Question type"
label="Question type" startAddOn={QuestionMarkCircleIcon}
startAddOn={QuestionMarkCircleIcon} startAddOnType="icon"
startAddOnType="icon" onChange={handleOpenContribute}
onChange={handleOpenContribute} />
/> </div>
</div> <div className="min-w-[150px] flex-1">
<div className="min-w-[150px] flex-1"> <TextInput
<TextInput disabled={true}
disabled={true} label="Date"
label="Date" startAddOn={CalendarDaysIcon}
startAddOn={CalendarDaysIcon} startAddOnType="icon"
startAddOnType="icon" onChange={handleOpenContribute}
onChange={handleOpenContribute} />
/>
</div>
<h1 className="bg-primary-600 hover:bg-primary-700 rounded-full px-3 py-2 text-white">
Contribute
</h1>
</div> </div>
</button> <h1 className="bg-primary-600 hover:bg-primary-700 rounded-full px-3 py-2 text-white">
Contribute
</h1>
</div>
<ContributeQuestionDialog <ContributeQuestionDialog
show={showDraftDialog} show={showDraftDialog}
onCancel={handleDraftDialogCancel} onCancel={handleDraftDialogCancel}
onSubmit={onSubmit} onSubmit={onSubmit}
/> />
</div> </button>
); );
} }

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

@ -64,12 +64,16 @@ export default function LandingComponent({
return ( return (
<main className="flex flex-1 flex-col items-center overflow-y-auto bg-white"> <main className="flex flex-1 flex-col items-center overflow-y-auto bg-white">
<div className="flex flex-1 flex-col items-start justify-center gap-12 px-4"> <div className="flex flex-1 flex-col items-start justify-center gap-12 px-4">
<header className="flex flex-col items-start gap-4"> <header className="flex flex-col items-start gap-16">
<div className="flex items-center justify-center"> <div className="flex flex-col items-center self-stretch">
<h1 className="text-3xl font-semibold text-slate-900"> <img
alt="Questions Bank"
className="h-40 w-40"
src="/bank-logo.png"
/>
<h1 className="text-4xl font-bold text-slate-900 text-center">
Tech Interview Question Bank Tech Interview Question Bank
</h1> </h1>
<img alt="app logo" className="h-20 w-20" src="/logo.svg"></img>
</div> </div>
<p className="mb-2 max-w-lg text-5xl font-semibold text-slate-900 sm:max-w-3xl"> <p className="mb-2 max-w-lg text-5xl font-semibold text-slate-900 sm:max-w-3xl">
Know the{' '} Know the{' '}
@ -97,6 +101,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 +110,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"

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

@ -4,6 +4,8 @@ import type { Vote } from '@prisma/client';
import type { ButtonSize } from '@tih/ui'; import type { ButtonSize } from '@tih/ui';
import { Button } from '@tih/ui'; import { Button } from '@tih/ui';
import { useProtectedCallback } from '~/utils/questions/useProtectedCallback';
export type BackendVote = { export type BackendVote = {
id: string; id: string;
vote: Vote; vote: Vote;
@ -31,6 +33,15 @@ export default function VotingButtons({
vote?.vote === 'UPVOTE' ? 'secondary' : 'tertiary'; vote?.vote === 'UPVOTE' ? 'secondary' : 'tertiary';
const downvoteButtonVariant = const downvoteButtonVariant =
vote?.vote === 'DOWNVOTE' ? 'secondary' : 'tertiary'; vote?.vote === 'DOWNVOTE' ? 'secondary' : 'tertiary';
const handleUpvoteClick = useProtectedCallback(() => {
onUpvote();
});
const handleDownvoteClick = useProtectedCallback(() => {
onDownvote();
});
return ( return (
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<Button <Button
@ -42,7 +53,7 @@ export default function VotingButtons({
onClick={(event) => { onClick={(event) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
onUpvote(); handleUpvoteClick();
}} }}
/> />
<p>{upvoteCount}</p> <p>{upvoteCount}</p>
@ -55,7 +66,7 @@ export default function VotingButtons({
onClick={(event) => { onClick={(event) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
onDownvote(); handleDownvoteClick();
}} }}
/> />
</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,
@ -9,6 +9,7 @@ import {
import type { QuestionsQuestionType } from '@prisma/client'; import type { QuestionsQuestionType } from '@prisma/client';
import { Button } from '@tih/ui'; import { Button } from '@tih/ui';
import { useProtectedCallback } from '~/utils/questions/useProtectedCallback';
import { useQuestionVote } from '~/utils/questions/useVote'; import { useQuestionVote } from '~/utils/questions/useVote';
import AddToListDropdown from '../../AddToListDropdown'; import AddToListDropdown from '../../AddToListDropdown';
@ -18,6 +19,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 +54,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;
}; };
@ -86,10 +89,12 @@ type ReceivedStatisticsProps =
type CreateEncounterProps = type CreateEncounterProps =
| { | {
createEncounterButtonText: string;
onReceivedSubmit: (data: CreateQuestionEncounterData) => void; onReceivedSubmit: (data: CreateQuestionEncounterData) => void;
showCreateEncounterButton: true; showCreateEncounterButton: true;
} }
| { | {
createEncounterButtonText?: never;
onReceivedSubmit?: never; onReceivedSubmit?: never;
showCreateEncounterButton?: false; showCreateEncounterButton?: false;
}; };
@ -130,13 +135,14 @@ export default function BaseQuestionCard({
showAnswerStatistics, showAnswerStatistics,
showReceivedStatistics, showReceivedStatistics,
showCreateEncounterButton, showCreateEncounterButton,
createEncounterButtonText,
showActionButton, showActionButton,
actionButtonLabel, actionButtonLabel,
onActionButtonClick, onActionButtonClick,
upvoteCount, upvoteCount,
timestamp, timestamp,
roles, roles,
locations, countries,
showHover, showHover,
onReceivedSubmit, onReceivedSubmit,
showDeleteButton, showDeleteButton,
@ -147,6 +153,26 @@ 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 handleCreateEncounterClick = useProtectedCallback(() => {
setShowReceivedForm(true);
});
const cardContent = ( const cardContent = (
<> <>
{showVoteButtons && ( {showVoteButtons && (
@ -168,7 +194,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" />
@ -220,13 +246,10 @@ export default function BaseQuestionCard({
<Button <Button
addonPosition="start" addonPosition="start"
icon={CheckIcon} icon={CheckIcon}
label="I received this too" label={createEncounterButtonText}
size="sm" size="sm"
variant="tertiary" variant="tertiary"
onClick={(event) => { onClick={handleCreateEncounterClick}
event.preventDefault();
setShowReceivedForm(true);
}}
/> />
)} )}
</div> </div>

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

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

@ -9,11 +9,15 @@ import CompanyTypeahead from '../typeahead/CompanyTypeahead';
import LocationTypeahead from '../typeahead/LocationTypeahead'; import 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,
}); });
} }
}} }}

@ -0,0 +1,40 @@
import type { PropsWithChildren } from 'react';
import { createContext, useState } from 'react';
import ProtectedDialog from './ProtectedDialog';
export type ProtectedContextData = {
showDialog: () => void;
};
export const ProtectedContext = createContext<ProtectedContextData>({
// eslint-disable-next-line @typescript-eslint/no-empty-function
showDialog: () => {},
});
export type ProtectedContextProviderProps = PropsWithChildren<
Record<string, unknown>
>;
export default function ProtectedContextProvider({
children,
}: ProtectedContextProviderProps) {
const [show, setShow] = useState(false);
return (
<ProtectedContext.Provider
value={{
showDialog: () => {
setShow(true);
},
}}>
{children}
<ProtectedDialog
show={show}
onClose={() => {
setShow(false);
}}
/>
</ProtectedContext.Provider>
);
}

@ -0,0 +1,36 @@
import { signIn } from 'next-auth/react';
import { Button, Dialog } from '@tih/ui';
export type ProtectedDialogProps = {
onClose: () => void;
show: boolean;
};
export default function ProtectedDialog({
show,
onClose,
}: ProtectedDialogProps) {
const handlePrimaryClick = () => {
signIn();
onClose();
};
return (
<Dialog
isShown={show}
primaryButton={
<Button
label="Sign in"
variant="primary"
onClick={handlePrimaryClick}
/>
}
secondaryButton={
<Button label="Cancel" variant="tertiary" onClick={onClose} />
}
title="Sign in to continue"
onClose={onClose}>
<p>This action requires you to be signed in.</p>
</Dialog>
);
}

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

@ -21,8 +21,7 @@ const navigation: ProductNavigationItems = [
]; ];
const config = { const config = {
// TODO: Change this to your own GA4 measurement ID. googleAnalyticsMeasurementID: 'G-VFTWPMW1WK',
googleAnalyticsMeasurementID: 'G-DBLZDQ2ZZN',
navigation, navigation,
showGlobalNav: false, showGlobalNav: false,
title: 'Resumes', title: 'Resumes',

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

@ -10,6 +10,8 @@ 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 { useGoogleAnalytics } from '~/components/global/GoogleAnalytics';
import type { import type {
ExperienceFilter, ExperienceFilter,
LocationFilter, LocationFilter,
@ -30,8 +32,17 @@ type Props = Readonly<{
}>; }>;
export default function ResumeListItem({ href, resumeInfo }: Props) { export default function ResumeListItem({ href, resumeInfo }: Props) {
const { event: gaEvent } = useGoogleAnalytics();
return ( return (
<Link href={href}> <Link
href={href}
onClick={() =>
gaEvent({
action: 'resumes.listitem_click',
category: 'engagement',
label: 'Select Resume',
})
}>
<div className="grid grid-cols-8"> <div className="grid grid-cols-8">
<div className="col-span-7 grid gap-4 border-b border-slate-200 p-4 hover:bg-slate-100 sm:grid-cols-7"> <div className="col-span-7 grid gap-4 border-b border-slate-200 p-4 hover:bg-slate-100 sm:grid-cols-7">
<div className="sm:col-span-4"> <div className="sm:col-span-4">
@ -39,10 +50,12 @@ export default function ResumeListItem({ href, resumeInfo }: Props) {
{resumeInfo.title} {resumeInfo.title}
<p <p
className={clsx( className={clsx(
'w-auto items-center space-x-4 rounded-xl border border-slate-300 px-2 py-1 text-xs font-medium text-white opacity-70', '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.isResolved ? 'bg-slate-400' : 'bg-success-500',
)}> )}>
{resumeInfo.isResolved ? 'Reviewed' : 'Unreviewed'} <span className="opacity-100">
{resumeInfo.isResolved ? 'Reviewed' : 'Unreviewed'}
</span>
</p> </p>
</div> </div>
<div className="text-primary-500 mt-2 flex items-center justify-start text-xs"> <div className="text-primary-500 mt-2 flex items-center justify-start text-xs">

@ -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,6 +54,11 @@ 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',
});
}, },
}, },
); );

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

@ -7,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 = {
@ -20,6 +22,7 @@ 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(); const router = useRouter();
@ -35,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',
});
}, },
}, },
); );
@ -44,6 +52,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_unvote',
category: 'engagement',
label: 'Unvote comment',
});
}, },
}, },
); );

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

@ -8,17 +8,19 @@ type Props = Readonly<{
disabled?: boolean; disabled?: boolean;
errorMessage?: string; 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([
@ -44,9 +46,10 @@ export default function CompaniesTypeahead({
value: id, value: id,
})) ?? [] })) ?? []
} }
placeholder={placeHolder} placeholder={placeholder}
required={required} required={required}
textSize="inherit" 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,9 +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" 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}

@ -680,6 +680,8 @@ export const getUserProfileResponseMapper = (
profileName: profile.profileName, profileName: profile.profileName,
token: profile.editToken, token: profile.editToken,
}; };
}).sort((a, b) => {
return b.createdAt > a.createdAt ? 1 : -1;
}); });
} }

@ -9,6 +9,7 @@ import { loggerLink } from '@trpc/client/links/loggerLink';
import { withTRPC } from '@trpc/next'; import { withTRPC } from '@trpc/next';
import AppShell from '~/components/global/AppShell'; import AppShell from '~/components/global/AppShell';
import ProtectedContextProvider from '~/components/questions/protected/ProtectedContextProvider';
import type { AppRouter } from '~/server/router'; import type { AppRouter } from '~/server/router';
@ -21,9 +22,11 @@ const MyApp: AppType<{ session: Session | null }> = ({
return ( return (
<SessionProvider session={session}> <SessionProvider session={session}>
<ToastsProvider> <ToastsProvider>
<AppShell> <ProtectedContextProvider>
<Component {...pageProps} /> <AppShell>
</AppShell> <Component {...pageProps} />
</AppShell>
</ProtectedContextProvider>
</ToastsProvider> </ToastsProvider>
</SessionProvider> </SessionProvider>
); );

@ -1,59 +0,0 @@
import Link from 'next/link';
import { useState } from 'react';
import { Banner } from '@tih/ui';
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">
<Banner size="sm">
Check if your offer is competitive by submitting it{' '}
<Link className="underline" href="/offers/submit">
here
</Link>
.
</Banner>
<div className="bg-slate-100 py-16 px-4">
<div>
<div>
<h1 className="text-primary-600 text-center text-4xl font-bold sm:text-5xl">
Tech Offers Repo
</h1>
</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
offers.
</div>
</div>
<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">
<span>Viewing offers for</span>
<div className="flex items-center space-x-4">
<JobTitlesTypeahead
isLabelHidden={true}
placeHolder="Software Engineer"
onSelect={({ value }) => setjobTitleFilter(value)}
/>
<span>in</span>
<CompaniesTypeahead
isLabelHidden={true}
placeHolder="All Companies"
onSelect={({ value }) => setCompanyFilter(value)}
/>
</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 dashboard
</h1>
<p className="mx-auto w-3/4 text-start text-xl text-slate-900">
Save your offer profiles to dashboard to easily access and edit them
later.
</p>
<div className="justfy-center mt-8 flex w-screen">
<ul className="mx-auto w-3/4 space-y-3" role="list">
{userProfiles?.map((profile) => (
<li
key={profile.id}
className="overflow-hidden bg-white px-4 py-4 shadow sm:rounded-md sm:px-6">
<DashboardOfferCard key={profile.id} profile={profile} />
</li>
))}
</ul>
</div>
</div>
)}
</>
);
}

@ -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 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,8 @@
import Error from 'next/error'; import Error from 'next/error';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useSession } from 'next-auth/react';
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 +16,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 +25,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);
@ -37,11 +35,16 @@ export default function OfferProfile() {
ProfileDetailTab.OFFERS, ProfileDetailTab.OFFERS,
); );
const [analysis, setAnalysis] = useState<ProfileAnalysis>(); const [analysis, setAnalysis] = useState<ProfileAnalysis>();
const { data: session } = useSession();
const getProfileQuery = trpc.useQuery( const getProfileQuery = trpc.useQuery(
[ [
'offers.profile.listOne', 'offers.profile.listOne',
{ profileId: offerProfileId as string, token: token as string }, {
profileId: offerProfileId as string,
token: token as string,
userId: session?.user?.id,
},
], ],
{ {
enabled: typeof offerProfileId === 'string', enabled: typeof offerProfileId === 'string',
@ -126,6 +129,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 +181,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 +202,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" />

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

@ -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,23 @@
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 { useProtectedCallback } from '~/utils/questions/useProtectedCallback';
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 +25,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,10 +49,23 @@ 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.user.create', 'questions.answers.comments.user.create',
@ -47,19 +73,25 @@ export default function QuestionPage() {
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,
},
]); ]);
}, },
}, },
); );
const handleSubmitComment = (data: AnswerCommentData) => { const handleSubmitComment = useProtectedCallback(
resetComment(); (data: AnswerCommentData) => {
addComment({ resetComment();
answerId: answerId as string, addComment({
content: data.commentContent, answerId: answerId as string,
}); content: data.commentContent,
}; });
},
);
if (!answer) { if (!answer) {
return <FullScreenSpinner />; return <FullScreenSpinner />;
@ -108,32 +140,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 +148,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,26 @@
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 { useProtectedCallback } from '~/utils/questions/useProtectedCallback';
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 +31,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,
@ -34,10 +54,11 @@ export default function QuestionPage() {
const { const {
register: comRegister, register: comRegister,
handleSubmit: handleCommentSubmit, handleSubmit: handleCommentSubmitClick,
reset: resetComment, reset: resetComment,
formState: { isDirty: isCommentDirty, isValid: isCommentValid }, formState: { isDirty: isCommentDirty, isValid: isCommentValid },
} = useForm<QuestionCommentData>({ mode: 'onChange' }); } = useForm<QuestionCommentData>({ mode: 'onChange' });
const commentRegister = useFormRegister(comRegister); const commentRegister = useFormRegister(comRegister);
const { questionId } = router.query; const { questionId } = router.query;
@ -52,12 +73,33 @@ 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.user.create', 'questions.questions.comments.user.create',
@ -70,10 +112,23 @@ 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 { data: answerData } = answerInfiniteQuery;
const { mutate: addAnswer } = trpc.useMutation( const { mutate: addAnswer } = trpc.useMutation(
'questions.answers.user.create', 'questions.answers.user.create',
@ -96,21 +151,25 @@ export default function QuestionPage() {
}, },
); );
const handleSubmitAnswer = (data: AnswerQuestionData) => { const handleSubmitAnswer = useProtectedCallback(
addAnswer({ (data: AnswerQuestionData) => {
content: data.answerContent, addAnswer({
questionId: questionId as string, content: data.answerContent,
}); questionId: questionId as string,
resetAnswer(); });
}; resetAnswer();
},
);
const handleSubmitComment = (data: QuestionCommentData) => { const handleSubmitComment = useProtectedCallback(
addComment({ (data: QuestionCommentData) => {
content: data.commentContent, addComment({
questionId: questionId as string, content: data.commentContent,
}); questionId: questionId as string,
resetComment(); });
}; resetComment();
},
);
if (!question) { if (!question) {
return <FullScreenSpinner />; return <FullScreenSpinner />;
@ -134,15 +193,16 @@ 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 ?? {}}
createEncounterButtonText="I received this too"
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',
@ -150,78 +210,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={handleCommentSubmitClick(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', {
@ -234,34 +290,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"
@ -270,21 +298,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,12 +194,14 @@ 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]);
@ -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,74 +486,72 @@ 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-6">
<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,
/> });
<QuestionSearchBar }}
sortOrderOptions={SORT_ORDERS} />
sortOrderValue={sortOrder} <div className="flex flex-col items-stretch gap-4">
sortTypeOptions={SORT_TYPES} <div className="sticky top-0 border-b border-slate-300 bg-slate-50 py-4">
sortTypeValue={sortType} <QuestionSearchBar
onFilterOptionsToggle={() => { sortOrderValue={sortOrder}
setFilterDrawerOpen(!filterDrawerOpen); sortTypeValue={sortType}
}} onFilterOptionsToggle={() => {
onSortOrderChange={setSortOrder} setFilterDrawerOpen(!filterDrawerOpen);
onSortTypeChange={setSortType} }}
/> onSortOrderChange={setSortOrder}
onSortTypeChange={setSortType}
/>
</div>
<div className="flex flex-col gap-2 pb-4"> <div className="flex flex-col gap-2 pb-4">
{(questionsQueryData?.pages ?? []).flatMap( {(questionsQueryData?.pages ?? []).flatMap(
({ data: questions }) => ({ data: questions }) =>
questions.map((question) => ( questions.map((question) => {
<QuestionOverviewCard const { companyCounts, countryCounts, roleCounts } =
key={question.id} relabelQuestionAggregates(
answerCount={question.numAnswers} question.aggregatedQuestionEncounters,
companies={ );
question.aggregatedQuestionEncounters.companyCounts
} return (
content={question.content} <QuestionOverviewCard
href={`/questions/${question.id}/${createSlug( key={question.id}
question.content, answerCount={question.numAnswers}
)}`} 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}
upvoteCount={question.numVotes} upvoteCount={question.numVotes}
/> />
)), );
}),
)}
{questionCount !== 0 && (
<PaginationLoadMoreButton query={questionsInfiniteQuery} />
)} )}
<Button
disabled={!hasNextPage || isFetchingNextPage}
isLoading={isFetchingNextPage}
label={hasNextPage ? 'Load more' : 'Nothing more to load'}
variant="tertiary"
onClick={() => {
fetchNextPage();
}}
/>
{questionCount === 0 && ( {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"> <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" /> <NoSymbolIcon className="h-6 w-6" />

@ -15,6 +15,8 @@ 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 { useProtectedCallback } from '~/utils/questions/useProtectedCallback';
import { trpc } from '~/utils/trpc'; import { trpc } from '~/utils/trpc';
export default function ListPage() { export default function ListPage() {
@ -76,6 +78,10 @@ export default function ListPage() {
setShowCreateListDialog(false); setShowCreateListDialog(false);
}; };
const handleAddClick = useProtectedCallback(() => {
setShowCreateListDialog(true);
});
const listOptions = ( const listOptions = (
<> <>
<ul className="flex flex-1 flex-col divide-y divide-solid divide-slate-200"> <ul className="flex flex-1 flex-col divide-y divide-solid divide-slate-200">
@ -156,10 +162,10 @@ export default function ListPage() {
label="Create" label="Create"
size="md" size="md"
variant="tertiary" variant="tertiary"
onClick={(e) => { onClick={(event) => {
e.preventDefault(); event.preventDefault();
e.stopPropagation(); event.stopPropagation();
setShowCreateListDialog(true); handleAddClick();
}} }}
/> />
</div> </div>
@ -172,37 +178,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">
@ -221,11 +228,13 @@ export default function ListPage() {
onCancel={handleDeleteListCancel} onCancel={handleDeleteListCancel}
onDelete={() => { onDelete={() => {
handleDeleteList(listIdToDelete); handleDeleteList(listIdToDelete);
}}></DeleteListDialog> }}
/>
<CreateListDialog <CreateListDialog
show={showCreateListDialog} show={showCreateListDialog}
onCancel={handleCreateListCancel} onCancel={handleCreateListCancel}
onSubmit={handleCreateList}></CreateListDialog> onSubmit={handleCreateList}
/>
</section> </section>
</div> </div>
</main> </main>

@ -17,6 +17,7 @@ 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';
@ -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 }],
@ -58,16 +61,31 @@ export default function ResumeReviewPage() {
const starMutation = trpc.useMutation('resumes.resume.star', { const starMutation = trpc.useMutation('resumes.resume.star', {
onSuccess() { onSuccess() {
invalidateResumeQueries(); invalidateResumeQueries();
gaEvent({
action: 'resumes.star_button_click',
category: 'engagement',
label: 'Star Resume',
});
}, },
}); });
const unstarMutation = trpc.useMutation('resumes.resume.unstar', { const unstarMutation = trpc.useMutation('resumes.resume.unstar', {
onSuccess() { onSuccess() {
invalidateResumeQueries(); invalidateResumeQueries();
gaEvent({
action: 'resumes.star_button_click',
category: 'engagement',
label: 'Unstar Resume',
});
}, },
}); });
const resolveMutation = trpc.useMutation('resumes.resume.user.resolve', { const resolveMutation = trpc.useMutation('resumes.resume.user.resolve', {
onSuccess() { onSuccess() {
invalidateResumeQueries(); invalidateResumeQueries();
gaEvent({
action: 'resumes.resolve_button_click',
category: 'engagement',
label: 'Resolve Resume',
});
}, },
}); });
@ -124,6 +142,11 @@ export default function ResumeReviewPage() {
pathname: '/resumes', 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'),

@ -20,6 +20,7 @@ import {
TextInput, TextInput,
} from '@tih/ui'; } from '@tih/ui';
import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics';
import ResumeFilterPill from '~/components/resumes/browse/ResumeFilterPill'; import ResumeFilterPill from '~/components/resumes/browse/ResumeFilterPill';
import ResumeListItems from '~/components/resumes/browse/ResumeListItems'; import ResumeListItems from '~/components/resumes/browse/ResumeListItems';
import ResumeSignInButton from '~/components/resumes/shared/ResumeSignInButton'; import ResumeSignInButton from '~/components/resumes/shared/ResumeSignInButton';
@ -118,7 +119,7 @@ export default function ResumeHomePage() {
'', '',
); );
const [shortcutSelected, setShortcutSelected, isShortcutInit] = const [shortcutSelected, setShortcutSelected, isShortcutInit] =
useSearchParams('shortcutSelected', 'All'); useSearchParams('shortcutSelected', 'Unreviewed');
const [currentPage, setCurrentPage, isCurrentPageInit] = useSearchParams( const [currentPage, setCurrentPage, isCurrentPageInit] = useSearchParams(
'currentPage', 'currentPage',
1, 1,
@ -127,7 +128,15 @@ export default function ResumeHomePage() {
'userFilters', 'userFilters',
INITIAL_FILTER_STATE, INITIAL_FILTER_STATE,
); );
const [isFiltersOpen, setIsFiltersOpen, isFiltersOpenInit] = useSearchParams<
Record<FilterId, boolean>
>('isFiltersOpen', {
experience: false,
location: false,
role: false,
});
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false); const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false);
const { event: gaEvent } = useGoogleAnalytics();
const skip = (currentPage - 1) * PAGE_LIMIT; const skip = (currentPage - 1) * PAGE_LIMIT;
const isSearchOptionsInit = useMemo(() => { const isSearchOptionsInit = useMemo(() => {
@ -137,7 +146,8 @@ export default function ResumeHomePage() {
isSearchValueInit && isSearchValueInit &&
isShortcutInit && isShortcutInit &&
isCurrentPageInit && isCurrentPageInit &&
isUserFiltersInit isUserFiltersInit &&
isFiltersOpenInit
); );
}, [ }, [
isTabsValueInit, isTabsValueInit,
@ -146,6 +156,7 @@ export default function ResumeHomePage() {
isShortcutInit, isShortcutInit,
isCurrentPageInit, isCurrentPageInit,
isUserFiltersInit, isUserFiltersInit,
isFiltersOpenInit,
]); ]);
useEffect(() => { useEffect(() => {
@ -164,6 +175,7 @@ export default function ResumeHomePage() {
pathname: router.pathname, pathname: router.pathname,
query: { query: {
currentPage: JSON.stringify(currentPage), currentPage: JSON.stringify(currentPage),
isFiltersOpen: JSON.stringify(isFiltersOpen),
searchValue: JSON.stringify(searchValue), searchValue: JSON.stringify(searchValue),
shortcutSelected: JSON.stringify(shortcutSelected), shortcutSelected: JSON.stringify(shortcutSelected),
sortOrder: JSON.stringify(sortOrder), sortOrder: JSON.stringify(sortOrder),
@ -180,22 +192,16 @@ export default function ResumeHomePage() {
currentPage, currentPage,
router.pathname, router.pathname,
isSearchOptionsInit, isSearchOptionsInit,
isFiltersOpen,
]); ]);
const filterCountsQuery = trpc.useQuery(
['resumes.resume.getTotalFilterCounts'],
{
staleTime: STALE_TIME,
},
);
const allResumesQuery = trpc.useQuery( const allResumesQuery = trpc.useQuery(
[ [
'resumes.resume.findAll', 'resumes.resume.findAll',
{ {
experienceFilters: userFilters.experience, experienceFilters: userFilters.experience,
isUnreviewed: userFilters.isUnreviewed,
locationFilters: userFilters.location, locationFilters: userFilters.location,
numComments: userFilters.numComments,
roleFilters: userFilters.role, roleFilters: userFilters.role,
searchValue: useDebounceValue(searchValue, DEBOUNCE_DELAY), searchValue: useDebounceValue(searchValue, DEBOUNCE_DELAY),
skip, skip,
@ -213,8 +219,8 @@ export default function ResumeHomePage() {
'resumes.resume.user.findUserStarred', 'resumes.resume.user.findUserStarred',
{ {
experienceFilters: userFilters.experience, experienceFilters: userFilters.experience,
isUnreviewed: userFilters.isUnreviewed,
locationFilters: userFilters.location, locationFilters: userFilters.location,
numComments: userFilters.numComments,
roleFilters: userFilters.role, roleFilters: userFilters.role,
searchValue: useDebounceValue(searchValue, DEBOUNCE_DELAY), searchValue: useDebounceValue(searchValue, DEBOUNCE_DELAY),
skip, skip,
@ -233,8 +239,8 @@ export default function ResumeHomePage() {
'resumes.resume.user.findUserCreated', 'resumes.resume.user.findUserCreated',
{ {
experienceFilters: userFilters.experience, experienceFilters: userFilters.experience,
isUnreviewed: userFilters.isUnreviewed,
locationFilters: userFilters.location, locationFilters: userFilters.location,
numComments: userFilters.numComments,
roleFilters: userFilters.role, roleFilters: userFilters.role,
searchValue: useDebounceValue(searchValue, DEBOUNCE_DELAY), searchValue: useDebounceValue(searchValue, DEBOUNCE_DELAY),
skip, skip,
@ -249,14 +255,6 @@ export default function ResumeHomePage() {
}, },
); );
const getFilterCount = (filter: FilterLabel, value: string) => {
if (filterCountsQuery.isLoading) {
return 0;
}
const filterCountsData = filterCountsQuery.data!;
return filterCountsData[filter][value];
};
const onSubmitResume = () => { const onSubmitResume = () => {
if (sessionData === null) { if (sessionData === null) {
router.push('/api/auth/signin'); router.push('/api/auth/signin');
@ -283,6 +281,11 @@ export default function ResumeHomePage() {
), ),
}); });
} }
gaEvent({
action: 'resumes.filter_checkbox_click',
category: 'engagement',
label: 'Select Filter',
});
}; };
const onClearFilterClick = (filterSection: FilterId) => { const onClearFilterClick = (filterSection: FilterId) => {
@ -300,11 +303,21 @@ export default function ResumeHomePage() {
setShortcutSelected(shortcutName); setShortcutSelected(shortcutName);
setSortOrder(shortcutSortOrder); setSortOrder(shortcutSortOrder);
setUserFilters(shortcutFilters); setUserFilters(shortcutFilters);
gaEvent({
action: 'resumes.shortcut_button_click',
category: 'engagement',
label: `Select Shortcut: ${shortcutName}`,
});
}; };
const onTabChange = (tab: string) => { const onTabChange = (tab: string) => {
setTabsValue(tab); setTabsValue(tab);
setCurrentPage(1); setCurrentPage(1);
gaEvent({
action: 'resumes.tab_click',
category: 'engagement',
label: `Select Tab: ${tab}`,
});
}; };
const getTabQueryData = () => { const getTabQueryData = () => {
@ -336,6 +349,18 @@ export default function ResumeHomePage() {
starredResumesQuery.isFetching || starredResumesQuery.isFetching ||
myResumesQuery.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];
};
return ( return (
<> <>
<Head> <Head>
@ -402,11 +427,19 @@ export default function ResumeHomePage() {
<Disclosure <Disclosure
key={filter.id} key={filter.id}
as="div" as="div"
className="border-t border-slate-200 px-4 pt-6 pb-4"> className="border-t border-slate-200 px-4 pt-6 pb-4"
defaultOpen={isFiltersOpen[filter.id]}>
{({ open }) => ( {({ open }) => (
<> <>
<h3 className="-mx-2 -my-3 flow-root"> <h3 className="-mx-2 -my-3 flow-root">
<Disclosure.Button className="flex w-full items-center justify-between bg-white px-2 py-3 text-slate-400 hover:text-slate-500"> <Disclosure.Button
className="flex w-full items-center justify-between bg-white px-2 py-3 text-slate-400 hover:text-slate-500"
onClick={() =>
setIsFiltersOpen({
...isFiltersOpen,
[filter.id]: !isFiltersOpen[filter.id],
})
}>
<span className="font-medium text-slate-900"> <span className="font-medium text-slate-900">
{filter.label} {filter.label}
</span> </span>
@ -430,12 +463,9 @@ export default function ResumeHomePage() {
{filter.options.map((option) => ( {filter.options.map((option) => (
<div <div
key={option.value} key={option.value}
className="[&>div>div:nth-child(1)>input]:text-primary-600 [&>div>div:nth-child(1)>input]:ring-primary-500 [&>div>div:nth-child(2)>label]:font-normal"> className="[&>div>div:nth-child(1)>input]:text-primary-600 [&>div>div:nth-child(1)>input]:ring-primary-500 flex items-center px-1 text-sm [&>div>div:nth-child(2)>label]:font-normal">
<CheckboxInput <CheckboxInput
label={`${option.label} (${getFilterCount( label={option.label}
filter.label,
option.label,
)})`}
value={userFilters[filter.id].includes( value={userFilters[filter.id].includes(
option.value, option.value,
)} )}
@ -447,11 +477,19 @@ export default function ResumeHomePage() {
) )
} }
/> />
<span className="ml-1 text-slate-500">
(
{getFilterCount(
filter.label,
option.label,
)}
)
</span>
</div> </div>
))} ))}
</div> </div>
<p <p
className="cursor-pointer text-sm text-slate-500 underline" className="inline-block cursor-pointer text-sm text-slate-500 underline hover:text-slate-700"
onClick={() => onClearFilterClick(filter.id)}> onClick={() => onClearFilterClick(filter.id)}>
Clear Clear
</p> </p>
@ -494,72 +532,83 @@ export default function ResumeHomePage() {
<h3 className="text-md font-medium tracking-tight text-slate-900"> <h3 className="text-md font-medium tracking-tight text-slate-900">
Explore these filters Explore these filters
</h3> </h3>
{filters.map((filter) => ( {isFiltersOpenInit &&
<Disclosure filters.map((filter) => (
key={filter.id} <Disclosure
as="div" key={filter.id}
className="border-b border-slate-200 pt-6 pb-4"> as="div"
{({ open }) => ( className="border-b border-slate-200 pt-6 pb-4"
<> defaultOpen={isFiltersOpen[filter.id]}>
<h3 className="-my-3 flow-root"> {({ open }) => (
<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"> <h3 className="-my-3 flow-root">
{filter.label} <Disclosure.Button
</span> className="flex w-full items-center justify-between py-3 text-sm text-slate-400 hover:text-slate-500"
<span className="ml-6 flex items-center"> onClick={() =>
{open ? ( setIsFiltersOpen({
<MinusIcon ...isFiltersOpen,
aria-hidden="true" [filter.id]: !isFiltersOpen[filter.id],
className="h-5 w-5" })
/> }>
) : ( <span className="font-medium text-slate-900">
<PlusIcon {filter.label}
aria-hidden="true" </span>
className="h-5 w-5" <span className="ml-6 flex items-center">
/> {open ? (
)} <MinusIcon
</span> aria-hidden="true"
</Disclosure.Button> className="h-5 w-5"
</h3> />
<Disclosure.Panel className="space-y-4 pt-4"> ) : (
<CheckboxList <PlusIcon
description="" aria-hidden="true"
isLabelHidden={true} className="h-5 w-5"
label="" />
orientation="vertical"> )}
{filter.options.map((option) => ( </span>
<div </Disclosure.Button>
key={option.value} </h3>
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"> <Disclosure.Panel className="space-y-4 pt-4">
<CheckboxInput <CheckboxList
label={`${option.label} (${getFilterCount( description=""
filter.label, isLabelHidden={true}
option.label, label=""
)})`} orientation="vertical">
value={userFilters[filter.id].includes( {filter.options.map((option) => (
option.value, <div
)} key={option.value}
onChange={(isChecked) => 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">
onFilterCheckboxChange( <CheckboxInput
isChecked, label={option.label}
filter.id, value={userFilters[filter.id].includes(
option.value, 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> ))}
))} </CheckboxList>
</CheckboxList> <p
<p className="inline-block cursor-pointer text-sm text-slate-500 underline hover:text-slate-700"
className="cursor-pointer text-sm text-slate-500 underline" onClick={() => onClearFilterClick(filter.id)}>
onClick={() => onClearFilterClick(filter.id)}> Clear
Clear </p>
</p> </Disclosure.Panel>
</Disclosure.Panel> </>
</> )}
)} </Disclosure>
</Disclosure> ))}
))}
</form> </form>
</div> </div>
</div> </div>
@ -599,6 +648,13 @@ export default function ResumeHomePage() {
type="text" type="text"
value={searchValue} value={searchValue}
onChange={setSearchValue} onChange={setSearchValue}
onFocus={() =>
gaEvent({
action: 'resumes.search_input_focus',
category: 'engagement',
label: 'Click Search',
})
}
/> />
</div> </div>
<DropdownMenu <DropdownMenu

@ -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,10 +171,12 @@ export default function SubmitResumeForm({
onSuccess() { onSuccess() {
if (isNewForm) { if (isNewForm) {
trpcContext.invalidateQueries('resumes.resume.findAll'); trpcContext.invalidateQueries('resumes.resume.findAll');
trpcContext.invalidateQueries(
'resumes.resume.getTotalFilterCounts',
);
router.push('/resumes'); 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"

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

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

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

@ -102,6 +102,57 @@ const education = z.object({
}); });
export const offersProfileRouter = createRouter() export const offersProfileRouter = createRouter()
.query('isValidToken', {
input: z.object({
profileId: z.string(),
token: z.string(),
}),
async resolve({ ctx, input }) {
const profile = await ctx.prisma.offersProfile.findFirst({
where: {
id: input.profileId,
},
});
return profile?.editToken === input.token;
},
})
.query('isSaved', {
input: z.object({
profileId: z.string(),
userId: z.string().nullish(),
}),
async resolve({ ctx, input }) {
if (!input.userId) {
return false;
}
const profile = await ctx.prisma.offersProfile.findFirst({
include: {
users: true,
},
where: {
id: input.profileId,
},
});
const users = profile?.users;
if (!users) {
return false;
}
let isSaved = false;
for (let i = 0; i < users.length; i++) {
if (users[i].id === input.userId) {
isSaved = true;
}
}
return isSaved;
},
})
.query('listOne', { .query('listOne', {
input: z.object({ input: z.object({
profileId: z.string(), profileId: z.string(),

@ -47,7 +47,7 @@ export const offersUserProfileRouter = createProtectedRouter()
}); });
}, },
}) })
.mutation('getUserProfiles', { .query('getUserProfiles', {
async resolve({ ctx }) { async resolve({ ctx }) {
const userId = ctx.session.user.id; const userId = ctx.session.user.id;
const result = await ctx.prisma.user.findFirst({ const result = await ctx.prisma.user.findFirst({

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

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

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

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

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

Loading…
Cancel
Save