diff --git a/SOLUTION.md b/SOLUTION.md new file mode 100644 index 0000000..7b01e64 --- /dev/null +++ b/SOLUTION.md @@ -0,0 +1,29 @@ +# Environment + +- I decided to create a Docker Compose setup to make development easier by reducing local configuration requirements + +# AI + +- I'm using Copilot integrated with VSCode to accelerate coding + +# Back-end + +- The CSV data is loaded when the server starts +- The reviews are grouped by UUID to make review requests less expensive +- The college names are stored in a list, removing duplications +- Includes a test for the load data function + +## Front-end + +- The components are specialized +- It is possible to use them in other contexts +- The SearchInput component has debouncing to avoid hitting the API unnecessarily +- I added a dummy element just for better appearance + + +## Run + +```sh +docker compose -p challenge up -d --build +``` + diff --git a/back-end/.DS_Store b/back-end/.DS_Store new file mode 100644 index 0000000..e61327d Binary files /dev/null and b/back-end/.DS_Store differ diff --git a/back-end/.gitignore b/back-end/.gitignore new file mode 100644 index 0000000..cad2309 --- /dev/null +++ b/back-end/.gitignore @@ -0,0 +1 @@ +/tmp \ No newline at end of file diff --git a/back-end/Dockerfile b/back-end/Dockerfile new file mode 100644 index 0000000..f39178b --- /dev/null +++ b/back-end/Dockerfile @@ -0,0 +1,8 @@ +FROM golang:1.24-alpine + +WORKDIR /app +COPY . . + +RUN apk update && apk add curl +RUN go mod tidy +RUN curl -sSfL https://raw.githubusercontent.com/air-verse/air/master/install.sh | sh -s -- -b $(go env GOPATH)/bin diff --git a/back-end/air.toml b/back-end/air.toml new file mode 100644 index 0000000..e69de29 diff --git a/back-end/go.mod b/back-end/go.mod index c6ba5a2..984b9d9 100644 --- a/back-end/go.mod +++ b/back-end/go.mod @@ -1,3 +1,10 @@ -module niche.com/fullstack-exercise - -go 1.24 \ No newline at end of file +module niche.com/fullstack-exercise + +go 1.24 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stretchr/testify v1.11.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/back-end/go.sum b/back-end/go.sum new file mode 100644 index 0000000..cc8b3f4 --- /dev/null +++ b/back-end/go.sum @@ -0,0 +1,9 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/back-end/reviews.go b/back-end/reviews.go index 6b64740..f4ffddd 100644 --- a/back-end/reviews.go +++ b/back-end/reviews.go @@ -1,13 +1,86 @@ package main +import ( + "encoding/csv" + "fmt" + "os" +) + +type Review struct { + CollegeUuid string `json:"collegeUuid"` + ReviewText string `json:"reviewText"` +} + +type College struct { + Uuid string `json:"uuid"` + Name string `json:"name"` + Url string `json:"url"` +} + // ReviewsData holds the reviews data. You should modify this type. -type ReviewsData struct{} +type ReviewsData struct { + Reviews map[string][]Review `json:"reviews"` + Colleges []College `json:"colleges"` +} func loadReviews() (*ReviewsData, error) { // TODO: Implement this function to load reviews from the CSV file // 1. Read the CSV file from data/niche_reviews.csv + + file, err := os.Open("data/niche_reviews.csv") + if err != nil { + fmt.Println("Error opening CSV file:", err) + return nil, err + } + defer file.Close() + + reader := csv.NewReader(file) + + records, err := reader.ReadAll() + if err != nil { + fmt.Println("Error reading CSV file:", err) + return nil, err + } + // 2. Parse the CSV data to extract college names and reviews - // 3. Build and return a ReviewsData struct with the processed data + uniqColleges := make(map[string]bool, 0) + colleges := make([]College, 0) + reviews := make(map[string][]Review, 0) + for i, record := range records { + if i == 0 { + continue // Skip header row + } + uuid := record[0] + name := record[1] + url := record[2] + text := record[3] + group, ok := reviews[uuid] + if !ok { + group = make([]Review, 0) + group = append(group, Review{ + CollegeUuid: uuid, + ReviewText: text, + }) + } else { + group = append(group, Review{ + CollegeUuid: uuid, + ReviewText: text, + }) + } + reviews[uuid] = group + _, ok = uniqColleges[uuid] + if !ok { + uniqColleges[uuid] = true + colleges = append(colleges, College{Name: name, Uuid: uuid, Url: url}) + } + } - return nil, nil + // 3. Build and return a ReviewsData struct with the processed data + return &ReviewsData{Reviews: reviews, Colleges: colleges}, nil } + +type ByCollegeName []College + +func (a ByCollegeName) Len() int { return len(a) } +func (a ByCollegeName) Swap(i, j int) { a[i], a[j] = a[j], a[i] } +func (a ByCollegeName) Less(i, j int) bool { return a[i].Name < a[j].Name } diff --git a/back-end/reviews_test.go b/back-end/reviews_test.go new file mode 100644 index 0000000..442f4e7 --- /dev/null +++ b/back-end/reviews_test.go @@ -0,0 +1,28 @@ +package main + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestLoadReviews(t *testing.T) { + data, err := loadReviews() + + assert.NoError(t, err, "Expected no error loading reviews") + assert.NotNil(t, data, "Expected non-nil data") + + t.Run("Reviews", func(t *testing.T) { + assert.Greater(t, len(data.Reviews), 0, "Expected at least one review group") + key := "515965b4-7557-44dd-a0b9-8832d2e143ee" + assert.Greater(t, len(data.Reviews[key]), 0, "Expected at least one review in the first group") + assert.Len(t, data.Reviews[key][0].CollegeUuid, 36, "Expected first review to have correct Uuid") + assert.Regexp(t, "^I am a dental student and feel like because the", data.Reviews[key][0].ReviewText, "Expected first review to have correct text") + }) + + t.Run("Colleges", func(t *testing.T) { + assert.Greater(t, len(data.Colleges), 0, "Expected at least one college") + assert.Equal(t, "A.T. Still University of Health Sciences", data.Colleges[0].Name, "Expected first college to have correct Name") + assert.Equal(t, "515965b4-7557-44dd-a0b9-8832d2e143ee", data.Colleges[0].Uuid, "Expected first college to have correct Uuid") + }) +} diff --git a/back-end/server.go b/back-end/server.go index d45900a..a472a63 100644 --- a/back-end/server.go +++ b/back-end/server.go @@ -1,7 +1,10 @@ package main import ( + "encoding/json" "net/http" + "sort" + "strings" ) // Server represents the HTTP server for our application @@ -17,6 +20,13 @@ func NewServer(reviewsData *ReviewsData) *Server { ReviewsData: reviewsData, } + data, err := loadReviews() + if err != nil { + server.ReviewsData = &ReviewsData{Reviews: map[string][]Review{}} + } + + server.ReviewsData = data + // Register routes server.Router.HandleFunc("/autocomplete", server.handleAutocomplete) server.Router.HandleFunc("/reviews", server.handleGetReviews) @@ -34,8 +44,24 @@ func (s *Server) handleAutocomplete(w http.ResponseWriter, r *http.Request) { // This should search through the colleges in ReviewsData // and return matches based on the query string + query := r.URL.Query().Get("q") + matches := []College{} + for _, college := range s.ReviewsData.Colleges { + if strings.HasPrefix(strings.ToLower(college.Name), strings.ToLower(query)) { + matches = append(matches, college) + } + } + + sort.Sort(ByCollegeName(matches)) + + response, err := json.Marshal(matches) + if err != nil { + w.Write([]byte(`[]`)) + return + } + // Write a simple 200 OK with empty JSON response for now - w.Write([]byte("{}")) + w.Write(response) } // handleGetReviews handles the reviews endpoint @@ -48,6 +74,26 @@ func (s *Server) handleGetReviews(w http.ResponseWriter, r *http.Request) { // This should retrieve reviews for a specific college // and return them in the response + collegeUuid := r.URL.Query().Get("college") + + if collegeUuid == "" { + w.Write([]byte(`[]`)) + return + } + + reviews, ok := s.ReviewsData.Reviews[collegeUuid] + + if !ok { + w.Write([]byte(`[]`)) + return + } + + response, err := json.Marshal(reviews) + if err != nil { + w.Write([]byte(`[]`)) + return + } + // Write a simple 200 OK with empty JSON response for now - w.Write([]byte("{}")) + w.Write(response) } diff --git a/back-end/start.sh b/back-end/start.sh new file mode 100755 index 0000000..aec58f7 --- /dev/null +++ b/back-end/start.sh @@ -0,0 +1,2 @@ +#!/bin/sh +air -c air.toml \ No newline at end of file diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..886b961 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,20 @@ +services: + backend: + build: ./back-end + ports: + - "8080:8080" + volumes: + - ./back-end:/app + working_dir: /app + command: ./start.sh + frontend: + image: node:20 + ports: + - "3000:3000" + volumes: + - ./front-end:/app + - node_modules:/app/node_modules + working_dir: /app + command: ./start.sh +volumes: + node_modules: {} diff --git a/front-end/.editorconfig b/front-end/.editorconfig new file mode 100644 index 0000000..30d0b40 --- /dev/null +++ b/front-end/.editorconfig @@ -0,0 +1,13 @@ +# top-most EditorConfig file + root = true + + [*] + indent_style = space + indent_size = 2 + end_of_line = lf + charset = utf-8 + trim_trailing_whitespace = true + insert_final_newline = true + + [*.md] + trim_trailing_whitespace = false diff --git a/front-end/next.config.ts b/front-end/next.config.ts index bd7c7c9..afe55f0 100644 --- a/front-end/next.config.ts +++ b/front-end/next.config.ts @@ -2,7 +2,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { /* config options here */ - devIndicators: false + devIndicators: false, }; export default nextConfig; diff --git a/front-end/package-lock.json b/front-end/package-lock.json index 8644a52..3388efb 100644 --- a/front-end/package-lock.json +++ b/front-end/package-lock.json @@ -8,20 +8,58 @@ "name": "niche-fullstack-exercise-frontend", "version": "0.1.0", "dependencies": { + "clsx": "^2.1.1", "next": "15.3.3", "react": "^19.0.0", "react-dom": "^19.0.0" }, "devDependencies": { "@eslint/eslintrc": "^3", + "@testing-library/dom": "^10.4.1", + "@testing-library/react": "^16.3.0", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", - "eslint": "^9", + "eslint": "^9.36.0", "eslint-config-next": "15.3.3", "typescript": "^5" } }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@emnapi/core": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.3.tgz", @@ -56,9 +94,9 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", - "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", "dev": true, "license": "MIT", "dependencies": { @@ -98,9 +136,9 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.20.0.tgz", - "integrity": "sha512-fxlS1kkIjx8+vy2SjuCB94q3htSNrufYTXubwiBFeaQHbH6Ipi43gFJq2zCMt6PHhImH3Xmr0NksKDvchWlpQQ==", + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", + "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -113,9 +151,9 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.2.2.tgz", - "integrity": "sha512-+GPzk8PlG0sPpzdU5ZvIRMPidzAnZDl/s9L+y13iodqvb8leL53bTannOrQ/Im7UkpsmFU5Ily5U60LWixnmLg==", + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.1.tgz", + "integrity": "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -123,9 +161,9 @@ } }, "node_modules/@eslint/core": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.14.0.tgz", - "integrity": "sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg==", + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz", + "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -160,9 +198,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.27.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.27.0.tgz", - "integrity": "sha512-G5JD9Tu5HJEu4z2Uo4aHY2sLV64B7CDMXxFzqzjl3NKd6RVzSXNoE80jk7Y0lJkTTkjiIhBAqmlYwjuBY3tvpA==", + "version": "9.36.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.36.0.tgz", + "integrity": "sha512-uhCbYtYynH30iZErszX78U+nR3pJU3RHGQ57NXy5QupD4SBVwDeU8TNBy+MjMngc1UyIW9noKqsRqfjQTBU2dw==", "dev": true, "license": "MIT", "engines": { @@ -183,13 +221,13 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.1.tgz", - "integrity": "sha512-0J+zgWxHN+xXONWIyPWKFMgVuJoZuGiIFu8yxk7RJjxkzpGmyja5wRFqZIVtjDVOQpV+Rw0iOAjYPE2eQyjr0w==", + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz", + "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.14.0", + "@eslint/core": "^0.15.2", "levn": "^0.4.1" }, "engines": { @@ -892,6 +930,64 @@ "tslib": "^2.8.0" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.0.tgz", + "integrity": "sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@tybys/wasm-util": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.9.0.tgz", @@ -903,6 +999,13 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", @@ -1480,9 +1583,9 @@ ] }, "node_modules/acorn": { - "version": "8.14.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", - "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", "bin": { @@ -1519,6 +1622,16 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -1908,6 +2021,15 @@ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "license": "MIT" }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", @@ -2104,6 +2226,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", @@ -2127,6 +2259,13 @@ "node": ">=0.10.0" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -2340,20 +2479,20 @@ } }, "node_modules/eslint": { - "version": "9.27.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.27.0.tgz", - "integrity": "sha512-ixRawFQuMB9DZ7fjU3iGGganFDp3+45bPOdaRurcFHSXO1e/sYwUX/FtQZpLZJR6SjMoJH8hR2pPEAfDyCoU2Q==", + "version": "9.36.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.36.0.tgz", + "integrity": "sha512-hB4FIzXovouYzwzECDcUkJ4OcfOEkXTv2zRY6B9bkwjx/cprAq0uvm1nl7zvQ0/TsUk0zQiN4uPfJpB9m+rPMQ==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.20.0", - "@eslint/config-helpers": "^0.2.1", - "@eslint/core": "^0.14.0", + "@eslint/config-array": "^0.21.0", + "@eslint/config-helpers": "^0.3.1", + "@eslint/core": "^0.15.2", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.27.0", - "@eslint/plugin-kit": "^0.3.1", + "@eslint/js": "9.36.0", + "@eslint/plugin-kit": "^0.3.5", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", @@ -2364,9 +2503,9 @@ "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.3.0", - "eslint-visitor-keys": "^4.2.0", - "espree": "^10.3.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", @@ -2672,9 +2811,9 @@ } }, "node_modules/eslint-scope": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.3.0.tgz", - "integrity": "sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ==", + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -2689,9 +2828,9 @@ } }, "node_modules/eslint-visitor-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -2702,15 +2841,15 @@ } }, "node_modules/espree": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz", - "integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.14.0", + "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.0" + "eslint-visitor-keys": "^4.2.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3816,6 +3955,16 @@ "loose-envify": "cli.js" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -4273,6 +4422,41 @@ "node": ">= 0.8.0" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", diff --git a/front-end/package.json b/front-end/package.json index d363184..669a2a0 100644 --- a/front-end/package.json +++ b/front-end/package.json @@ -9,17 +9,20 @@ "lint": "next lint" }, "dependencies": { + "clsx": "^2.1.1", + "next": "15.3.3", "react": "^19.0.0", - "react-dom": "^19.0.0", - "next": "15.3.3" + "react-dom": "^19.0.0" }, "devDependencies": { - "typescript": "^5", + "@eslint/eslintrc": "^3", + "@testing-library/dom": "^10.4.1", + "@testing-library/react": "^16.3.0", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", - "eslint": "^9", + "eslint": "^9.36.0", "eslint-config-next": "15.3.3", - "@eslint/eslintrc": "^3" + "typescript": "^5" } } diff --git a/front-end/src/app/components/AutocompleteInput/AutocompleteInput.tsx b/front-end/src/app/components/AutocompleteInput/AutocompleteInput.tsx new file mode 100644 index 0000000..153a941 --- /dev/null +++ b/front-end/src/app/components/AutocompleteInput/AutocompleteInput.tsx @@ -0,0 +1,238 @@ +"use client"; +import "./autocomplete-input.css"; +import { useCallback, useEffect, useRef, useState } from "react"; +import clsx from "clsx"; +import { SearchInput } from "../SearchInput/SearchInput"; + +export interface SuggestionsItem { + title: string; + subtitle?: string; + value: T; +} + +export type OnSelect = (item: T) => void; + +interface State { + value: string; + limit: number; + focusedItem?: SuggestionsItem; + keyBoardFocusPosition: number; + isVisible?: boolean; +} + +interface Props { + isLoading?: boolean; + suggestions?: SuggestionsItem[]; + limit?: number; + placeholder?: string; + onChange?: (value: string) => void; + onSelect?: OnSelect; +} + +export function AutocompleteInput({ + suggestions, + isLoading, + placeholder, + limit = 10, + onChange, + onSelect, +}: Props) { + const [state, setState] = useState>({ + value: "", + isVisible: false, + keyBoardFocusPosition: -1, + limit, + }); + const ref = useRef(null); + + const handleSelect = useCallback( + (item: SuggestionsItem) => { + console.log("Selected item:", item); + onSelect?.(item.value); + setState((prev) => ({ + ...prev, + value: item.title, + keyBoardFocusPosition: -1, + isVisible: false, + })); + }, + [onSelect], + ); + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + console.log("Key down event:", event.key); + if (!suggestions || suggestions.length === 0) return; + if (event.key === "ArrowDown") { + event.preventDefault(); + console.log( + "Current position:", + state.keyBoardFocusPosition, + state.limit, + ); + setState((prev) => ({ + ...prev, + keyBoardFocusPosition: Math.min( + prev.keyBoardFocusPosition + 1, + Math.min(suggestions.length - 1, prev.limit - 1), + ), + limit: + prev.keyBoardFocusPosition === prev.limit - 1 + ? prev.limit + limit + : prev.limit, + })); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + setState((prev) => ({ + ...prev, + keyBoardFocusPosition: Math.max(prev.keyBoardFocusPosition - 1, 0), + })); + } else if (event.key === "Enter") { + event.preventDefault(); + if ( + state.keyBoardFocusPosition >= 0 && + state.keyBoardFocusPosition < suggestions.length + ) { + const selectedItem = suggestions[state.keyBoardFocusPosition]; + handleSelect(selectedItem); + setState((prev) => ({ ...prev, keyBoardFocusPosition: -1 })); + } + } else if (event.key === "Escape") { + event.preventDefault(); + setState((prev) => ({ + ...prev, + keyBoardFocusPosition: -1, + isVisible: false, + })); + } + }, + [suggestions, limit, state.keyBoardFocusPosition, handleSelect], + ); + + const handleInputChange = useCallback( + (e: React.ChangeEvent) => { + setState((prev) => ({ + ...prev, + value: e.target.value, + keyBoardFocusPosition: -1, + })); + }, + [], + ); + + const handleMouseOverItem = useCallback( + (e: React.MouseEvent) => { + const parent = e.currentTarget.parentElement; + const index = parent + ? Array.from(parent.children).indexOf(e.currentTarget) + : -1; + setState((prev) => ({ ...prev, keyBoardFocusPosition: index })); + }, + [], + ); + + const handleClickOutside = useCallback( + (event: React.MouseEvent) => { + if (ref.current && !ref.current.contains(event.target as Node)) { + setState((prev) => ({ + ...prev, + keyBoardFocusPosition: -1, + isVisible: false, + })); + } + }, + [], + ); + + const handleMore = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + setState((prev) => ({ ...prev, limit: prev.limit + limit })); + }, []); + + const handleClear = useCallback(() => { + setState((prev) => ({ + ...prev, + value: "", + keyBoardFocusPosition: -1, + isVisible: false, + })); + }, []); + + useEffect(() => { + const timeout = setTimeout(() => { + console.log("Fetch suggestions for:", state.value); + onChange?.(state.value); + }, 300); + return () => clearTimeout(timeout); + }, [state.value, onChange]); + + useEffect(() => { + setState((prev) => ({ + ...prev, + focusedItem: suggestions?.[state.keyBoardFocusPosition], + })); + }, [state.keyBoardFocusPosition, suggestions]); + + useEffect(() => { + if (!suggestions || suggestions.length === 0) { + setState((prev) => ({ + ...prev, + keyBoardFocusPosition: -1, + isVisible: false, + limit, + })); + } else { + setState((prev) => ({ ...prev, isVisible: true })); + } + }, [suggestions]); + + return ( + <> +
+
+ setState((prev) => ({ ...prev, isVisible: true }))} + onClear={handleClear} + /> +
+ {state.isVisible && suggestions && suggestions.length > 0 && ( +
    + {suggestions.slice(0, state.limit).map((suggestion, index) => ( +
  • handleSelect(suggestion)} + onMouseMove={handleMouseOverItem} + > +

    {suggestion.title}

    + {suggestion.subtitle &&

    {suggestion.subtitle}

    } +
  • + ))} + {suggestions.length > state.limit && ( +
  • + + ...and {suggestions.length - state.limit} more + +
  • + )} +
+ )} +
+ + {state.isVisible && ( +
+ )} + + ); +} diff --git a/front-end/src/app/components/AutocompleteInput/autocomplete-input.css b/front-end/src/app/components/AutocompleteInput/autocomplete-input.css new file mode 100644 index 0000000..b022225 --- /dev/null +++ b/front-end/src/app/components/AutocompleteInput/autocomplete-input.css @@ -0,0 +1,52 @@ +.autocomplete-input { + position: relative; + z-index: 2; +} + +.autocomplete-input_suggestions { + top: calc(100% + 4px); + list-style: none; + margin: 0; + padding: 0; + position: absolute; + display: flex; + flex-direction: column; + gap: 2px; + border-radius: 6px; + border: 1px solid #ccc; + background-color: white; + font-size: 0.8em; + width: 100%; + overflow: hidden; + z-index: 2; +} + +.autocomplete-input_suggestions a { + text-decoration: none; + color: inherit; + color: #999; +} + +.autocomplete-input_suggestions .suggestion-item { + padding: 0.5rem; + cursor: pointer; +} +.autocomplete-input_suggestions .suggestion-item.hover { + background-color: aquamarine; +} + +.autocomplete-input_suggestions .suggestion-item p { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: inherit; +} + +.autocomplete-input_outside { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + z-index: 1; +} diff --git a/front-end/src/app/components/AutocompleteInput/index.ts b/front-end/src/app/components/AutocompleteInput/index.ts new file mode 100644 index 0000000..98afbcf --- /dev/null +++ b/front-end/src/app/components/AutocompleteInput/index.ts @@ -0,0 +1 @@ +export { AutocompleteInput } from "./AutocompleteInput"; diff --git a/front-end/src/app/components/CollegeReviewFinder/CollegeReviewFinder.tsx b/front-end/src/app/components/CollegeReviewFinder/CollegeReviewFinder.tsx new file mode 100644 index 0000000..98ee0e5 --- /dev/null +++ b/front-end/src/app/components/CollegeReviewFinder/CollegeReviewFinder.tsx @@ -0,0 +1,157 @@ +"use client"; +import "./college-review-finder.css"; +import { AutocompleteInput } from "../AutocompleteInput"; + +import { useCallback, useState } from "react"; +import { + fetchAutocomplete, + fetchCollegeByUuid, +} from "@/client/api/college-review.api"; +import { + OnSelect, + SuggestionsItem, +} from "../AutocompleteInput/AutocompleteInput"; +import { Autocomplete } from "@/app/interfaces/Autocomplete"; +import { Review } from "@/app/interfaces/Review"; + +interface CollegeReviewFinderState { + query: string; + isLoading: boolean; + autocompleteList?: Autocomplete[]; + reviewList?: Review[]; + selectedCollege?: Autocomplete; +} + +function toSuggestions( + collegeReviews: Autocomplete[] = [], +): SuggestionsItem[] { + return collegeReviews.map((cr) => ({ + title: cr.name, + value: cr, + })); +} + +function randomRating() { + const rating = Math.floor(Math.random() * 5) + 1; + const start = new Array(5).fill(0).map((_, i) => i < rating); + return start; +} + +export function CollegeReviewFinder() { + const [state, setState] = useState({ + query: "", + isLoading: false, + reviewList: [], + autocompleteList: [], + }); + + const handleChange = useCallback( + async (query: string) => { + console.log("Searching for:", query); + if (!query) { + setState((prev) => ({ + ...prev, + query, + collegeReviews: [], + autocompleteList: [], + selectedCollege: undefined, + })); + return; + } + if (query === state.selectedCollege?.name) { + return; + } + setState((prev) => ({ ...prev, isLoading: true, query })); + const result = await fetchAutocomplete(query); + setState((prev) => ({ + ...prev, + isLoading: false, + autocompleteList: result, + })); + }, + [state.selectedCollege], + ); + + const handleSelect: OnSelect = useCallback( + async (review: Autocomplete) => { + console.log("Selected college review:", review); + setState((prev) => ({ + ...prev, + isLoading: true, + selectedCollege: review, + query: review.name, + collegeReviews: [], + autocompleteList: [], + })); + const result = await fetchCollegeByUuid(review.uuid); + setState((prev) => ({ + ...prev, + isLoading: false, + reviewList: result || [], + })); + }, + [], + ); + + return ( +
+

Find College Reviews

+ + {state.selectedCollege && ( +
+

Reviews for {state.selectedCollege.name}

+ {state.reviewList && state.reviewList.length > 0 ? ( +
    + {state.reviewList.map((review, index) => ( +
  • +
    + + + +
    +
    +

    Anonymous

    +

    {review.reviewText}

    +
    + {randomRating().map((r, i) => ( + + + + ))} +
    +
    +
  • + ))} +
+ ) : ( +

No reviews found for this college.

+ )} +
+ )} +
+ ); +} diff --git a/front-end/src/app/components/CollegeReviewFinder/college-review-finder.css b/front-end/src/app/components/CollegeReviewFinder/college-review-finder.css new file mode 100644 index 0000000..d551e68 --- /dev/null +++ b/front-end/src/app/components/CollegeReviewFinder/college-review-finder.css @@ -0,0 +1,52 @@ +.college-review-finder { + display: flex; + flex-direction: column; + gap: 1rem; + max-width: 600px; + margin: 0 auto; + margin-top: 2rem; +} + +.college-review-finder_college { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.college-review-finder_reviews { + display: flex; + flex-direction: column; + gap: 0.5rem; + list-style: none; +} + +.college-review-finder_details { + font-size: 0.8em; + line-height: 2em; + border-radius: 8px; + padding: 1rem; + display: flex; +} + +.college-review-finder_avatar { + width: 32px; + height: 32px; + border-radius: 50%; + object-fit: cover; + margin-right: 1rem; + flex-shrink: 0; + background-color: aquamarine; + display: flex; + align-items: center; + justify-content: center; +} +.college-review-finder_avatar svg { + opacity: 0.6; + height: 15px; + width: 15px; +} + +.college-review-finder_details p { + line-height: 1.5em; + font-family: "Times New Roman", Times, serif; +} diff --git a/front-end/src/app/components/CollegeReviewFinder/index.ts b/front-end/src/app/components/CollegeReviewFinder/index.ts new file mode 100644 index 0000000..9735bab --- /dev/null +++ b/front-end/src/app/components/CollegeReviewFinder/index.ts @@ -0,0 +1 @@ +export { CollegeReviewFinder as CollegeFinder } from "./CollegeReviewFinder"; diff --git a/front-end/src/app/components/SearchInput/SearchInput.tsx b/front-end/src/app/components/SearchInput/SearchInput.tsx new file mode 100644 index 0000000..5a9571a --- /dev/null +++ b/front-end/src/app/components/SearchInput/SearchInput.tsx @@ -0,0 +1,30 @@ +import "./search-input.css"; + +interface Props extends React.ComponentPropsWithoutRef<"input"> { + isLoading?: boolean; + onClear?: () => void; +} + +export function SearchInput({ isLoading, onClear, ...props }: Props) { + return ( +
+ + {props.value && ( +
onClear?.()}> + + + +
+ )} + {isLoading &&
} +
+ ); +} diff --git a/front-end/src/app/components/SearchInput/index.ts b/front-end/src/app/components/SearchInput/index.ts new file mode 100644 index 0000000..1e5ee32 --- /dev/null +++ b/front-end/src/app/components/SearchInput/index.ts @@ -0,0 +1 @@ +export { SearchInput } from "./SearchInput"; diff --git a/front-end/src/app/components/SearchInput/search-input.css b/front-end/src/app/components/SearchInput/search-input.css new file mode 100644 index 0000000..8d08263 --- /dev/null +++ b/front-end/src/app/components/SearchInput/search-input.css @@ -0,0 +1,42 @@ +@keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +.search-input input { + width: 100%; + padding: 10px; + font-size: 16px; + border: 1px solid #ccc; + border-radius: 4px; + position: relative; +} + +.search-input_loader { + position: absolute; + right: 10px; + width: 20px; + height: 20px; + border: 3px solid #ccc; + top: calc(50% - 10px); + border-top-color: #333; + border-radius: 50%; + animation: spin 1s linear infinite; + opacity: 0.5; +} + +.search-input_clear { + position: absolute; + right: 10px; + top: calc(50% - 10px); + width: 20px; + height: 20px; + background: none; + border: none; + cursor: pointer; + padding: 0; +} diff --git a/front-end/src/app/globals.css b/front-end/src/app/globals.css index 96ba8a3..6b9fbc7 100644 --- a/front-end/src/app/globals.css +++ b/front-end/src/app/globals.css @@ -3,3 +3,16 @@ margin: 0; padding: 0; } + +body { + font: + 400 16px / 1.36em "Source sans pro", + sans-serif; + color: #1b1b1b; +} + +.container { + max-width: 1200px; + margin: 0 auto; + padding: 20px; +} diff --git a/front-end/src/app/interfaces/Autocomplete.ts b/front-end/src/app/interfaces/Autocomplete.ts new file mode 100644 index 0000000..77841b0 --- /dev/null +++ b/front-end/src/app/interfaces/Autocomplete.ts @@ -0,0 +1,5 @@ +export interface Autocomplete { + uuid: string; + name: string; + url: string; +} diff --git a/front-end/src/app/interfaces/Review.ts b/front-end/src/app/interfaces/Review.ts new file mode 100644 index 0000000..a9badc8 --- /dev/null +++ b/front-end/src/app/interfaces/Review.ts @@ -0,0 +1,4 @@ +export interface Review { + collegeUuid: string; + reviewText: string; +} diff --git a/front-end/src/app/layout.tsx b/front-end/src/app/layout.tsx index b086a3e..bb7bf5d 100644 --- a/front-end/src/app/layout.tsx +++ b/front-end/src/app/layout.tsx @@ -13,9 +13,7 @@ export default function RootLayout({ }>) { return ( - - {children} - + {children} ); } diff --git a/front-end/src/app/page.tsx b/front-end/src/app/page.tsx index b42a1f5..4978141 100644 --- a/front-end/src/app/page.tsx +++ b/front-end/src/app/page.tsx @@ -1,7 +1,9 @@ +import { CollegeReviewFinder } from "./components/CollegeReviewFinder/CollegeReviewFinder"; + export default function Home() { return ( -
-

Hello World

+
+
); } diff --git a/front-end/src/client/api/college-review.api.ts b/front-end/src/client/api/college-review.api.ts new file mode 100644 index 0000000..c1f5f26 --- /dev/null +++ b/front-end/src/client/api/college-review.api.ts @@ -0,0 +1,37 @@ +import { Autocomplete } from "@/app/interfaces/Autocomplete"; +import { Review } from "@/app/interfaces/Review"; +import { apiHost } from "@/constants"; + +function apiUrl(path: string): URL { + return new URL(`${apiHost}${path}`); +} + +export async function fetchAutocomplete( + query: string, +): Promise { + if (!query) return []; + const url = apiUrl(`/autocomplete`); + url.searchParams.append("q", query); + const response = await fetch(url); + if (!response.ok) { + console.error("Failed to fetch college names:", response.statusText); + return []; + } + const data = await response.json(); + return data; +} + +export async function fetchCollegeByUuid( + uuid: string, +): Promise { + if (!uuid) return null; + const url = apiUrl(`/reviews`); + url.searchParams.append("college", uuid); + const response = await fetch(url); + if (!response.ok) { + console.error("Failed to fetch review by UUID:", response.statusText); + return null; + } + const data = await response.json(); + return data; +} diff --git a/front-end/src/constants.ts b/front-end/src/constants.ts new file mode 100644 index 0000000..594c891 --- /dev/null +++ b/front-end/src/constants.ts @@ -0,0 +1 @@ +export const apiHost = process.env.API_HOST || "http://localhost:8080"; diff --git a/front-end/start.sh b/front-end/start.sh new file mode 100755 index 0000000..3ff937a --- /dev/null +++ b/front-end/start.sh @@ -0,0 +1,3 @@ +#!/bin/bash +npm i +npm run dev \ No newline at end of file