From ba08c3ef689726d91bef9818ddb3aeb4b1eaded2 Mon Sep 17 00:00:00 2001 From: solp721 Date: Tue, 12 Aug 2025 22:41:31 +0900 Subject: [PATCH 1/6] =?UTF-8?q?test=20:=20=EA=B8=B0=EC=A1=B4=20=ED=95=98?= =?UTF-8?q?=EB=93=9C=EC=BD=94=EB=94=A9=20=EB=B0=A9=EC=8B=9D=20API=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tier1-critical/01-auth-and-review.cy.ts | 109 +++++++++++------- .../03-reviews-interactions.cy.ts | 15 ++- .../04-navigation-and-univ-switch.cy.ts | 8 +- cypress/support/commands.ts | 83 +++++++++++++ 4 files changed, 162 insertions(+), 53 deletions(-) diff --git a/cypress/e2e/tier1-critical/01-auth-and-review.cy.ts b/cypress/e2e/tier1-critical/01-auth-and-review.cy.ts index 765f348..e3a4d6d 100644 --- a/cypress/e2e/tier1-critical/01-auth-and-review.cy.ts +++ b/cypress/e2e/tier1-critical/01-auth-and-review.cy.ts @@ -9,56 +9,81 @@ describe('사용자는 로그인을하고 자신이 들은 강의에 리뷰를 it('사용자는 자신이 수강한 강의를 찾고 리뷰를 작성할 수 있다.', () => { cy.uiLogin(); - cy.visit(encodeURI('/동서대학교')); - cy.contains('[data-test="lecture-card"] h3', '데이터마이닝', { timeout: 15000 }) - .scrollIntoView() - .should('be.visible') - .then(($h3) => { - cy.wrap($h3).closest('a').click({ force: true }); + cy.getMyLectures().then((response) => { + expect(response.status).to.eq(200); + expect(response.body.data).to.have.length.greaterThan(0); + + const enrolledLecture = response.body.data[0]; + const lectureName = enrolledLecture.lectureName; + + cy.visitUniversity(); + cy.clickLectureByName(lectureName); + + cy.byTest('lecture-detail').should('exist'); + cy.byTest('write-review').click(); + cy.byTest('write-review-form').should('exist'); + + cy.byTest('review-title-input').type(`${lectureName} 수업 후기`); + cy.byTest('review-content-input').type('E2E 테스트로 작성된 리뷰입니다.'); + cy.byTest('star-5').click({ force: true }); + + cy.window().then((win) => { + cy.stub(win, 'alert').as('alert'); }); - cy.byTest('lecture-detail').should('exist'); - - cy.byTest('write-review').click(); - cy.byTest('write-review-form').should('exist'); - cy.byTest('review-title-input').type('데이터마이닝 수업 후기'); - cy.byTest('review-content-input').type( - '실습 위주로 진행되어 이해가 쉬웠고, 과제량이 적당했습니다. 추천합니다.' - ); - cy.byTest('star-5').click({ force: true }); - - cy.window().then((win) => { - cy.stub(win, 'alert').as('alert'); - }); - cy.byTest('write-submit').click({ force: true }); + cy.byTest('write-submit').click({ force: true }); - cy.get('@alert').should('have.been.called'); - cy.byTest('review-list').should('exist'); - cy.byTest('review-item').should('exist'); + cy.get('@alert').should('have.been.called'); + cy.byTest('review-list').should('exist'); + cy.byTest('review-item').should('exist'); - cy.reload(); - cy.byTest('review-list').should('exist'); - cy.byTest('review-item').should('exist'); + cy.reload(); + cy.byTest('review-list').should('exist'); + cy.byTest('review-item').should('exist'); + }); }); it('사용자는 수강하지 않은 강의에 리뷰를 작성할 수 없다.', () => { cy.uiLogin(); - cy.visit(encodeURI('/동서대학교')); - cy.byTest('lecture-card').first().click(); - cy.byTest('lecture-detail').should('exist'); - - cy.byTest('write-review').click(); - cy.byTest('write-review-form').should('exist'); - cy.byTest('review-title-input').type('후기 작성 테스트'); - cy.byTest('review-content-input').type( - '수강하지 않은 강의에 대한 리뷰 작성 시도가 차단되는지 확인합니다.' - ); - cy.byTest('star-5').click({ force: true }); - cy.window().then((win) => { - cy.stub(win, 'alert').as('alert'); - }); - cy.byTest('write-submit').click({ force: true }); - cy.get('@alert').should('have.been.called'); + cy.getMyLectures().then((enrolledResponse) => { + const enrolledLectures = enrolledResponse.body.data.map( + (lecture: { lectureName: string }) => lecture.lectureName + ); + + const universityName = Cypress.env('TEST_UNIVERSITY_NAME'); + + cy.getUniversityLectures(universityName).then((allLecturesResponse) => { + const allLectures = allLecturesResponse.body.data; + + const unenrolledLecture = allLectures.find( + (lecture: { lectureName: string }) => !enrolledLectures.includes(lecture.lectureName) + ); + + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + expect(unenrolledLecture).to.not.be.undefined; + + if (!unenrolledLecture) { + throw new Error('비수강 강의를 찾을 수 없습니다'); + } + + cy.visitUniversity(); + cy.clickLectureByName(unenrolledLecture.lectureName); + + cy.byTest('lecture-detail').should('exist'); + cy.byTest('write-review').click(); + cy.byTest('write-review-form').should('exist'); + cy.byTest('review-title-input').type('권한 테스트'); + cy.byTest('review-content-input').type('수강하지 않은 강의에 대한 리뷰 작성 시도'); + cy.byTest('star-5').click({ force: true }); + + cy.window().then((win) => { + cy.stub(win, 'alert').as('alert'); + }); + cy.byTest('write-submit').click({ force: true }); + + cy.get('@alert').should('have.been.called'); + }); + }); }); }); diff --git a/cypress/e2e/tier2-experience/03-reviews-interactions.cy.ts b/cypress/e2e/tier2-experience/03-reviews-interactions.cy.ts index 656f42f..c767852 100644 --- a/cypress/e2e/tier2-experience/03-reviews-interactions.cy.ts +++ b/cypress/e2e/tier2-experience/03-reviews-interactions.cy.ts @@ -1,14 +1,13 @@ describe('사용자는 리뷰 좋아요를 누를 수 있고, 리뷰 정렬 탭을 통해 리뷰를 정렬할 수 있다.', () => { beforeEach(() => { cy.uiLogin(); - cy.visit(encodeURI('/동서대학교')); - cy.contains('[data-test="lecture-card"] h3', '소프트웨어개발실습2', { timeout: 15000 }) - .scrollIntoView() - .should('be.visible') - .then(($h3) => { - cy.wrap($h3).closest('a').click({ force: true }); - }); - cy.byTest('lecture-detail').should('exist'); + + const universityName = Cypress.env('TEST_UNIVERSITY_NAME'); + cy.getLectureWithReviews(universityName).then((lectureWithReviews) => { + cy.visitUniversity(); + cy.clickLectureByName(lectureWithReviews.lectureName); + cy.byTest('lecture-detail').should('exist'); + }); }); it('사용자는 좋아요 버튼을 통해 리뷰에 좋아요를 누를 수 있다. 한번더 누르면 좋아요가 취소된다.', () => { diff --git a/cypress/e2e/tier2-experience/04-navigation-and-univ-switch.cy.ts b/cypress/e2e/tier2-experience/04-navigation-and-univ-switch.cy.ts index 86eb86f..3caa991 100644 --- a/cypress/e2e/tier2-experience/04-navigation-and-univ-switch.cy.ts +++ b/cypress/e2e/tier2-experience/04-navigation-and-univ-switch.cy.ts @@ -1,6 +1,6 @@ describe('사용자는 강의 목록에서 상세 페이지로 이동할 수 있고, 대학 전환 드롭다운을 통해 대학을 전환할 수 있다.', () => { it('사용자는 강의 목록에서 상세 페이지로 이동할 수 있다.', () => { - cy.visit(encodeURI('/동서대학교')); + cy.visitUniversity(); cy.byTest('lecture-list').should('exist'); cy.byTest('lecture-card').click(); @@ -11,11 +11,13 @@ describe('사용자는 강의 목록에서 상세 페이지로 이동할 수 있 }); it('사용자는 대학 전환 드롭다운을 통해 대학을 전환할 수 있다.', () => { - cy.visit(encodeURI('/동서대학교')); + cy.visitUniversity(); + + const universityName = Cypress.env('TEST_UNIVERSITY_NAME'); cy.byTest('univ-switch').click({ force: true }); cy.byTest('univ-option').click({ force: true }); - cy.location('pathname').should('not.eq', encodeURI('/동서대학교')); + cy.location('pathname').should('not.eq', encodeURI(`/${universityName}`)); }); }); diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index 4ae8d3f..1d87680 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -48,6 +48,78 @@ Cypress.Commands.add('uiLogin', (userId?: string, userPw?: string) => { cy.get('header').contains('button', '로그아웃', { timeout: 10000 }).should('be.visible'); }); +Cypress.Commands.add('getMyLectures', () => { + return cy.request({ + method: 'GET', + url: `${Cypress.env('NEXT_PUBLIC_API_URL')}/class/me`, + qs: { userNumber: Cypress.env('TEST_USER_NUMBER') }, + }); +}); + +Cypress.Commands.add('getUniversityLectures', (universityName: string) => { + return cy.request({ + method: 'GET', + url: `${Cypress.env('NEXT_PUBLIC_API_URL')}/class`, + qs: { university: universityName }, + }); +}); + +Cypress.Commands.add('visitUniversity', (universityName?: string) => { + const university = universityName || Cypress.env('TEST_UNIVERSITY_NAME'); + cy.visit(encodeURI(`/${university}`)); +}); + +Cypress.Commands.add('clickLectureByName', (lectureName: string) => { + cy.contains('[data-test="lecture-card"] h3', lectureName, { timeout: 15000 }) + .scrollIntoView() + .should('be.visible') + .then(($h3) => { + cy.wrap($h3).closest('a').click({ force: true }); + }); +}); + +Cypress.Commands.add('getLectureWithReviews', (universityName: string) => { + return cy.getUniversityLectures(universityName).then((response) => { + const lectures = response.body.data as { lectureName: string; lectureId: number }[]; + + // 각 강의를 순차적으로 확인하여 리뷰가 있는 첫 번째 강의 찾기 + let currentIndex = 0; + + const checkNextLecture = (): Cypress.Chainable<{ lectureName: string; lectureId: number }> => { + if (currentIndex >= lectures.length) { + throw new Error('리뷰가 있는 강의를 찾을 수 없습니다'); + } + + const lecture = lectures[currentIndex]; + + return cy + .request({ + method: 'GET', + url: `${Cypress.env('NEXT_PUBLIC_API_URL')}/class`, + qs: { + university: universityName, + lectureId: lecture.lectureId, + }, + failOnStatusCode: false, + }) + .then((detailResponse) => { + if ( + detailResponse.status === 200 && + detailResponse.body.data && + detailResponse.body.data.reviewCount > 0 + ) { + return cy.wrap(lecture); + } else { + currentIndex++; + return checkNextLecture(); + } + }); + }; + + return checkNextLecture(); + }); +}); + declare global { // eslint-disable-next-line @typescript-eslint/no-namespace namespace Cypress { @@ -55,6 +127,17 @@ declare global { byTest(testId: string): Chainable>; dedupeByTest(testId: string): Chainable; uiLogin(userId?: string, userPw?: string): Chainable; + getMyLectures(): Chainable< + Cypress.Response<{ data: Array<{ lectureName: string; lectureId: number }> }> + >; + getUniversityLectures( + universityName: string + ): Chainable }>>; + visitUniversity(universityName?: string): Chainable; + clickLectureByName(lectureName: string): Chainable; + getLectureWithReviews( + universityName: string + ): Chainable<{ lectureName: string; lectureId: number }>; } } } From 751f008c4be6ef37e56b45b9acd4e6eb8ed35f27 Mon Sep 17 00:00:00 2001 From: solp721 Date: Tue, 12 Aug 2025 22:41:57 +0900 Subject: [PATCH 2/6] =?UTF-8?q?chore=20:=20=ED=97=A4=EB=8D=94=20=EC=BA=90?= =?UTF-8?q?=EC=8B=B1=20=EC=98=B5=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- next.config.ts | 22 ++++++++++++++++++++++ vercel.json | 31 +++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 vercel.json diff --git a/next.config.ts b/next.config.ts index 70bac17..d30aad0 100644 --- a/next.config.ts +++ b/next.config.ts @@ -2,6 +2,28 @@ import type { NextConfig } from 'next'; import path from 'path'; const nextConfig: NextConfig = { + async headers() { + return [ + { + source: '/(.*)', + headers: [ + { + key: 'Cache-Control', + value: 'public, max-age=300, s-maxage=86400', + }, + ], + }, + { + source: '/_next/static/(.*)', + headers: [ + { + key: 'Cache-Control', + value: 'public, max-age=31536000, immutable', + }, + ], + }, + ]; + }, webpack: (config) => { config.resolve = config.resolve || {}; config.resolve.alias = { diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..1ba6678 --- /dev/null +++ b/vercel.json @@ -0,0 +1,31 @@ +{ + "headers": [ + { + "source": "/(.*)", + "headers": [ + { + "key": "Cache-Control", + "value": "public, max-age=300, s-maxage=86400" + } + ] + }, + { + "source": "/api/(.*)", + "headers": [ + { + "key": "Cache-Control", + "value": "public, max-age=60, s-maxage=300" + } + ] + }, + { + "source": "/_next/static/(.*)", + "headers": [ + { + "key": "Cache-Control", + "value": "public, max-age=31536000, immutable" + } + ] + } + ] +} From 7b41ec1e95d3e0c76aae188d8b03030095a56fed Mon Sep 17 00:00:00 2001 From: solp721 Date: Tue, 12 Aug 2025 22:42:25 +0900 Subject: [PATCH 3/6] =?UTF-8?q?refactor=20:=20=ED=95=A8=EC=88=98=20?= =?UTF-8?q?=EB=B0=8F=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EB=A9=94=EB=AA=A8?= =?UTF-8?q?=EC=9D=B4=EC=A0=9C=EC=9D=B4=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/common/modal/Modal.tsx | 85 ++++++++++--------- .../starRating/InteractiveStarRating.tsx | 63 +++++++++----- components/common/starRating/StarRating.tsx | 24 ++++-- components/ui/button/Button.tsx | 11 ++- .../server/components/LectureCardHybrid.tsx | 10 ++- .../lecture/server/components/LectureInfo.tsx | 8 +- .../client/components/DeleteReviewButton.tsx | 21 ++--- .../client/components/ReviewActionButton.tsx | 7 ++ 8 files changed, 134 insertions(+), 95 deletions(-) diff --git a/components/common/modal/Modal.tsx b/components/common/modal/Modal.tsx index 76d552a..e185474 100644 --- a/components/common/modal/Modal.tsx +++ b/components/common/modal/Modal.tsx @@ -17,57 +17,58 @@ export interface ModalRef { close: () => void; } -const Modal = forwardRef( - ({ title, children, size = 'medium', onClose, open }, ref) => { - const [internalOpen, setInternalOpen] = useState(false); +const Modal = forwardRef(function Modal( + { title, children, size = 'medium', onClose, open }, + ref +) { + const [internalOpen, setInternalOpen] = useState(false); - useImperativeHandle(ref, () => ({ - open: () => setInternalOpen(true), - close: () => setInternalOpen(false), - })); + useImperativeHandle(ref, () => ({ + open: () => setInternalOpen(true), + close: () => setInternalOpen(false), + })); - const handleClose = useCallback(() => { - setInternalOpen(false); - onClose?.(); - }, [onClose]); + const handleClose = useCallback(() => { + setInternalOpen(false); + onClose?.(); + }, [onClose]); - useEffect(() => { - const handleEscape = (e: KeyboardEvent) => { - if (e.key === 'Escape') { - handleClose(); - } - }; - - const isOpen = typeof open === 'boolean' ? open : internalOpen; - if (isOpen) { - document.addEventListener('keydown', handleEscape); - document.body.style.overflow = 'hidden'; + useEffect(() => { + const handleEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + handleClose(); } - - return () => { - document.removeEventListener('keydown', handleEscape); - document.body.style.overflow = 'unset'; - }; - }, [open, internalOpen, handleClose]); + }; const isOpen = typeof open === 'boolean' ? open : internalOpen; - if (!isOpen) return null; + if (isOpen) { + document.addEventListener('keydown', handleEscape); + document.body.style.overflow = 'hidden'; + } + + return () => { + document.removeEventListener('keydown', handleEscape); + document.body.style.overflow = 'unset'; + }; + }, [open, internalOpen, handleClose]); + + const isOpen = typeof open === 'boolean' ? open : internalOpen; + if (!isOpen) return null; - return ( -
-
e.stopPropagation()}> -
- {title &&

{title}

} - -
-
{children}
+ return ( +
+
e.stopPropagation()}> +
+ {title &&

{title}

} +
+
{children}
- ); - } -); +
+ ); +}); Modal.displayName = 'Modal'; diff --git a/components/common/starRating/InteractiveStarRating.tsx b/components/common/starRating/InteractiveStarRating.tsx index 0bac046..521974a 100644 --- a/components/common/starRating/InteractiveStarRating.tsx +++ b/components/common/starRating/InteractiveStarRating.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; +import { useState, memo, useCallback, useMemo } from 'react'; import { Star } from 'lucide-react'; import styles from './InteractiveStarRating.module.css'; @@ -11,7 +11,7 @@ interface InteractiveStarRatingProps { disabled?: boolean; } -export default function InteractiveStarRating({ +const InteractiveStarRating = memo(function InteractiveStarRating({ rating, onRatingChange, size = 'medium', @@ -19,36 +19,51 @@ export default function InteractiveStarRating({ }: InteractiveStarRatingProps) { const [hoverRating, setHoverRating] = useState(0); - const handleMouseEnter = (star: number, event: React.MouseEvent) => { - if (!disabled) { + const calculateRatingFromEvent = useCallback( + (star: number, event: React.MouseEvent) => { const rect = event.currentTarget.getBoundingClientRect(); const x = event.clientX - rect.left; const width = rect.width; const isHalf = x < width / 2; - const newRating = isHalf ? star - 0.5 : star; - setHoverRating(newRating); - } - }; + return isHalf ? star - 0.5 : star; + }, + [] + ); + + const handleMouseEnter = useCallback( + (star: number, event: React.MouseEvent) => { + if (!disabled) { + const newRating = calculateRatingFromEvent(star, event); + setHoverRating(newRating); + } + }, + [disabled, calculateRatingFromEvent] + ); - const handleMouseLeave = () => { + const handleMouseLeave = useCallback(() => { if (!disabled) { setHoverRating(0); } - }; + }, [disabled]); - const handleClick = (star: number, event: React.MouseEvent) => { - if (!disabled) { - const rect = event.currentTarget.getBoundingClientRect(); - const x = event.clientX - rect.left; - const width = rect.width; - const isHalf = x < width / 2; - const newRating = isHalf ? star - 0.5 : star; - onRatingChange(newRating); - } - }; + const handleClick = useCallback( + (star: number, event: React.MouseEvent) => { + if (!disabled) { + const newRating = calculateRatingFromEvent(star, event); + onRatingChange(newRating); + } + }, + [disabled, onRatingChange, calculateRatingFromEvent] + ); const displayRating = hoverRating || rating; + const starSize = useMemo(() => { + return size === 'small' ? 20 : size === 'medium' ? 28 : 32; + }, [size]); + + const stars = useMemo(() => [1, 2, 3, 4, 5], []); + return (
- {[1, 2, 3, 4, 5].map((star) => ( + {stars.map((star) => ( @@ -83,4 +98,6 @@ export default function InteractiveStarRating({
); -} +}); + +export default InteractiveStarRating; diff --git a/components/common/starRating/StarRating.tsx b/components/common/starRating/StarRating.tsx index a051a19..6392ca5 100644 --- a/components/common/starRating/StarRating.tsx +++ b/components/common/starRating/StarRating.tsx @@ -1,3 +1,4 @@ +import { memo, useMemo } from 'react'; import styles from './StarRating.module.css'; interface StarRatingProps { @@ -6,16 +7,21 @@ interface StarRatingProps { size?: 'small' | 'medium' | 'large' | 'veryLarge'; } -export default function StarRating({ +const StarRating = memo(function StarRating({ rating, showRatingText = true, size = 'medium', }: StarRatingProps) { - const fullStars = Math.floor(rating); - const hasHalfStar = rating % 1 !== 0; - const emptyStars = 5 - Math.ceil(rating); + const starData = useMemo(() => { + const fullStars = Math.floor(rating); + const hasHalfStar = rating % 1 !== 0; + const emptyStars = 5 - Math.ceil(rating); - const renderStars = () => { + return { fullStars, hasHalfStar, emptyStars }; + }, [rating]); + + const renderStars = useMemo(() => { + const { fullStars, hasHalfStar, emptyStars } = starData; const stars = []; for (let i = 0; i < fullStars; i++) { @@ -43,7 +49,7 @@ export default function StarRating({ } return stars; - }; + }, [starData, size]); if (rating === 0) { return 평점 없음; @@ -51,10 +57,12 @@ export default function StarRating({ return (
-
{renderStars()}
+
{renderStars}
{showRatingText && ( {rating.toFixed(1)} )}
); -} +}); + +export default StarRating; diff --git a/components/ui/button/Button.tsx b/components/ui/button/Button.tsx index 8d6760e..8439a82 100644 --- a/components/ui/button/Button.tsx +++ b/components/ui/button/Button.tsx @@ -1,5 +1,6 @@ 'use client'; +import { memo, useMemo } from 'react'; import styles from './Button.module.css'; interface ButtonProps extends React.ButtonHTMLAttributes { @@ -7,17 +8,21 @@ interface ButtonProps extends React.ButtonHTMLAttributes { className?: string; } -export default function Button({ +const Button = memo(function Button({ children, variant = 'default', className = '', ...rest }: ButtonProps) { - const buttonClass = `${styles.button} ${styles[variant]} ${className}`.trim(); + const buttonClass = useMemo(() => { + return `${styles.button} ${styles[variant]} ${className}`.trim(); + }, [variant, className]); return ( ); -} +}); + +export default Button; diff --git a/domains/lecture/server/components/LectureCardHybrid.tsx b/domains/lecture/server/components/LectureCardHybrid.tsx index c69eb9d..3ca6123 100644 --- a/domains/lecture/server/components/LectureCardHybrid.tsx +++ b/domains/lecture/server/components/LectureCardHybrid.tsx @@ -1,3 +1,4 @@ +import { memo } from 'react'; import { StaticLectureData } from '../../shared/types'; import { BookOpen, Tag, Star } from 'lucide-react'; import { DynamicRating } from '@/domains/lecture'; @@ -8,7 +9,10 @@ interface LectureCardHybridProps { universityName: string; } -export default function LectureCardHybrid({ staticData, universityName }: LectureCardHybridProps) { +const LectureCardHybrid = memo(function LectureCardHybrid({ + staticData, + universityName, +}: LectureCardHybridProps) { return (
@@ -61,4 +65,6 @@ export default function LectureCardHybrid({ staticData, universityName }: Lectur
); -} +}); + +export default LectureCardHybrid; diff --git a/domains/lecture/server/components/LectureInfo.tsx b/domains/lecture/server/components/LectureInfo.tsx index 9607296..58f8a56 100644 --- a/domains/lecture/server/components/LectureInfo.tsx +++ b/domains/lecture/server/components/LectureInfo.tsx @@ -39,8 +39,12 @@ export default function LectureInfo({
-

{lecture.lectureName}

-
{lecture.professor}
+

+ {lecture.lectureName} +

+
+ {lecture.professor} +
diff --git a/domains/mypage/client/components/DeleteReviewButton.tsx b/domains/mypage/client/components/DeleteReviewButton.tsx index 7a5ef4e..3d8fafa 100644 --- a/domains/mypage/client/components/DeleteReviewButton.tsx +++ b/domains/mypage/client/components/DeleteReviewButton.tsx @@ -2,10 +2,14 @@ import { deleteReview } from '@/lib'; import { revalidateReviewsPage, revalidateLecturesPage } from '@/app/mypage/actions'; +import { useQueryClient } from '@tanstack/react-query'; +import { invalidateReviewCache } from '@/utils'; import type { ReviewCardProps } from '../../shared/types'; import styles from '../../styles/ReviewCard.module.css'; export function DeleteReviewButton({ review, userNumber }: ReviewCardProps) { + const queryClient = useQueryClient(); + const handleDelete = async () => { if (confirm('정말로 이 리뷰를 삭제하시겠습니까?')) { try { @@ -14,22 +18,9 @@ export function DeleteReviewButton({ review, userNumber }: ReviewCardProps) { userNumber, }); - await Promise.all([revalidateReviewsPage(), revalidateLecturesPage()]); + invalidateReviewCache(queryClient, String(review.lecture.lectureId)); - try { - await fetch('/api/revalidate', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - action: 'review_deleted', - universityName: review.lecture.university, - lectureId: String(review.lecture.lectureId), - userNumber, - }), - }); - } catch (error) { - console.error('리뷰 삭제 태그 재검증 실패', error); - } + await Promise.all([revalidateReviewsPage(), revalidateLecturesPage()]); } catch (error) { alert('리뷰 삭제에 실패했습니다.'); } diff --git a/domains/mypage/client/components/ReviewActionButton.tsx b/domains/mypage/client/components/ReviewActionButton.tsx index f845f78..3fe5d2c 100644 --- a/domains/mypage/client/components/ReviewActionButton.tsx +++ b/domains/mypage/client/components/ReviewActionButton.tsx @@ -2,13 +2,16 @@ import { useRef, useState, useEffect } from 'react'; import { createPortal } from 'react-dom'; +import { useQueryClient } from '@tanstack/react-query'; import Link from 'next/link'; import { WriteReviewModal, type WriteReviewModalRef } from '@/domains/review'; import { revalidateLecturesPage, revalidateReviewsPage } from '@/app/mypage/actions'; +import { invalidateReviewCache } from '@/utils'; import type { ReviewActionButtonProps } from '../../shared/types'; import styles from '../../styles/LectureCard.module.css'; export function ReviewActionButton({ lecture }: ReviewActionButtonProps) { + const queryClient = useQueryClient(); const [selectedLecture, setSelectedLecture] = useState<{ id: string; name: string; @@ -34,6 +37,10 @@ export function ReviewActionButton({ lecture }: ReviewActionButtonProps) { const handleReviewSubmitSuccess = async () => { setSelectedLecture(null); + + const lectureId = lecture.classNumber.toString(); + invalidateReviewCache(queryClient, lectureId); + await Promise.all([revalidateLecturesPage(), revalidateReviewsPage()]); }; From 17146c470635eb0a8ed08ed2c9decd591d402def Mon Sep 17 00:00:00 2001 From: solp721 Date: Tue, 12 Aug 2025 22:43:04 +0900 Subject: [PATCH 4/6] =?UTF-8?q?refactor=20:=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EC=9E=AC=EA=B2=80=EC=A6=9D=20=EB=A1=9C=EC=A7=81=20=EB=AA=A8?= =?UTF-8?q?=EB=93=88=ED=99=94=20=EB=B0=8F=20=EC=82=AC=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- domains/review/client/hooks/useLikeReview.ts | 6 ++-- domains/review/client/hooks/usePatchReview.ts | 9 ++---- domains/review/client/hooks/usePostReview.ts | 7 ++--- utils/index.ts | 1 + utils/invalidateReviewCache.ts | 30 +++++++++++++++++++ 5 files changed, 37 insertions(+), 16 deletions(-) create mode 100644 utils/invalidateReviewCache.ts diff --git a/domains/review/client/hooks/useLikeReview.ts b/domains/review/client/hooks/useLikeReview.ts index b3d979f..48846b3 100644 --- a/domains/review/client/hooks/useLikeReview.ts +++ b/domains/review/client/hooks/useLikeReview.ts @@ -2,6 +2,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { likeReview } from '@/lib/review'; +import { invalidateReviewCache } from '@/utils'; import type { LikeReviewParams, LikeReviewResult } from '../../shared/types/api'; export function useLikeReview() { @@ -10,10 +11,7 @@ export function useLikeReview() { return useMutation({ mutationFn: likeReview, onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: ['reviews'], - exact: false, - }); + invalidateReviewCache(queryClient); }, }); } diff --git a/domains/review/client/hooks/usePatchReview.ts b/domains/review/client/hooks/usePatchReview.ts index db71226..2715032 100644 --- a/domains/review/client/hooks/usePatchReview.ts +++ b/domains/review/client/hooks/usePatchReview.ts @@ -2,6 +2,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { patchReview } from '@/lib/review'; +import { invalidateReviewCache } from '@/utils'; import type { PostReviewRequest, PostReviewResult } from '../../shared/types/api'; export function usePatchReview(lectureId: string) { @@ -10,13 +11,7 @@ export function usePatchReview(lectureId: string) { return useMutation({ mutationFn: patchReview, onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: ['reviews', lectureId], - exact: false, - }); - queryClient.invalidateQueries({ queryKey: ['lecture-rating'] }); - queryClient.invalidateQueries({ queryKey: ['my-reviews'] }); - queryClient.invalidateQueries({ queryKey: ['my-lectures'] }); + invalidateReviewCache(queryClient, lectureId); }, }); } diff --git a/domains/review/client/hooks/usePostReview.ts b/domains/review/client/hooks/usePostReview.ts index ce070a3..0d5fb2d 100644 --- a/domains/review/client/hooks/usePostReview.ts +++ b/domains/review/client/hooks/usePostReview.ts @@ -2,6 +2,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { postReview } from '@/lib/review'; +import { invalidateReviewCache } from '@/utils'; import type { PostReviewRequest, PostReviewResult } from '../../shared/types/api'; export function usePostReview(lectureId: string) { @@ -10,11 +11,7 @@ export function usePostReview(lectureId: string) { return useMutation({ mutationFn: postReview, onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: ['reviews', lectureId], - exact: false, - }); - queryClient.invalidateQueries({ queryKey: ['lecture-rating'] }); + invalidateReviewCache(queryClient, lectureId); }, }); } diff --git a/utils/index.ts b/utils/index.ts index f19ecf1..f2b0ce2 100644 --- a/utils/index.ts +++ b/utils/index.ts @@ -3,3 +3,4 @@ export * from './universityUtils'; export * from './queryClient'; export * from './queryPrefetch'; export * from './tokenManager'; +export * from './invalidateReviewCache'; diff --git a/utils/invalidateReviewCache.ts b/utils/invalidateReviewCache.ts new file mode 100644 index 0000000..d9c445d --- /dev/null +++ b/utils/invalidateReviewCache.ts @@ -0,0 +1,30 @@ +import type { QueryClient } from '@tanstack/react-query'; + +export function invalidateReviewCache(queryClient: QueryClient, lectureId?: string) { + if (lectureId) { + queryClient.invalidateQueries({ + queryKey: ['reviews', lectureId], + exact: false, + }); + } + + queryClient.invalidateQueries({ + queryKey: ['reviews'], + exact: false, + }); + + queryClient.invalidateQueries({ + queryKey: ['lecture-rating'], + exact: false, + }); + + queryClient.invalidateQueries({ + queryKey: ['my-reviews'], + exact: false, + }); + + queryClient.invalidateQueries({ + queryKey: ['my-lectures'], + exact: false, + }); +} From 025328cc9c2d14d59a207916c4be39c25064ce05 Mon Sep 17 00:00:00 2001 From: solp721 Date: Tue, 12 Aug 2025 23:38:08 +0900 Subject: [PATCH 5/6] =?UTF-8?q?feat=20:=20=EA=B0=81=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83=20=EB=B3=84=20?= =?UTF-8?q?=EB=A9=94=ED=83=80=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=84=B8?= =?UTF-8?q?=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/[universityName]/[lectureId]/page.tsx | 20 +++++++++ app/[universityName]/page.tsx | 20 +++++++++ app/layout.tsx | 53 ++++++++++++++++++++++- app/mypage/curriculum/page.tsx | 10 +++++ app/mypage/lectures/page.tsx | 10 +++++ app/mypage/profile/page.tsx | 10 +++++ app/mypage/reviews/page.tsx | 10 +++++ app/page.tsx | 11 +++++ app/sitemap.ts | 44 +++++++++++++++++++ public/manifest.json | 18 ++++++++ public/robots.txt | 9 ++++ 11 files changed, 213 insertions(+), 2 deletions(-) create mode 100644 app/sitemap.ts create mode 100644 public/manifest.json create mode 100644 public/robots.txt diff --git a/app/[universityName]/[lectureId]/page.tsx b/app/[universityName]/[lectureId]/page.tsx index 7324438..279713d 100644 --- a/app/[universityName]/[lectureId]/page.tsx +++ b/app/[universityName]/[lectureId]/page.tsx @@ -1,3 +1,4 @@ +import type { Metadata } from 'next'; import { ReviewCardListSkeleton, ReviewListServer } from '@/domains/review'; import { LectureInfoSkeleton, LectureInfoServer } from '@/domains/lecture'; import { Suspense } from 'react'; @@ -7,6 +8,25 @@ import { universityNames } from '@/constants'; import { getLectureListStatic } from '@/lib'; import styles from '@/styles/global.module.css'; +export async function generateMetadata({ + params, +}: { + params: Promise<{ universityName: string; lectureId: string }>; +}): Promise { + const { universityName, lectureId } = await params; + const decodedUniversity = decodeURIComponent(universityName); + + return { + title: `강의 상세정보`, + description: `${decodedUniversity} 강의의 상세정보와 학생 리뷰를 확인하세요. 실제 수강생들의 생생한 후기!`, + openGraph: { + title: `강의 상세정보 | AllClass`, + description: `${decodedUniversity} 강의의 상세정보와 학생 리뷰를 확인하세요.`, + }, + keywords: [decodedUniversity, '강의상세', '강의리뷰', '수강후기', '평점', 'AllClass'], + }; +} + export async function generateStaticParams() { const allParams = await Promise.allSettled( universityNames.map(async (universityName) => { diff --git a/app/[universityName]/page.tsx b/app/[universityName]/page.tsx index 0707726..b301543 100644 --- a/app/[universityName]/page.tsx +++ b/app/[universityName]/page.tsx @@ -1,3 +1,4 @@ +import type { Metadata } from 'next'; import { universityNames } from '@/constants'; import { getLectureListStatic } from '@/lib/lecture'; import styles from '@/styles/global.module.css'; @@ -12,6 +13,25 @@ export async function generateStaticParams() { return universityNames.map((name) => ({ universityName: encodeURIComponent(name) })); } +export async function generateMetadata({ + params, +}: { + params: Promise<{ universityName: string }>; +}): Promise { + const { universityName } = await params; + const decoded = decodeURIComponent(universityName); + + return { + title: `${decoded} 강의정보`, + description: `${decoded}의 모든 강의정보와 학생 리뷰를 확인하세요. 평점, 수강후기, 교수님 정보까지!`, + openGraph: { + title: `${decoded} 강의정보 | AllClass`, + description: `${decoded}의 모든 강의정보와 학생 리뷰를 확인하세요.`, + }, + keywords: [decoded, '강의정보', '수강후기', '평점', '강의리뷰', 'AllClass'], + }; +} + export default async function UniversityPage({ params, }: { diff --git a/app/layout.tsx b/app/layout.tsx index ab7d0ce..40d9a0c 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -15,8 +15,57 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: 'AllClass', - description: 'AllClass를 통해 쉽고 빠르게 부산 소속 대학교 강의정보를 확인해보세요.', + title: { + default: 'AllClass - 부산 대학교 강의정보 플랫폼', + template: '%s | AllClass', + }, + description: + 'AllClass를 통해 쉽고 빠르게 부산 소속 대학교 강의정보를 확인해보세요. 강의 리뷰, 수강신청 가이드, 학점 관리까지 한 번에!', + keywords: [ + '부산대학교', + '강의정보', + '수강신청', + '강의리뷰', + '대학교', + '부산', + '학점관리', + 'AllClass', + ], + authors: [{ name: 'AllClass Team' }], + creator: 'AllClass', + publisher: 'AllClass', + robots: { + index: true, + follow: true, + }, + openGraph: { + type: 'website', + locale: 'ko_KR', + url: 'https://all-class.vercel.app', + title: 'AllClass - 부산 대학교 강의정보 플랫폼', + description: 'AllClass를 통해 쉽고 빠르게 부산 소속 대학교 강의정보를 확인해보세요.', + siteName: 'AllClass', + images: [ + { + url: '/assets/logo.svg', + width: 1200, + height: 630, + alt: 'AllClass 로고', + }, + ], + }, + twitter: { + card: 'summary_large_image', + title: 'AllClass - 부산 대학교 강의정보 플랫폼', + description: 'AllClass를 통해 쉽고 빠르게 부산 소속 대학교 강의정보를 확인해보세요.', + images: ['/assets/logo.svg'], + }, + icons: { + icon: '/assets/logo.svg', + shortcut: '/assets/logo.svg', + apple: '/assets/logo.svg', + }, + manifest: '/manifest.json', }; export default function RootLayout({ diff --git a/app/mypage/curriculum/page.tsx b/app/mypage/curriculum/page.tsx index 3c25dfe..4e25b4e 100644 --- a/app/mypage/curriculum/page.tsx +++ b/app/mypage/curriculum/page.tsx @@ -1,5 +1,15 @@ +import type { Metadata } from 'next'; import { DashboardLayout } from '@/components/layout'; +export const metadata: Metadata = { + title: '커리큘럼', + description: '내 커리큘럼 정보를 확인하고 관리하세요.', + robots: { + index: false, + follow: false, + }, +}; + export default function CurriculumPage() { return ( diff --git a/app/mypage/lectures/page.tsx b/app/mypage/lectures/page.tsx index 72d9de6..b17a795 100644 --- a/app/mypage/lectures/page.tsx +++ b/app/mypage/lectures/page.tsx @@ -1,8 +1,18 @@ +import type { Metadata } from 'next'; import { Suspense } from 'react'; import { DashboardLayout } from '@/components/layout'; import { LoadingSpinner } from '@/components/common/loading/LoadingSpinner'; import { MyLectureListServer } from '@/domains/mypage/server/components/MyLectureListServer'; +export const metadata: Metadata = { + title: '수강한 강의', + description: '내가 수강한 강의 목록을 확인하세요.', + robots: { + index: false, + follow: false, + }, +}; + export default function LecturesPage() { return ( diff --git a/app/mypage/profile/page.tsx b/app/mypage/profile/page.tsx index 37ac161..d5d647c 100644 --- a/app/mypage/profile/page.tsx +++ b/app/mypage/profile/page.tsx @@ -1,5 +1,15 @@ +import type { Metadata } from 'next'; import { DashboardLayout } from '@/components/layout'; +export const metadata: Metadata = { + title: '내 정보', + description: '프로필 정보를 관리하고 개인정보를 업데이트하세요.', + robots: { + index: false, + follow: false, + }, +}; + export default function ProfilePage() { return ( diff --git a/app/mypage/reviews/page.tsx b/app/mypage/reviews/page.tsx index 98b4fd3..401e96f 100644 --- a/app/mypage/reviews/page.tsx +++ b/app/mypage/reviews/page.tsx @@ -1,8 +1,18 @@ +import type { Metadata } from 'next'; import { Suspense } from 'react'; import { DashboardLayout } from '@/components/layout'; import { LoadingSpinner } from '@/components/common/loading/LoadingSpinner'; import { MyReviewListServer } from '@/domains/mypage/server/components/MyReviewListServer'; +export const metadata: Metadata = { + title: '리뷰 관리', + description: '내가 작성한 리뷰를 관리하세요.', + robots: { + index: false, + follow: false, + }, +}; + export default function ReviewsPage() { return ( diff --git a/app/page.tsx b/app/page.tsx index 338f72e..a77bd3f 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,6 +1,17 @@ +import type { Metadata } from 'next'; import { Header } from '@/components/ui'; import { LoginTriggerWrapper } from '@/components/common'; +export const metadata: Metadata = { + title: '홈', + description: + 'AllClass에서 부산 대학교 강의정보를 한 눈에 확인하세요. 강의 리뷰, 평점, 수강신청 정보까지!', + openGraph: { + title: 'AllClass - 부산 대학교 강의정보 플랫폼', + description: 'AllClass에서 부산 대학교 강의정보를 한 눈에 확인하세요.', + }, +}; + export default async function Home({ searchParams, }: { diff --git a/app/sitemap.ts b/app/sitemap.ts new file mode 100644 index 0000000..f056e08 --- /dev/null +++ b/app/sitemap.ts @@ -0,0 +1,44 @@ +import { MetadataRoute } from 'next'; +import { universityNames } from '@/constants'; +import { getLectureListStatic } from '@/lib/lecture'; + +export default async function sitemap(): Promise { + const baseUrl = 'https://all-class.vercel.app'; + + const staticPages = [ + { + url: baseUrl, + lastModified: new Date(), + changeFrequency: 'daily' as const, + priority: 1, + }, + ]; + + const universityPages = universityNames.map((universityName) => ({ + url: `${baseUrl}/${encodeURIComponent(universityName)}`, + lastModified: new Date(), + changeFrequency: 'daily' as const, + priority: 0.8, + })); + + const lecturePages: MetadataRoute.Sitemap = []; + + for (const universityName of universityNames) { + try { + const lectures = await getLectureListStatic(universityName); + if (lectures.success && lectures.lectures) { + const pages = lectures.lectures.map((lecture) => ({ + url: `${baseUrl}/${encodeURIComponent(universityName)}/${lecture.lectureId}`, + lastModified: new Date(), + changeFrequency: 'weekly' as const, + priority: 0.7, + })); + lecturePages.push(...pages); + } + } catch (error) { + console.error(`Error generating sitemap for ${universityName}:`, error); + } + } + + return [...staticPages, ...universityPages, ...lecturePages]; +} diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 0000000..2dfa460 --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,18 @@ +{ + "name": "AllClass - 부산 대학교 강의정보 플랫폼", + "short_name": "AllClass", + "description": "부산 소속 대학교 강의정보를 확인하고 리뷰를 공유하세요", + "start_url": "/", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#000000", + "icons": [ + { + "src": "/assets/logo.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any maskable" + } + ], + "categories": ["education", "productivity"] +} diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..587b51f --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,9 @@ +User-agent: * +Allow: / + +# 개인정보 관련 페이지는 크롤링 차단 +Disallow: /mypage/ +Disallow: /api/ + +# Sitemap 위치 +Sitemap: https://all-class.vercel.app/sitemap.xml \ No newline at end of file From f22f7508cf1afcaaee95be874003850fbeb99119 Mon Sep 17 00:00:00 2001 From: solp721 Date: Tue, 12 Aug 2025 23:41:30 +0900 Subject: [PATCH 6/6] =?UTF-8?q?fix=20:=20=EB=A9=94=ED=83=80=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=ED=83=80=EC=9D=B4=ED=8B=80=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/layout.tsx | 6 +++--- app/page.tsx | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/app/layout.tsx b/app/layout.tsx index 40d9a0c..7f290d2 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -16,7 +16,7 @@ const geistMono = Geist_Mono({ export const metadata: Metadata = { title: { - default: 'AllClass - 부산 대학교 강의정보 플랫폼', + default: 'AllClass - 부산소속 대학교 강의정보 플랫폼', template: '%s | AllClass', }, description: @@ -42,7 +42,7 @@ export const metadata: Metadata = { type: 'website', locale: 'ko_KR', url: 'https://all-class.vercel.app', - title: 'AllClass - 부산 대학교 강의정보 플랫폼', + title: 'AllClass - 부산소속 대학교 강의정보 플랫폼', description: 'AllClass를 통해 쉽고 빠르게 부산 소속 대학교 강의정보를 확인해보세요.', siteName: 'AllClass', images: [ @@ -56,7 +56,7 @@ export const metadata: Metadata = { }, twitter: { card: 'summary_large_image', - title: 'AllClass - 부산 대학교 강의정보 플랫폼', + title: 'AllClass - 부산소속 대학교 강의정보 플랫폼', description: 'AllClass를 통해 쉽고 빠르게 부산 소속 대학교 강의정보를 확인해보세요.', images: ['/assets/logo.svg'], }, diff --git a/app/page.tsx b/app/page.tsx index a77bd3f..31b5c46 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -3,12 +3,12 @@ import { Header } from '@/components/ui'; import { LoginTriggerWrapper } from '@/components/common'; export const metadata: Metadata = { - title: '홈', + title: 'AllClass', description: - 'AllClass에서 부산 대학교 강의정보를 한 눈에 확인하세요. 강의 리뷰, 평점, 수강신청 정보까지!', + 'AllClass에서 부산소속 대학교 강의정보를 한 눈에 확인하세요. 강의 리뷰, 평점, 수강신청 정보까지!', openGraph: { - title: 'AllClass - 부산 대학교 강의정보 플랫폼', - description: 'AllClass에서 부산 대학교 강의정보를 한 눈에 확인하세요.', + title: 'AllClass - 부산소속 대학교 강의정보 플랫폼', + description: 'AllClass에서 부산소속 대학교 강의정보를 한 눈에 확인하세요.', }, };