diff --git a/.eslintrc.json b/.eslintrc.json index 60552981..eff4c942 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -4,7 +4,7 @@ "es2021": true, "jest": true }, - "extends": ["next/core-web-vitals", "airbnb", "airbnb-typescript"], + "extends": ["next/core-web-vitals"], "parser": "@typescript-eslint/parser", "parserOptions": { "ecmaFeatures": { @@ -17,36 +17,8 @@ "plugins": ["react", "@typescript-eslint"], "rules": { "react-hooks/exhaustive-deps": "off", - "react/jsx-props-no-spreading": "off", - "linebreak-style": 0, - "import/no-extraneous-dependencies": "off", "import/prefer-default-export": ["off", { "target": "single" }], - "object-curly-newline": "off", - "arrow-parens": "off", - "arrow-body-style": "off", - "@typescript-eslint/no-shadow": "off", - "operator-linebreak": "off", - "react/require-default-props": "off", - "implicit-arrow-linebreak": "off", - "@typescript-eslint/naming-convention": "off", - "consistent-return": "off", - "@typescript-eslint/indent": "off", - "jsx-a11y/control-has-associated-label": "off", - "react/jsx-wrap-multilines": "off", - "react/jsx-indent": "off", - "react/no-invalid-html-attribute": "off", - "eslint-plugin-import/no-cycle": "off", - "import/no-cycle":"off", - "no-param-reassign":"off", - "@typescript-eslint/no-unused-vars": "warn", //사용안한 변수는 경고처리 - "react/jsx-curly-newline": "off", // jsx안에 }를 새로운 라인에 사용할 수 있다. - "@typescript-eslint/no-use-before-define": ["warn"], // 선언하기 전에 사용 한다면 경고 - "jsx-a11y/label-has-associated-control": [ - 2, - { - "labelAttributes": ["htmlFor"] - } - ] + "react/display-name": "off" }, "globals": { "React": "writable" diff --git a/init-https.sh b/init-https.sh new file mode 100644 index 00000000..5ddb3505 --- /dev/null +++ b/init-https.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +MKCERT_INSTALLED=$(which mkcert) + +if [ -z $MKCERT_INSTALLED ];then + brew install mkcert +fi + +mkcert -install +mkcert localhost \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index b3fa2b11..85ef3a98 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,7 @@ "dayjs": "^1.11.10", "embla-carousel-autoplay": "^8.0.0-rc22", "embla-carousel-react": "^8.0.0-rc17", + "event-source-polyfill": "^1.0.31", "jsonwebtoken": "^9.0.2", "lucide-react": "^0.299.0", "next": "14.0.3", @@ -68,6 +69,7 @@ "@testing-library/jest-dom": "^6.1.5", "@testing-library/react": "^14.1.2", "@types/crypto-js": "^4.2.1", + "@types/event-source-polyfill": "^1.0.5", "@types/jest": "^29.5.11", "@types/jsonwebtoken": "^9.0.5", "@types/node": "^20.10.4", @@ -4708,6 +4710,12 @@ "@types/estree": "*" } }, + "node_modules/@types/event-source-polyfill": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/event-source-polyfill/-/event-source-polyfill-1.0.5.tgz", + "integrity": "sha512-iaiDuDI2aIFft7XkcwMzDWLqo7LVDixd2sR6B4wxJut9xcp/Ev9bO4EFg4rm6S9QxATLBj5OPxdeocgmhjwKaw==", + "dev": true + }, "node_modules/@types/glob": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.2.0.tgz", @@ -8565,6 +8573,11 @@ "node": ">=0.10.0" } }, + "node_modules/event-source-polyfill": { + "version": "1.0.31", + "resolved": "https://registry.npmjs.org/event-source-polyfill/-/event-source-polyfill-1.0.31.tgz", + "integrity": "sha512-4IJSItgS/41IxN5UVAVuAyczwZF7ZIEsM1XAoUzIHA6A+xzusEZUutdXz2Nr+MQPLxfTiCvqE79/C8HT8fKFvA==" + }, "node_modules/eventemitter3": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", diff --git a/package.json b/package.json index 79d5d15a..ee5444c8 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "version": "0.1.0", "private": true, "scripts": { + "init-https": "sh init-https.sh", "dev": "next dev", "build": "next build", "build:dev": "env-cmd -f .env.development next build", @@ -40,6 +41,7 @@ "dayjs": "^1.11.10", "embla-carousel-autoplay": "^8.0.0-rc22", "embla-carousel-react": "^8.0.0-rc17", + "event-source-polyfill": "^1.0.31", "jsonwebtoken": "^9.0.2", "lucide-react": "^0.299.0", "next": "14.0.3", @@ -73,6 +75,7 @@ "@testing-library/jest-dom": "^6.1.5", "@testing-library/react": "^14.1.2", "@types/crypto-js": "^4.2.1", + "@types/event-source-polyfill": "^1.0.5", "@types/jest": "^29.5.11", "@types/jsonwebtoken": "^9.0.5", "@types/node": "^20.10.4", diff --git a/server.js b/server.js new file mode 100644 index 00000000..bf220d60 --- /dev/null +++ b/server.js @@ -0,0 +1,40 @@ +const http = require('http'); +const { parse } = require('url'); +const next = require('next'); + +const https = require('https'); +const fs = require('fs'); + +const dev = process.env.NODE_ENV !== 'production'; +const app = next({ dev }); +const handle = app.getRequestHandler(); + +const PORT = 3000; + +const httpsOptions = { + key: fs.readFileSync('./key.pem'), + cert: fs.readFileSync('./cert.pem'), +}; + +app.prepare().then(() => { + http + .createServer((req, res) => { + const parsedUrl = parse(req.url, true); + handle(req, res, parsedUrl); + }) + .listen(PORT, err => { + if (err) throw err; + console.log(`> Ready on http://localhost:${PORT}`); + }); + + // https 서버 추가 + https + .createServer(httpsOptions, (req, res) => { + const parsedUrl = parse(req.url, true); + handle(req, res, parsedUrl); + }) + .listen(PORT + 1, err => { + if (err) throw err; + console.log(`> HTTPS: Ready on https://localhost:${PORT + 1}`); + }); +}); diff --git a/src/app/notification/_components/Card.tsx b/src/app/notification/_components/Card.tsx index ef7f8554..4ea8b2ca 100644 --- a/src/app/notification/_components/Card.tsx +++ b/src/app/notification/_components/Card.tsx @@ -2,17 +2,37 @@ import Link from 'next/link'; import Image from 'next/image'; import { Megaphone } from 'lucide-react'; -import type { NotificationProps } from '@/types/notification'; import { COLOR } from '@/styles/color'; +import type { NotificationProps } from '@/types/notification'; +import getTimeDiff from '@/utils/getTimeDiff'; +import { useRouter } from 'next/navigation'; +import readNotification from '../api/readNotification'; +import clsx from 'clsx'; // is_read 읽음 여부에 따른 디자인 변화 필요 function Card({ ...props }: NotificationProps) { - const { url, title, content, created_at, image } = props; + const { url, title, content, created_at, image, id, is_read } = props; + const router = useRouter(); + const timeDiff = getTimeDiff(created_at); + const buttonClassName = (isRead?: boolean) => + clsx( + 'w-[calc(100%-32px)] bg-white rounded-[10px] border my-2 mx-4 flex px-[10px] py-[14px] gap-3', + { + 'border-purple-200 bg-[#F2EDFF]': !isRead, + 'border-grey-200 bg-white': isRead, + }, + ); + + const handleClick = (id: number) => { + router.push(url); + readNotification(id); + }; return ( - handleClick(id)} + className={buttonClassName(is_read)} > {image ? ( )} -
+
{title} {content} - {created_at} + {timeDiff}
- + ); } diff --git a/src/app/notification/api/fetchSse.ts b/src/app/notification/api/fetchSse.ts new file mode 100644 index 00000000..4ceb04c7 --- /dev/null +++ b/src/app/notification/api/fetchSse.ts @@ -0,0 +1,47 @@ +import { BACK_URL } from '@/constants/api'; +import { END_POINT } from '@/constants/api/end-point'; +import CustomError from '@/error/CustomError'; +import { getCookie } from '@/lib/cookie'; +import { decrypt } from '@/utils/crypto'; +import { EventSourcePolyfill } from 'event-source-polyfill'; + +export const fetchSse = async () => { + const { main, getSubscribe } = END_POINT.notificationController; + const accessToken = decrypt(getCookie({ name: 'access_token' })); + + try { + const eventSource = new EventSourcePolyfill( + BACK_URL + main + getSubscribe, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + + eventSource.onopen = () => { + console.log('Connection established.'); + }; + + // 서버로부터 빈 메시지가 수신되면 이를 무시합니다. + eventSource.onmessage = event => { + if (event.data.trim() === '') { + return; + } + const eventData = JSON.parse(event.data); + console.log('Received message:', eventData); + // 여기서 이벤트 데이터를 처리하거나 화면에 표시하는 등의 작업을 수행합니다. + }; + + // 오류 처리 로직을 추가합니다. + eventSource.onerror = error => { + console.error('EventSource error:', error); + eventSource.close(); + }; + } catch (error: unknown) { + if (error instanceof CustomError) { + throw new Error(error.message); + } + throw new Error(String(error)); + } +}; diff --git a/src/app/notification/api/readNotification.ts b/src/app/notification/api/readNotification.ts new file mode 100644 index 00000000..3e30ec72 --- /dev/null +++ b/src/app/notification/api/readNotification.ts @@ -0,0 +1,15 @@ +import CustomError from '@/error/CustomError'; +import { axiosInstance } from '@/lib/axios/axios-instance'; +import { END_POINT } from '@/constants/api/end-point'; + +const readNotification = async (id: number) => { + try { + await axiosInstance.patch(END_POINT.notificationController.read(id)); + } catch (error: unknown) { + if (error instanceof CustomError) { + throw new Error(error.message); + } + } +}; + +export default readNotification; diff --git a/src/app/service/page.tsx b/src/app/service/page.tsx index 32f0e2bf..01e41b79 100644 --- a/src/app/service/page.tsx +++ b/src/app/service/page.tsx @@ -14,7 +14,7 @@ function Page() {
<Link href={CALLBACK_URL.notification}> - <Bell strokeWidth={2.5} /> + <Bell strokeWidth={2} /> </Link> </section> <Notice /> diff --git a/src/app/sign-in/kakao/page.tsx b/src/app/sign-in/kakao/page.tsx index e850f173..5fa38836 100644 --- a/src/app/sign-in/kakao/page.tsx +++ b/src/app/sign-in/kakao/page.tsx @@ -12,9 +12,10 @@ import { encrypt } from '@/utils/crypto'; import { useRouter } from 'next/navigation'; import { getEmail } from '@/utils/getEmail'; import { useToast } from '@/app/_components/ui/use-toast'; +import { SIGN_UP_ERROR_MESSAGES } from '@/constants/sign-in'; import { setTokensInCookies } from '@/utils/setTokensInCookies'; +import { fetchSse } from '@/app/notification/api/fetchSse'; import { ERROR_CODE, STATUS_CODE } from '@/constants/api/statusCode'; -import { SIGN_UP_ERROR_MESSAGES } from '@/constants/sign-in'; // 카카오 소셜 로그인 REDIRECT URI PAGE function Page() { @@ -43,6 +44,7 @@ function Page() { case STATUS_CODE.ok: router.push(service); setId(response.data.id); + await fetchSse(); break; case invalidEmail: diff --git a/src/constants/api/end-point.ts b/src/constants/api/end-point.ts index 9e4debfd..7d637a32 100644 --- a/src/constants/api/end-point.ts +++ b/src/constants/api/end-point.ts @@ -161,14 +161,20 @@ export const END_POINT = { notificationController: { // DEFAULT - default: '/notification', + main: '/notification', getNotification: (page: number) => { const url = new URLSearchParams(); url.append('page', String(page)); - return `${END_POINT.notificationController.default}?${url.toString()}`; + return `${END_POINT.notificationController.main}?${url.toString()}`; }, + + read: (notificationId: number) => { + return `${END_POINT.notificationController.main}/${notificationId}/read`; + }, + + getSubscribe: '/subscribe', }, }; diff --git a/src/types/notification/index.ts b/src/types/notification/index.ts index 809a473b..04e7d53d 100644 --- a/src/types/notification/index.ts +++ b/src/types/notification/index.ts @@ -15,3 +15,9 @@ export interface NotificationProps { export interface NotificationData extends Pagination { content: NotificationProps[]; } + +export interface SubscribeResponse { + id: string; + event: string; + data: string; +}