diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..56e500a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,91 @@ +name: AllClass CI +run-name: AllClass CI β€’ ${{ github.ref_name }} β€’ by ${{ github.actor }} + +on: + push: + branches: + - main + - dev + - feat/** + pull_request: + branches: + - main + - dev + - feat/** + +jobs: + lint_build: + name: "πŸ”§ Lint & Build" + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: "========== πŸ”§ Lint & Build β€’ START ==========" + run: echo "========== πŸ”§ Lint & Build β€’ START ==========" + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: yarn + + - name: Install dependencies + run: yarn install --frozen-lockfile + + - name: Lint + run: yarn lint + + - name: Build + run: yarn build + env: + NEXT_TELEMETRY_DISABLED: 1 + NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }} + + - name: "========== πŸ”§ Lint & Build β€’ END ==========" + if: always() + env: + JOB_STATUS: ${{ job.status }} + run: | + echo "========== πŸ”§ Lint & Build β€’ END (status: ${JOB_STATUS}) ==========" + + e2e: + name: "πŸ§ͺ AllClass E2E Test" + needs: lint_build + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: "========== πŸ§ͺ AllClass E2E β€’ START ==========" + run: echo "========== πŸ§ͺ AllClass E2E β€’ START ==========" + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: yarn + + - name: Install dependencies + run: yarn install --frozen-lockfile + + - name: Cypress run + uses: cypress-io/github-action@v6 + with: + build: yarn build + start: yarn start + wait-on: 'http://localhost:3000' + browser: chrome + env: + CI: true + NEXT_TELEMETRY_DISABLED: 1 + NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }} + CYPRESS_TEST_USER_ID: ${{ secrets.CYPRESS_TEST_USER_ID }} + CYPRESS_TEST_USER_PW: ${{ secrets.CYPRESS_TEST_USER_PW }} + + - name: "========== πŸ§ͺ AllClass E2E β€’ END ==========" + if: always() + env: + JOB_STATUS: ${{ job.status }} + run: | + echo "========== πŸ§ͺ AllClass E2E β€’ END (status: ${JOB_STATUS}) ==========" \ No newline at end of file diff --git a/.gitignore b/.gitignore index 5ef6a52..10d5412 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,8 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* +# cypress local secrets +cypress.env.json # vercel .vercel diff --git a/app/[universityName]/[lectureId]/page.tsx b/app/[universityName]/[lectureId]/page.tsx index c1882a4..7324438 100644 --- a/app/[universityName]/[lectureId]/page.tsx +++ b/app/[universityName]/[lectureId]/page.tsx @@ -1,13 +1,11 @@ -import { ReviewCardListSkeleton } from '@/domains/review'; -import { LectureInfoSkeleton } from '@/domains/lecture'; -import { LectureInfoServer } from '@/domains/lecture/server/components/LectureInfoServer'; -import { ReviewListServer } from '@/domains/review/server/components/ReviewListServer'; +import { ReviewCardListSkeleton, ReviewListServer } from '@/domains/review'; +import { LectureInfoSkeleton, LectureInfoServer } from '@/domains/lecture'; import { Suspense } from 'react'; -import styles from '@/styles/global.module.css'; import { dehydrate, HydrationBoundary } from '@tanstack/react-query'; import { createQueryClient, prefetchLectureRating, prefetchAllSortOptions } from '@/utils'; import { universityNames } from '@/constants'; import { getLectureListStatic } from '@/lib'; +import styles from '@/styles/global.module.css'; export async function generateStaticParams() { const allParams = await Promise.allSettled( diff --git a/app/api/revalidate/route.ts b/app/api/revalidate/route.ts index f5e8ac0..4012046 100644 --- a/app/api/revalidate/route.ts +++ b/app/api/revalidate/route.ts @@ -3,7 +3,7 @@ import { NextRequest, NextResponse } from 'next/server'; export async function POST(request: NextRequest) { try { - const { universityName, action } = await request.json(); + const { universityName, action, lectureId, userNumber } = await request.json(); if (!universityName) { return NextResponse.json({ message: 'University name is required' }, { status: 400 }); @@ -14,6 +14,23 @@ export async function POST(request: NextRequest) { case 'lecture_removed': case 'lecture_modified': await revalidateTag(`lectures-${universityName}`); + if (lectureId) { + await revalidateTag(`lecture-info-${universityName}-${lectureId}`); + await revalidateTag(`reviews-${lectureId}`); + } + break; + + case 'review_added': + case 'review_updated': + case 'review_deleted': + if (lectureId) { + await revalidateTag(`reviews-${lectureId}`); + await revalidateTag(`lecture-info-${universityName}-${lectureId}`); + } + if (userNumber) { + await revalidateTag(`my-reviews-${userNumber}`); + await revalidateTag(`my-lectures-${userNumber}`); + } break; case 'university_added': @@ -29,6 +46,8 @@ export async function POST(request: NextRequest) { timestamp: new Date().toISOString(), action, universityName, + lectureId, + userNumber, }); } catch (error) { return NextResponse.json({ message: 'Internal server error' }, { status: 500 }); diff --git a/app/layout.tsx b/app/layout.tsx index 76efdfc..ab7d0ce 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -27,11 +27,9 @@ export default function RootLayout({ return ( - - -
{children}
-
-
+ +
{children}
+
); diff --git a/app/providers/AuthHydrationProvider.tsx b/app/providers/AuthHydrationProvider.tsx index a33c5de..5d6bf54 100644 --- a/app/providers/AuthHydrationProvider.tsx +++ b/app/providers/AuthHydrationProvider.tsx @@ -1,5 +1,6 @@ import { QueryClient, HydrationBoundary, dehydrate } from '@tanstack/react-query'; import { hydrateAuthState } from '@/utils/authHydration'; +import AppProviders from './AppProviders'; interface AuthHydrationProviderProps { children: React.ReactNode; @@ -10,5 +11,9 @@ export default async function AuthHydrationProvider({ children }: AuthHydrationP await hydrateAuthState(queryClient); - return {children}; + return ( + + {children} + + ); } diff --git a/components/common/auth/LoginTriggerWrapper.tsx b/components/common/auth/LoginTriggerWrapper.tsx index 34bcfa8..6c45152 100644 --- a/components/common/auth/LoginTriggerWrapper.tsx +++ b/components/common/auth/LoginTriggerWrapper.tsx @@ -2,7 +2,7 @@ import { Suspense } from 'react'; import { useSearchParams } from 'next/navigation'; -import { useLoginTrigger } from '@/hooks/useLoginTrigger'; +import { useLoginTrigger } from './hooks/useLoginTrigger'; import { LoadingSpinner } from '../loading/LoadingSpinner'; function LoginTrigger() { diff --git a/hooks/useLoginTrigger.ts b/components/common/auth/hooks/useLoginTrigger.ts similarity index 100% rename from hooks/useLoginTrigger.ts rename to components/common/auth/hooks/useLoginTrigger.ts diff --git a/components/common/Selector/Selector.module.css b/components/common/selector/Selector.module.css similarity index 100% rename from components/common/Selector/Selector.module.css rename to components/common/selector/Selector.module.css diff --git a/components/common/Selector/Selector.tsx b/components/common/selector/Selector.tsx similarity index 100% rename from components/common/Selector/Selector.tsx rename to components/common/selector/Selector.tsx diff --git a/components/common/starRating/InteractiveStarRating.tsx b/components/common/starRating/InteractiveStarRating.tsx index 3f36b91..0bac046 100644 --- a/components/common/starRating/InteractiveStarRating.tsx +++ b/components/common/starRating/InteractiveStarRating.tsx @@ -50,7 +50,11 @@ export default function InteractiveStarRating({ const displayRating = hoverRating || rating; return ( -
+
@@ -68,6 +72,7 @@ export default function InteractiveStarRating({ onMouseEnter={(event) => handleMouseEnter(star, event)} onClick={(event) => handleClick(star, event)} disabled={disabled} + data-test={`star-${star}`} > void; +interface ButtonProps extends React.ButtonHTMLAttributes { variant?: 'default' | 'primary' | 'secondary'; - disabled?: boolean; - type?: 'button' | 'submit' | 'reset'; className?: string; } export default function Button({ children, - onClick, variant = 'default', - disabled = false, - type = 'button', className = '', + ...rest }: ButtonProps) { const buttonClass = `${styles.button} ${styles[variant]} ${className}`.trim(); return ( -
- +
) => { if (isCurrent) { e.preventDefault(); diff --git a/cypress/e2e/home.cy.ts b/cypress/e2e/home.cy.ts deleted file mode 100644 index 2949433..0000000 --- a/cypress/e2e/home.cy.ts +++ /dev/null @@ -1,5 +0,0 @@ -describe('Home', () => { - it('should display the home page', () => { - cy.visit('/'); - }); -}); diff --git a/cypress/e2e/tier1-critical/01-auth-and-review.cy.ts b/cypress/e2e/tier1-critical/01-auth-and-review.cy.ts new file mode 100644 index 0000000..765f348 --- /dev/null +++ b/cypress/e2e/tier1-critical/01-auth-and-review.cy.ts @@ -0,0 +1,64 @@ +describe('μ‚¬μš©μžλŠ” λ‘œκ·ΈμΈμ„ν•˜κ³  μžμ‹ μ΄ 듀은 κ°•μ˜μ— 리뷰λ₯Ό μž‘μ„±ν•  수 μžˆλ‹€.', () => { + it('μ‚¬μš©μžλŠ” 헀더에 둜그인 λ²„νŠΌμ„ 톡해 λ‘œκ·ΈμΈν•  수 μžˆλ‹€.', () => { + cy.clearCookies(); + + cy.uiLogin(); + + cy.get('header').contains('button', 'λ‘œκ·Έμ•„μ›ƒ').should('be.visible'); + }); + + 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.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.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'); + }); +}); diff --git a/cypress/e2e/tier1-critical/02-mypage.cy.ts b/cypress/e2e/tier1-critical/02-mypage.cy.ts new file mode 100644 index 0000000..b00d041 --- /dev/null +++ b/cypress/e2e/tier1-critical/02-mypage.cy.ts @@ -0,0 +1,58 @@ +describe('μ‚¬μš©μžλŠ” λ§ˆμ΄νŽ˜μ΄μ§€μ—μ„œ 리뷰 및 κ°•μ˜λ₯Ό 관리할 수 μžˆλ‹€.', () => { + it('μ‚¬μš©μžλŠ” λΉ„λ‘œκ·ΈμΈ μƒνƒœμ—μ„œ λ§ˆμ΄νŽ˜μ΄μ§€μ— μ ‘κ·Ό μ‹œ 둜그인 μœ λ„λ₯Ό λ°›λŠ”λ‹€.', () => { + cy.clearCookies(); + + cy.visit('/mypage/reviews'); + + cy.location('pathname').should('eq', '/'); + cy.location('search').should('contain', 'showLogin=true'); + }); + + it('μ‚¬μš©μžλŠ” 둜그인 ν›„ λ§ˆμ΄νŽ˜μ΄μ§€μ— μ ‘κ·Όν•˜λ©° λ‚΄ κ°•μ˜ λͺ©λ‘μ„ 확인할 수 μžˆλ‹€.', () => { + cy.uiLogin(); + cy.visit('/mypage/lectures'); + + cy.byTest('my-lectures-list').should('exist'); + cy.byTest('my-lecture-card').first().click(); + + cy.byTest('lecture-detail').should('exist'); + }); + + it('μ‚¬μš©μžλŠ” λ§ˆμ΄νŽ˜μ΄μ§€μ—μ„œ λ‚΄ 리뷰λ₯Ό μˆ˜μ • 및 μ‚­μ œν•  수 μžˆλ‹€.', () => { + cy.uiLogin(); + cy.visit('/mypage/reviews'); + + cy.get('body').then(($body) => { + const hasList = $body.find('[data-test="my-reviews-list"]').length > 0; + if (!hasList) { + cy.byTest('my-reviews-empty').should('exist'); + return; + } + + cy.byTest('my-reviews-list').within(() => { + cy.byTest('my-review-item').first().as('target'); + cy.get('@target').find('[data-test="my-review-edit"]').click({ force: true }); + }); + cy.byTest('write-review-form').should('exist'); + cy.byTest('review-title-input').type(' (μˆ˜μ •)'); + cy.byTest('write-submit').click({ force: true }); + + cy.byTest('my-reviews-list').within(() => { + cy.get('@target') + .find('[data-test="my-review-title"]') + .should(($t) => { + expect($t.text()).to.contain('(μˆ˜μ •)'); + }); + }); + + cy.window().then((win) => { + const confirmStub = cy.stub(win, 'confirm').returns(true); + cy.wrap(confirmStub).as('confirm'); + }); + cy.byTest('my-reviews-list').within(() => { + cy.get('@target').find('[data-test="my-review-delete"]').click({ force: true }); + }); + cy.get('@confirm').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 new file mode 100644 index 0000000..656f42f --- /dev/null +++ b/cypress/e2e/tier2-experience/03-reviews-interactions.cy.ts @@ -0,0 +1,51 @@ +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'); + }); + + it('μ‚¬μš©μžλŠ” μ’‹μ•„μš” λ²„νŠΌμ„ 톡해 리뷰에 μ’‹μ•„μš”λ₯Ό λˆ„λ₯Ό 수 μžˆλ‹€. ν•œλ²ˆλ” λˆ„λ₯΄λ©΄ μ’‹μ•„μš”κ°€ μ·¨μ†Œλœλ‹€.', () => { + cy.byTest('review-list').should('exist'); + cy.byTest('review-item').first().as('firstReview'); + cy.get('@firstReview') + .find('[data-test="like-count"]') + .invoke('text') + .then((text) => { + const initial = parseInt(text || '0', 10) || 0; + + cy.get('@firstReview').find('[data-test="like"]').click(); + cy.get('@firstReview') + .find('[data-test="like-count"]') + .should(($el) => { + const now = parseInt($el.text() || '0', 10) || 0; + expect(now).to.be.gte(initial); + }); + + cy.get('@firstReview').find('[data-test="like"]').click(); + cy.get('@firstReview') + .find('[data-test="like-count"]') + .should(($el) => { + const now = parseInt($el.text() || '0', 10) || 0; + expect(now).to.be.lte(initial + 1); + }); + }); + }); + + it('μ‚¬μš©μžλŠ” 리뷰 μ •λ ¬ 탭을 톡해 리뷰λ₯Ό νŠΉμ • κΈ°μ€€μœΌλ‘œ μ •λ ¬ν•  수 μžˆλ‹€.', () => { + cy.byTest('sort-container').should('exist'); + + cy.byTest('sort-latest').click(); + cy.byTest('sort-rating_desc').click(); + cy.byTest('sort-rating_asc').click(); + cy.byTest('sort-likes_desc').click(); + + cy.byTest('review-list').should('exist'); + }); +}); 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 new file mode 100644 index 0000000..86eb86f --- /dev/null +++ b/cypress/e2e/tier2-experience/04-navigation-and-univ-switch.cy.ts @@ -0,0 +1,21 @@ +describe('μ‚¬μš©μžλŠ” κ°•μ˜ λͺ©λ‘μ—μ„œ 상세 νŽ˜μ΄μ§€λ‘œ 이동할 수 있고, λŒ€ν•™ μ „ν™˜ λ“œλ‘­λ‹€μš΄μ„ 톡해 λŒ€ν•™μ„ μ „ν™˜ν•  수 μžˆλ‹€.', () => { + it('μ‚¬μš©μžλŠ” κ°•μ˜ λͺ©λ‘μ—μ„œ 상세 νŽ˜μ΄μ§€λ‘œ 이동할 수 μžˆλ‹€.', () => { + cy.visit(encodeURI('/λ™μ„œλŒ€ν•™κ΅')); + cy.byTest('lecture-list').should('exist'); + + cy.byTest('lecture-card').click(); + + cy.byTest('lecture-detail').should('exist'); + cy.byTest('lecture-title').should('be.visible'); + cy.byTest('lecture-professor').should('be.visible'); + }); + + it('μ‚¬μš©μžλŠ” λŒ€ν•™ μ „ν™˜ λ“œλ‘­λ‹€μš΄μ„ 톡해 λŒ€ν•™μ„ μ „ν™˜ν•  수 μžˆλ‹€.', () => { + cy.visit(encodeURI('/λ™μ„œλŒ€ν•™κ΅')); + + cy.byTest('univ-switch').click({ force: true }); + cy.byTest('univ-option').click({ force: true }); + + cy.location('pathname').should('not.eq', encodeURI('/λ™μ„œλŒ€ν•™κ΅')); + }); +}); diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index 95857ae..4ae8d3f 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -1,4 +1,64 @@ /// + +export {}; + +Cypress.Commands.add('byTest', (testId: string) => { + return cy.get(`[data-test="${testId}"]`).filter(':visible').first(); +}); + +Cypress.Commands.add('dedupeByTest', (testId: string) => { + cy.document().then((doc) => { + const nodes = Array.from(doc.querySelectorAll(`[data-test="${testId}"]`)); + if (nodes.length <= 1) return; + const isVisible = (el: Element) => (el as HTMLElement).offsetParent !== null; + const keeper = nodes.find(isVisible) ?? nodes[0]; + nodes.forEach((el) => { + if (el !== keeper) el.parentElement?.removeChild(el); + }); + }); +}); + +Cypress.Commands.add('uiLogin', (userId?: string, userPw?: string) => { + const id = userId ?? (Cypress.env('TEST_USER_ID') as string); + const pw = userPw ?? (Cypress.env('TEST_USER_PW') as string); + + cy.intercept('POST', '**/signin').as('signin'); + cy.visit('/'); + + cy.get('body').then(($body) => { + const hasDataTest = $body.find('[data-test="open-login"]').length > 0; + if (hasDataTest) { + cy.dedupeByTest('open-login'); + cy.byTest('open-login').click(); + } else { + cy.get('header', { timeout: 10000 }).within(() => { + cy.contains('button', '둜그인', { timeout: 10000 }).click(); + }); + } + }); + cy.get('#login-id').filter(':visible').first().type(String(id), { force: true }); + cy.get('#login-password').filter(':visible').first().type(String(pw), { force: true }); + cy.dedupeByTest('login-form'); + cy.byTest('login-form').within(() => { + cy.dedupeByTest('login-submit'); + cy.byTest('login-submit').click({ force: true }); + }); + cy.wait('@signin'); + + cy.get('header').contains('button', 'λ‘œκ·Έμ•„μ›ƒ', { timeout: 10000 }).should('be.visible'); +}); + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Cypress { + interface Chainable { + byTest(testId: string): Chainable>; + dedupeByTest(testId: string): Chainable; + uiLogin(userId?: string, userPw?: string): Chainable; + } + } +} + // *********************************************** // This example commands.ts shows you how to // create various custom commands and overwrite diff --git a/domains/auth/client/components/LoginModal.tsx b/domains/auth/client/components/LoginModal.tsx index 940c968..95b0bab 100644 --- a/domains/auth/client/components/LoginModal.tsx +++ b/domains/auth/client/components/LoginModal.tsx @@ -50,7 +50,7 @@ export default function LoginModal() { return ( -
+
-
diff --git a/domains/lecture/index.ts b/domains/lecture/index.ts index e7df9a8..c243d16 100644 --- a/domains/lecture/index.ts +++ b/domains/lecture/index.ts @@ -1,6 +1,7 @@ export { default as LectureCardHybrid } from './server/components/LectureCardHybrid'; export { default as LectureListHybrid } from './server/components/LectureListHybrid'; export { default as LectureInfoComponent } from './server/components/LectureInfo'; +export { LectureInfoServer } from './server/components/LectureInfoServer'; export { default as DynamicRating } from './client/components/DynamicRating'; export { default as DynamicReviewCount } from './client/components/DynamicReviewCount'; diff --git a/domains/lecture/server/components/LectureCardHybrid.tsx b/domains/lecture/server/components/LectureCardHybrid.tsx index 1dbf9de..c69eb9d 100644 --- a/domains/lecture/server/components/LectureCardHybrid.tsx +++ b/domains/lecture/server/components/LectureCardHybrid.tsx @@ -10,7 +10,7 @@ interface LectureCardHybridProps { export default function LectureCardHybrid({ staticData, universityName }: LectureCardHybridProps) { return ( -
+
@@ -49,7 +49,7 @@ export default function LectureCardHybrid({ staticData, universityName }: Lectur 평점 -
+
+
-

{lecture.lectureName}

-
{lecture.professor}
+

{lecture.lectureName}

+
{lecture.professor}
@@ -67,7 +67,7 @@ export default function LectureInfo({
- + +
{lectures.message || 'κ°•μ˜λ₯Ό λΆˆλŸ¬μ˜€λŠ”λ° μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€.'}
); @@ -30,7 +30,7 @@ export default function LectureListHybrid({ universityName, lectures }: LectureL })); return ( -
+
{lectureList.map((lecture: StaticLectureData) => { const openedParam = lecture.opened ? 'true' : 'false'; @@ -39,6 +39,8 @@ export default function LectureListHybrid({ universityName, lectures }: LectureL href={`/${universityName}/${lecture.lectureId}?opened=${openedParam}`} key={lecture.lectureId} prefetch={true} + data-test="lecture-card" + data-lecture-id={lecture.lectureId} > diff --git a/domains/mypage/client/components/DeleteReviewButton.tsx b/domains/mypage/client/components/DeleteReviewButton.tsx index 7757f3e..7a5ef4e 100644 --- a/domains/mypage/client/components/DeleteReviewButton.tsx +++ b/domains/mypage/client/components/DeleteReviewButton.tsx @@ -15,6 +15,21 @@ export function DeleteReviewButton({ review, userNumber }: ReviewCardProps) { }); await Promise.all([revalidateReviewsPage(), revalidateLecturesPage()]); + + 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); + } } catch (error) { alert('리뷰 μ‚­μ œμ— μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€.'); } @@ -22,7 +37,12 @@ export function DeleteReviewButton({ review, userNumber }: ReviewCardProps) { }; return ( - ); diff --git a/domains/mypage/client/components/PatchReviewButton.tsx b/domains/mypage/client/components/PatchReviewButton.tsx index 20bd082..df4809c 100644 --- a/domains/mypage/client/components/PatchReviewButton.tsx +++ b/domains/mypage/client/components/PatchReviewButton.tsx @@ -48,7 +48,7 @@ export function PatchReviewButton({ review, userNumber }: ReviewCardProps) { return ( <> - diff --git a/domains/mypage/server/components/LectureCard.tsx b/domains/mypage/server/components/LectureCard.tsx index c019918..2f9a605 100644 --- a/domains/mypage/server/components/LectureCard.tsx +++ b/domains/mypage/server/components/LectureCard.tsx @@ -13,7 +13,7 @@ export function LectureCard({ lecture }: LectureCardProps) { : `${styles.compactReviewBadge} ${styles.compactUnReviewedBadge}`; return ( -
+
diff --git a/domains/mypage/server/components/LectureList.tsx b/domains/mypage/server/components/LectureList.tsx index 6785e35..80b94a2 100644 --- a/domains/mypage/server/components/LectureList.tsx +++ b/domains/mypage/server/components/LectureList.tsx @@ -16,7 +16,7 @@ export function LectureList({ lectures }: LectureListProps) { } return ( -
+
{lectures.map((lecture) => ( ))} diff --git a/domains/mypage/server/components/ReviewCard.tsx b/domains/mypage/server/components/ReviewCard.tsx index 26743e0..d79617a 100644 --- a/domains/mypage/server/components/ReviewCard.tsx +++ b/domains/mypage/server/components/ReviewCard.tsx @@ -5,7 +5,7 @@ import styles from '../../styles/ReviewCard.module.css'; export function ReviewCard({ review, userNumber }: ReviewCardProps) { return ( -
+

{review.lecture.lectureName}

@@ -24,13 +24,17 @@ export function ReviewCard({ review, userNumber }: ReviewCardProps) {
-

{review.postTitle}

+

+ {review.postTitle} +

{review.starLating}
-

{review.postContent}

+

+ {review.postContent} +

diff --git a/domains/mypage/server/components/ReviewList.tsx b/domains/mypage/server/components/ReviewList.tsx index e505cf0..758b2d6 100644 --- a/domains/mypage/server/components/ReviewList.tsx +++ b/domains/mypage/server/components/ReviewList.tsx @@ -10,7 +10,7 @@ interface ReviewListProps { export function ReviewList({ reviews, userNumber }: ReviewListProps) { if (reviews.length === 0) { return ( -
+

μž‘μ„±ν•œ 리뷰가 μ—†μŠ΅λ‹ˆλ‹€.

κ°•μ˜λ₯Ό μˆ˜κ°•ν•œ ν›„ 리뷰λ₯Ό μž‘μ„±ν•΄λ³΄μ„Έμš”!

@@ -18,7 +18,7 @@ export function ReviewList({ reviews, userNumber }: ReviewListProps) { } return ( -
+
{reviews.map((review) => ( ))} diff --git a/domains/review/client/components/LikeReview.tsx b/domains/review/client/components/LikeReview.tsx index 0cdc932..88d5133 100644 --- a/domains/review/client/components/LikeReview.tsx +++ b/domains/review/client/components/LikeReview.tsx @@ -9,13 +9,13 @@ export default function LikeReview({ review }: { review: Review }) { const { handleLikeClick, isLoggedIn } = useLikeReviewAction({ review }); return ( -
+
- {review.likes} + {review.likes}
); } diff --git a/domains/review/client/components/ReviewHeader.tsx b/domains/review/client/components/ReviewHeader.tsx index 94e7106..f85abce 100644 --- a/domains/review/client/components/ReviewHeader.tsx +++ b/domains/review/client/components/ReviewHeader.tsx @@ -41,7 +41,7 @@ export default function ReviewHeader({
-
diff --git a/domains/review/client/components/ReviewList.tsx b/domains/review/client/components/ReviewList.tsx index 178f418..3c68bd3 100644 --- a/domains/review/client/components/ReviewList.tsx +++ b/domains/review/client/components/ReviewList.tsx @@ -32,7 +32,7 @@ export default function ReviewList({ reviews, isLoading, isFetching, error }: Re if (!reviews || reviews.length === 0) { return ( -
+
@@ -43,7 +43,7 @@ export default function ReviewList({ reviews, isLoading, isFetching, error }: Re } return ( -
+
{reviews.map((review) => ( diff --git a/domains/review/client/components/ReviewSortSelector.tsx b/domains/review/client/components/ReviewSortSelector.tsx index fb53244..34bfd33 100644 --- a/domains/review/client/components/ReviewSortSelector.tsx +++ b/domains/review/client/components/ReviewSortSelector.tsx @@ -25,25 +25,27 @@ export default function ReviewSortSelector({ }; return ( -
-
+
+
{SORT_OPTIONS.map((option) => ( ))}
-
+
diff --git a/domains/review/client/components/WriteReviewModal.tsx b/domains/review/client/components/WriteReviewModal.tsx index f1bb244..5efcf02 100644 --- a/domains/review/client/components/WriteReviewModal.tsx +++ b/domains/review/client/components/WriteReviewModal.tsx @@ -74,7 +74,7 @@ const WriteReviewModal = forwardRef( size="medium" onClose={handleClose} > -
+
@@ -109,15 +110,16 @@ const WriteReviewModal = forwardRef( required rows={6} maxLength={1000} + data-test="review-content-input" />
{content.length}/1000
- -