diff --git a/.gitignore b/.gitignore index b059b28..47e9438 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,4 @@ next-env.d.ts .env # Sentry Config File .env.sentry-build-plugin -todo +todo \ No newline at end of file diff --git a/cypress.config.ts b/cypress.config.ts index 679438c..f07841f 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -2,6 +2,8 @@ import { defineConfig } from 'cypress'; export default defineConfig({ e2e: { + chromeWebSecurity: false, // Cross-Origin 제한 해제 + setupNodeEvents(on, config) { // implement node event listeners here }, diff --git a/cypress/e2e/home.cy.ts b/cypress/e2e/home.cy.ts deleted file mode 100644 index b61b444..0000000 --- a/cypress/e2e/home.cy.ts +++ /dev/null @@ -1,7 +0,0 @@ -export {}; - -describe('홈페이지 테스트', () => { - beforeEach(() => { - cy.visit('/'); - }); -}); diff --git a/cypress/e2e/login.cy.ts b/cypress/e2e/login.cy.ts new file mode 100644 index 0000000..a233b02 --- /dev/null +++ b/cypress/e2e/login.cy.ts @@ -0,0 +1,10 @@ +export function logintest() { + cy.visit('/sign'); + cy.get('input[type="email"]').type('moaguide1'); + cy.get('input[type="password"]').type('qwer1234!'); + cy.get('.submit').click(); + + // 로그인 성공 후 세션 유지 확인 + cy.url().should('not.include', '/sign'); + // cy.getCookie('access_token').should('exist'); +} diff --git a/cypress/e2e/paymentIndex.cy.ts b/cypress/e2e/paymentIndex.cy.ts new file mode 100644 index 0000000..cb43f65 --- /dev/null +++ b/cypress/e2e/paymentIndex.cy.ts @@ -0,0 +1,42 @@ +describe('PaymentIndex Component', () => { + beforeEach(() => { + cy.intercept('GET', '/api/payment-status', { fixture: 'paymentStatus.json' }).as( + 'getPaymentStatus' + ); + cy.visit('/payment'); + }); + + it('renders payment benefits correctly', () => { + // Check that the header and subscription benefits are rendered + cy.get('.text-heading2').contains('구독 시작하기'); + cy.get('.text-body7').should('have.length', 4); + }); + + it('allows selecting a subscription option', () => { + // Click on the first subscription option and check that it is selected + cy.get('[data-testid="subscription-option"]').first().click(); + cy.get('[data-testid="subscription-option"]') + .first() + .should('have.class', 'border-normal'); + }); + + it('redirects to the correct page based on login status', () => { + // Mock logged-in state + cy.setCookie('access_token', 'validToken'); + cy.get('.cta-button').click(); + cy.url().should('include', '/payment/check'); + + // Mock logged-out state + cy.clearCookie('access_token'); + cy.get('.cta-button').click(); + cy.url().should('include', '/sign'); + }); + + it('handles back button click', () => { + // Check that clicking the back button navigates to the previous page + cy.get('.back-button').click(); + cy.url().should('not.include', '/payment'); + }); +}); + +export {}; diff --git a/cypress/e2e/paymentTest.cy.ts/paymentcycle.cy.ts b/cypress/e2e/paymentTest.cy.ts/paymentcycle.cy.ts new file mode 100644 index 0000000..633be84 --- /dev/null +++ b/cypress/e2e/paymentTest.cy.ts/paymentcycle.cy.ts @@ -0,0 +1,50 @@ +/* eslint-disable */ +import { logintest } from '../login.cy'; + +describe('Full Payment Flow', () => { + beforeEach(() => { + cy.intercept('GET', '/api/payment-status', { fixture: 'paymentStatus.json' }).as( + 'getPaymentStatus' + ); + cy.intercept('GET', '/api/issubscribed', { subscribed: false }).as('getIsSubscribed'); + // cy.setCookie('access_token', 'your-mock-token'); + }); + + it('completes a full payment flow', () => { + logintest(); + cy.visit('/payment'); + cy.wait(3000); + cy.contains('div', '첫 달 무료체험하기').should('exist').click(); + cy.url().should('include', '/payment/check'); + cy.get('.subscribed_check').click(); + cy.wait(1500); + cy.get('.payment_start').click(); + cy.wait(3000); + + //토스 페이먼츠 결제 테스트 + + cy.get('#__tosspayments_payment-gateway_iframe__') + // .should('exist') + .then(($iframe) => { + const body = $iframe.contents().find('body'); + cy.log(body.html()); + cy.wrap(body) + .should('not.be.empty') + .within(() => { + cy.get('input[aria-label="카드번호 1 ~ 4 자리"]').type('6243'); + cy.get('input[aria-label="카드번호 5 ~ 8 자리"]').type('6303'); + cy.get('input[aria-label="카드번호 9 ~ 12 자리"]').type('1763'); + cy.get('input[aria-label="카드번호 13 ~ 16 자리"]').type('5652'); + cy.get('input[aria-label="카드 유효기간"]').type('0825'); + cy.get('input[aria-label="주민등록번호 생년월일"]').type('010310'); + cy.get('input[aria-label="주민등록번호 성별"]').type('3'); + cy.get('input[type="checkbox"]').check(); + cy.contains('button', '다음').click(); + }); + }); + + cy.url().should('include', '/payment/check/confirm/successloading'); + cy.get('p').contains('결제가 진행중입니다...').should('exist'); + }); +}); +export {}; diff --git a/cypress/fixtures/isSubscribed.json b/cypress/fixtures/isSubscribed.json new file mode 100644 index 0000000..6afe34f --- /dev/null +++ b/cypress/fixtures/isSubscribed.json @@ -0,0 +1,3 @@ +{ + "subscribed": true +} diff --git a/cypress/fixtures/paymentStatus.json b/cypress/fixtures/paymentStatus.json new file mode 100644 index 0000000..15e2ebf --- /dev/null +++ b/cypress/fixtures/paymentStatus.json @@ -0,0 +1,4 @@ +{ + "status": "success", + "message": "Payment status retrieved successfully" +} diff --git a/next.config.mjs b/next.config.mjs index b1f02ba..87d9ca6 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -8,7 +8,6 @@ const bundleAnalyzer = withBundleAnalyzer({ }); const nextConfig = { reactStrictMode: false, - styledComponents: true, output: 'standalone', experimental: { instrumentationHook: true }, @@ -47,42 +46,20 @@ const nextConfig = { } }; -export default withSentryConfig(bundleAnalyzer(nextConfig), { - // For all available options, see: - // https://github.com/getsentry/sentry-webpack-plugin#options - +const SentryWebpackPluginOptions = { org: 'moaguide', project: 'javascript-nextjs', - - // Only print logs for uploading source maps in CI + authToken: process.env.SENTRY_AUTH_TOKEN, silent: !process.env.CI, - - // For all available options, see: - // https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/ - - // Upload a larger set of source maps for prettier stack traces (increases build time) widenClientFileUpload: true, - - // Automatically annotate React components to show their full name in breadcrumbs and session replay + sourcemaps: { + deleteSourcemapsAfterUpload: true + }, reactComponentAnnotation: { enabled: true }, - - // Route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers. - // This can increase your server load as well as your hosting bill. - // Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client- - // side errors will fail. - tunnelRoute: '/monitoring', - - // Hides source maps from generated client bundles - hideSourceMaps: true, - - // Automatically tree-shake Sentry logger statements to reduce bundle size + hideSourceMaps: false, disableLogger: true, - - // Enables automatic instrumentation of Vercel Cron Monitors. (Does not yet work with App Router route handlers.) - // See the following for more information: - // https://docs.sentry.io/product/crons/ - // https://vercel.com/docs/cron-jobs automaticVercelMonitors: true -}); +}; +export default withSentryConfig(bundleAnalyzer(nextConfig), SentryWebpackPluginOptions); diff --git a/package.json b/package.json index d6e0cc6..345afb6 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "start": "next start -p 80", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "analyze": "cross-env ANALYZE=true next build", - "cypress:open": "cypress open" + "cypress": "cypress open" }, "dependencies": { "@sentry/nextjs": "8", diff --git a/public/images/learning/articleLiked.svg b/public/images/learning/articleLiked.svg new file mode 100644 index 0000000..6ddd987 --- /dev/null +++ b/public/images/learning/articleLiked.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/images/learning/articleShare.svg b/public/images/learning/articleShare.svg new file mode 100644 index 0000000..3f4157a --- /dev/null +++ b/public/images/learning/articleShare.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/sentry.properties b/sentry.properties deleted file mode 100644 index 88ae701..0000000 --- a/sentry.properties +++ /dev/null @@ -1,5 +0,0 @@ -defaults.url=https://sentry.io/ -defaults.org=YOUR_ORGANIZATION -defaults.project=YOUR_PROJECT_NAME - -auth.token=YOUR_AUTH_TOKEN diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 030d3d8..8e53441 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -14,13 +14,13 @@ import NaverAnalytics from '@/lib/NaverAnalytics'; import AuthWrapper from '@/components/common/AuthWrapper'; import ToastProvider from '@/providers/ToastProvider'; import RefreshTokenWrapper from '@/components/common/RefreshTokenWrapper'; +import * as Sentry from '@sentry/nextjs'; declare global { interface Window { kakao: any; } } - const pretendard = localFont({ src: '../static/fonts/PretendardVariable.woff2', display: 'swap', @@ -55,6 +55,11 @@ export const metadata: Metadata = { } }; +Sentry.init({ + dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, + tracesSampleRate: 1.0 // 조정 가능 +}); + export default function RootLayout({ children }: Readonly<{ diff --git a/src/app/learning/detail/[articleId]/page.tsx b/src/app/learning/detail/[articleId]/page.tsx new file mode 100644 index 0000000..0961eea --- /dev/null +++ b/src/app/learning/detail/[articleId]/page.tsx @@ -0,0 +1,17 @@ +import Navbar from '@/components/common/Navbar'; +import ArticleDetailClientWrapper from '@/components/learning/article/ArticleDetailClientWrapper'; + +interface PageProps { + params: { articleId: string }; +} + +export default function ArticleDetailPage({ params }: PageProps) { + const articleId = params.articleId; + + return ( + <> + + + + ); +} \ No newline at end of file diff --git a/src/app/mypage/cardmanagement/success/page.tsx b/src/app/mypage/cardmanagement/success/page.tsx index ab89bf2..db1b177 100644 --- a/src/app/mypage/cardmanagement/success/page.tsx +++ b/src/app/mypage/cardmanagement/success/page.tsx @@ -18,11 +18,11 @@ export default function CardRegisterSuccess() {

카드 등록에 성공했습니다.

-

등록 내역

+ {/*

등록 내역

주문 ID: {orderId || ''} -

+

*/}
@@ -74,7 +76,7 @@ const PaymentIndex = () => {
- 첫 달 무료체험하기 + {isfirst ? '첫 달 무료 체험하기' : '4,900원 결제하기'}
); diff --git a/src/app/payment/(payment)/TossPaymentsCardWidget.tsx b/src/app/payment/(payment)/TossPaymentsCardWidget.tsx index eb0c2a8..c55f099 100644 --- a/src/app/payment/(payment)/TossPaymentsCardWidget.tsx +++ b/src/app/payment/(payment)/TossPaymentsCardWidget.tsx @@ -33,7 +33,7 @@ const TossPaymentsCardWidget = () => { try { await payment?.requestBillingAuth({ - method: 'CARD', // 자동결제(빌링)는 카드만 지원합니다 + method: 'CARD', successUrl: window.location.origin + `/payment/check/confirm/successloading`, failUrl: window.location.origin + '/payment/check/confirm/fail', diff --git a/src/app/payment/check/confirm/successloading/page.tsx b/src/app/payment/check/confirm/successloading/page.tsx index 31a8a00..b229de8 100644 --- a/src/app/payment/check/confirm/successloading/page.tsx +++ b/src/app/payment/check/confirm/successloading/page.tsx @@ -43,6 +43,7 @@ const PaymentSuccessLoading = () => { throw new Error('추가 작업에 실패했습니다.'); } }; + const mutation = useMutation({ mutationFn: fetchPayment, retry: 0, // 재시도 비활성화 @@ -52,7 +53,7 @@ const PaymentSuccessLoading = () => { fetchNextAPI() .then((response) => { console.log('연쇄 호출 성공:', response); - router.push('/payment/check/confirm/success'); // 성공 시 페이지 이동 + router.push(`/payment/check/confirm/success?orderId=${response?.orderId}`); // 성공 시 페이지 이동 }) .catch((error) => { console.error('연쇄 호출 실패:', error); @@ -67,6 +68,7 @@ const PaymentSuccessLoading = () => { console.log('결제 요청 완료'); } }); + /* eslint-disable react-hooks/exhaustive-deps */ useEffect(() => { if (!data?.cardName) { @@ -77,7 +79,7 @@ const PaymentSuccessLoading = () => { fetchNextAPI() .then((response) => { console.log('연쇄 호출 성공:', response); - router.push('/payment/check/confirm/success'); // 성공 시 페이지 이동 + router.push(`/payment/check/confirm/success?orderId=${response?.orderId}`); // 성공 시 페이지 이동 }) .catch((error) => { console.error('연쇄 호출 실패:', error); diff --git a/src/app/payment/check/page.tsx b/src/app/payment/check/page.tsx index 2d623bc..3bb3ca1 100644 --- a/src/app/payment/check/page.tsx +++ b/src/app/payment/check/page.tsx @@ -7,20 +7,21 @@ import { Line } from '@/components/common/Line'; import { getCoupon } from '@/factory/Coupon/getCoupon'; import Image from 'next/image'; import { useCheckCardRegister } from '@/factory/Card/CheckCardRegister'; +import { SubscribedStatus } from '@/utils/subscribedStatus'; const PaymentCheckPage = () => { const { data } = getCoupon(); const couponLength = data?.coupons?.length as number; const couponName = data?.coupons[0]?.couponName; - console.log(data); + const router = useRouter(); const [isChecked, setIsChecked] = useState(false); const { setModalType, setOpen } = useModalStore(); - const { data: CheckCard, isLoading } = useCheckCardRegister(); - const { requestBillingAuth } = TossPaymentsCardWidget(); + const { Subscribestatus } = SubscribedStatus(); + console.log(Subscribestatus); const bililngRequest = () => { if (isChecked) { if (!CheckCard?.cardName) { @@ -41,17 +42,16 @@ const PaymentCheckPage = () => {
결제하기
구독 플랜
-
- 1개월 구독 + 1개월 -
+
1개월 구독
결제 금액
-
0원
+ +
4,900 원
쿠폰 사용
- {couponLength > 0 ? ( + {isLoading ? null : couponLength > 0 ? (
{couponName}
@@ -61,7 +61,7 @@ const PaymentCheckPage = () => { 등록된 쿠폰이 없습니다.
{ router.push('/mypage/coupon'); }}> @@ -79,21 +79,35 @@ const PaymentCheckPage = () => {
최종 결제 금액
-
0원
+ + {isLoading ? null : couponLength > 0 ? ( +
0 원
+ ) : ( +
4,900 원
+ )}
-
-
- setIsChecked((prev) => !prev)} - className="cursor-pointer" - /> -
-
setIsChecked((prev) => !prev)} - className="text-body8 cursor-pointer"> - 거래 내용을 확인하였으며, 동의합니다 +
+
+
+
+ setIsChecked((prev) => !prev)} + className="subscribed_check cursor-pointer" + /> +
+
setIsChecked((prev) => !prev)} + className="text-body8 cursor-pointer"> + 거래 내용을 확인하였으며, 동의합니다 +
+
+ {Subscribestatus === 'unsubscribing' ? ( +
+ 실 결제는 구독만료일에 결제 됩니다. +
+ ) : null}
@@ -103,10 +117,10 @@ const PaymentCheckPage = () => { // isChecked && requestBillingAuth(); bililngRequest(); }} - className={` my-10 py-[18px] w-full rounded-[12px] flex items-center justify-center text-title1 + className={`payment_start my-10 py-[18px] w-full rounded-[12px] flex items-center justify-center text-title1 ${isChecked ? 'cursor-pointer bg-gradient2 text-white' : 'bg-gray100 text-gray300'} `}> - 0원 결제하기 + {couponLength > 0 ?
0원 결제하기
:
4,900 원 결제하기
}
); diff --git a/src/app/product/detail/art/[id]/page.tsx b/src/app/product/detail/art/[id]/page.tsx index ff23a3f..37a677e 100644 --- a/src/app/product/detail/art/[id]/page.tsx +++ b/src/app/product/detail/art/[id]/page.tsx @@ -23,6 +23,13 @@ const ArtDetailpage = (props: { params: { id: string } }) => { const [localData, setLocalData] = useState(data); const { handleBookmarkClick } = BookmarkUpdate({ data, localData, setLocalData }); + const sortComponents: { [key: string]: JSX.Element } = { + news: , + report: , + profit: , + detail: + }; + return (
@@ -33,17 +40,7 @@ const ArtDetailpage = (props: { params: { id: string } }) => { /> - - {sort === 'news' ? ( - - ) : sort === 'report' ? ( - - ) : sort === 'profit' ? ( - - ) : sort === 'detail' ? ( - - ) : undefined} - + {sortComponents[sort]}
); }; diff --git a/src/app/product/detail/building/[id]/page.tsx b/src/app/product/detail/building/[id]/page.tsx index 1beae8a..0ab24e5 100644 --- a/src/app/product/detail/building/[id]/page.tsx +++ b/src/app/product/detail/building/[id]/page.tsx @@ -27,6 +27,20 @@ const BuildingDetailpage = (props: { params: { id: string } }) => { const [localData, setLocalData] = useState(data); const { handleBookmarkClick } = BookmarkUpdate({ data, localData, setLocalData }); + const sortComponents: { [key: string]: JSX.Element } = { + public: , + news: , + report: , + profit: , + detail: ( + + ) + }; + return (
@@ -37,23 +51,8 @@ const BuildingDetailpage = (props: { params: { id: string } }) => { /> - - {sort === 'public' ? ( - - ) : sort === 'news' ? ( - - ) : sort === 'report' ? ( - - ) : sort === 'profit' ? ( - - ) : sort === 'detail' ? ( - - ) : undefined} - + + {sortComponents[sort]}
); }; diff --git a/src/app/product/detail/content/[id]/page.tsx b/src/app/product/detail/content/[id]/page.tsx index 5d1c5bf..90dcaa2 100644 --- a/src/app/product/detail/content/[id]/page.tsx +++ b/src/app/product/detail/content/[id]/page.tsx @@ -26,6 +26,21 @@ const ContentDetailpage = (props: { params: { id: string } }) => { const { handleBookmarkClick } = BookmarkUpdate({ data, localData, setLocalData }); + const sortComponents: { [key: string]: JSX.Element } = { + news: , + report: , + profit: ( + + ), + detail: ( + + ) + }; + return (
@@ -36,25 +51,8 @@ const ContentDetailpage = (props: { params: { id: string } }) => { /> - - {sort === 'news' ? ( - - ) : sort === 'report' ? ( - - ) : sort === 'profit' ? ( - - ) : sort === 'detail' ? ( - - ) : undefined} - + + {sortComponents[sort]}
); }; diff --git a/src/app/product/detail/cow/[id]/page.tsx b/src/app/product/detail/cow/[id]/page.tsx index c11787b..f61aaca 100644 --- a/src/app/product/detail/cow/[id]/page.tsx +++ b/src/app/product/detail/cow/[id]/page.tsx @@ -25,6 +25,13 @@ const CowDetailpage = (props: { params: { id: string } }) => { const [localData, setLocalData] = useState(data); const { handleBookmarkClick } = BookmarkUpdate({ data, localData, setLocalData }); + const sortComponents: { [key: string]: JSX.Element } = { + news: , + report: , + profit: , + detail: + }; + return (
@@ -35,17 +42,7 @@ const CowDetailpage = (props: { params: { id: string } }) => { /> - - {sort === 'news' ? ( - - ) : sort === 'report' ? ( - - ) : sort === 'profit' ? ( - - ) : sort === 'detail' ? ( - - ) : undefined} - + {sortComponents[sort]}
); }; diff --git a/src/app/product/detail/music/[id]/page.tsx b/src/app/product/detail/music/[id]/page.tsx index 3ea45d0..ff5c45c 100644 --- a/src/app/product/detail/music/[id]/page.tsx +++ b/src/app/product/detail/music/[id]/page.tsx @@ -25,6 +25,13 @@ const MusicDetailpage = (props: { params: { id: string } }) => { const [localData, setLocalData] = useState(data); const { handleBookmarkClick } = BookmarkUpdate({ data, localData, setLocalData }); + const sortComponents: { [key: string]: JSX.Element } = { + news: , + report: , + profit: , + detail: + }; + return (
@@ -35,18 +42,7 @@ const MusicDetailpage = (props: { params: { id: string } }) => { /> - - - {sort === 'news' ? ( - - ) : sort === 'report' ? ( - - ) : sort === 'profit' ? ( - - ) : sort === 'detail' ? ( - - ) : undefined} - + {sortComponents[sort]}
); }; diff --git a/src/app/product/page.tsx b/src/app/product/page.tsx index 5557236..d8942d1 100644 --- a/src/app/product/page.tsx +++ b/src/app/product/page.tsx @@ -1,7 +1,9 @@ import Navbar from '@/components/common/Navbar'; import Product from './(product)/Product'; import { IProductCommon, IReport, ISummaryData } from '@/types/Diviend'; -import { cookies } from 'next/headers'; +import { getCookie } from '@/utils/serverCookies'; + +const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'https://api.moaguide.com'; const ProductPage = async ({ searchParams @@ -9,27 +11,22 @@ const ProductPage = async ({ params: { slug: string }; searchParams: { [key: string]: string | string[] | undefined }; }) => { - const getCookie = (key: string) => { - return cookies().get(key)?.value; - }; const token = getCookie('access_token') || ''; const pages = searchParams['page'] || 1; const subcategory = searchParams['subcategory'] || 'trade'; const sort = searchParams['sort'] || 'lastDivide_rate desc'; const category = searchParams['category'] || 'all'; - const buildingDiviedResponse = await fetch(`https://api.moaguide.com/summary`, { + + const buildingDiviedResponse = await fetch(`${API_BASE_URL}/summary`, { cache: 'no-store' }); - const buildingReportResponse = await fetch( - 'https://api.moaguide.com/summary/report/building', - { - cache: 'no-store' - } - ); + const buildingReportResponse = await fetch(`${API_BASE_URL}/summary/report/building`, { + cache: 'no-store' + }); const productDetailResponse = await fetch( - `https://api.moaguide.com/summary/list?category=${category}&subcategory=${subcategory}&sort=${sort}&page=${pages}&size=10`, + `${API_BASE_URL}/summary/list?category=${category}&subcategory=${subcategory}&sort=${sort}&page=${pages}&size=10`, { headers: { Authorization: `Bearer ${token} ` diff --git a/src/app/sign/(sign)/SignLayout.tsx b/src/app/sign/(sign)/SignLayout.tsx index 202a526..298f49b 100644 --- a/src/app/sign/(sign)/SignLayout.tsx +++ b/src/app/sign/(sign)/SignLayout.tsx @@ -109,7 +109,7 @@ const SignLayout = () => {
{errorMessage}
)} -
+
{ const handleSubmit = async () => { try { const verifyToken = getCookie('verify_token'); - if (!verifyToken) { throw new Error('Verify token이 없습니다.'); } - const authHeaders = { cookie: '', Verify: verifyToken }; - const response = await finalSignup(formData, authHeaders); - if (response === '회원가입 완료') { setModalType('signupComplete'); setOpen(true); + signupHistory(formData.email || ''); } } catch (error) { console.error('서버 요청 오류:', error); diff --git a/src/components/common/GnbWrapper.tsx b/src/components/common/GnbWrapper.tsx index eed8276..75a378e 100644 --- a/src/components/common/GnbWrapper.tsx +++ b/src/components/common/GnbWrapper.tsx @@ -9,7 +9,7 @@ const GnbWrapper = () => { const [isGnbHidden, setIsGnbHidden] = useState(false); useEffect(() => { const checkIfGnbShouldBeHidden = () => { - const pathsToHideGnb = ['/signup', '/sign', '/find', '/detail', '/login', '/quiz']; + const pathsToHideGnb = ['/signup', '/sign', '/find', '/login', '/quiz']; const shouldHideGnb = pathsToHideGnb.some((path) => pathname.includes(path)); setIsGnbHidden(shouldHideGnb); }; diff --git a/src/components/common/Navbar.tsx b/src/components/common/Navbar.tsx index bb624fc..9cd4d18 100644 --- a/src/components/common/Navbar.tsx +++ b/src/components/common/Navbar.tsx @@ -7,7 +7,7 @@ const Navbar = () => { const router = useRouter(); const pathname = usePathname(); return ( -
+
{ @@ -41,7 +41,7 @@ const Navbar = () => { router.push('/practicepage'); }} className={` desk:whitespace-nowrap px-4 py-3 flex-1 flex justify-center items-center cursor-pointer text-body5 desk2:text-heading4 - ${pathname === '/practicepage' ? 'text-black border-b-[2px] border-black' : 'text-gray300'} + ${pathname.startsWith('/learning') ? 'text-black border-b-[2px] border-black' : 'text-gray300'} `}> 학습하기
diff --git a/src/components/learning/FilteredContents.tsx b/src/components/learning/FilteredContents.tsx index 414b8a1..c2c8226 100644 --- a/src/components/learning/FilteredContents.tsx +++ b/src/components/learning/FilteredContents.tsx @@ -1,8 +1,11 @@ +import { useRouter } from 'next/navigation'; import Image from 'next/image'; import defaultImage from '../../../public/images/learning/learning_img.svg'; +import { FilteredResponse } from '@/types/filterArticle'; +import { getValidImageSrc } from '@/utils/checkImageProperty'; interface FilteredContentsProps { - contents: any[]; + contents: FilteredResponse['content']; total: number; page: number; size: number; @@ -16,23 +19,28 @@ const FilteredContents = ({ size, onPageChange, }: FilteredContentsProps) => { + const router = useRouter(); const totalPages = Math.ceil(total / size); const formatDate = (dateString: string) => { const date = new Date(dateString); - return date.toISOString().split('T')[0]; + return date.toISOString().split('T')[0]; }; return ( -
+
{contents.length > 0 ? ( contents.map((item) => ( -
+
router.push(`/learning/detail/${item.article.articleId}`)} + >
- {item.title}

- {item.title} + {item.article.title}

- {item.description} + {item.article.description || '설명 없음'}

- {formatDate(item.date)} - ❤ {item.likes} - 👁 {item.views} + {formatDate(item.article.date)} + ❤ {item.article.likes} + 👁 {item.article.views}
@@ -58,7 +66,6 @@ const FilteredContents = ({ )}
- {/* 페이지네이션 */} {totalPages > 1 && (
    diff --git a/src/components/learning/LatestNewsClipping.tsx b/src/components/learning/LatestNewsClipping.tsx index 27ad164..1f03213 100644 --- a/src/components/learning/LatestNewsClipping.tsx +++ b/src/components/learning/LatestNewsClipping.tsx @@ -4,8 +4,10 @@ import React, { useEffect, useState } from 'react'; import Image from 'next/image'; import defaultImage from '../../../public/images/learning/learning_img.svg'; import LatestNewsClippingSkeleton from '../skeleton/LatestNewsClippingSkeleton'; +import { useRouter } from 'next/navigation'; const LatestNewsClipping = ({ contents }: { contents: any[] }) => { + const router = useRouter(); const [isMobile, setIsMobile] = useState(null); useEffect(() => { @@ -34,7 +36,7 @@ const LatestNewsClipping = ({ contents }: { contents: any[] }) => { // 모바일 레이아웃
    {contents[0] && ( -
    +
    router.push(`/learning/detail/${contents[0].articleId}`)}> {contents[0].title} { )}
    {contents.slice(1, 5).map((content, index) => ( -
    +
    router.push(`/learning/detail/${content.articleId}`)}>
    { // 데스크톱 레이아웃
    {contents[0] && ( -
    +
    router.push(`/learning/detail/${contents[0].articleId}`)}>
    { )}
    {contents.slice(1, 5).map((content, index) => ( -
    +
    router.push(`/learning/detail/${content.articleId}`)}>
    { +const LearningPageClient = ({ initialData }: { initialData: OverviewResponse }) => { const [selectedType, setSelectedType] = useState(''); const [selectedCategory, setSelectedCategory] = useState(''); const [activeDropdown, setActiveDropdown] = useState(null); const [page, setPage] = useState(1); - - const fetchContentsWithPage = async () => { - const type = selectedType || 'all'; - const category = selectedCategory || 'all'; - const endpoint = `http://43.200.90.72/contents/list?type=${type}&category=${category}&page=${page}`; - const response = await fetch(endpoint); - if (!response.ok) throw new Error('API 호출 실패'); - return response.json(); - }; - + const { data, isLoading } = useQuery({ queryKey: ['contents', selectedType, selectedCategory, page], queryFn: fetchContentsWithPage, @@ -57,6 +50,14 @@ const LearningPageClient = ({ initialData }: { initialData: any }) => { setActiveDropdown(null); }; + const extractContents = (contents: Content[] | undefined) => + Array.isArray(contents) + ? contents.map((item) => ({ + ...item.article, + likedByMe: item.likedByMe, + })) + : []; + return (
    @@ -67,8 +68,8 @@ const LearningPageClient = ({ initialData }: { initialData: any }) => { objectFit="cover" className="w-full" /> -
    - +
    +
    + + + +
    + ); +}; + +export default ArticleDetailClientWrapper; \ No newline at end of file diff --git a/src/components/learning/article/ArticleDetailContent.tsx b/src/components/learning/article/ArticleDetailContent.tsx new file mode 100644 index 0000000..fd9f663 --- /dev/null +++ b/src/components/learning/article/ArticleDetailContent.tsx @@ -0,0 +1,94 @@ +import { getValidImageSrc } from "@/utils/checkImageProperty"; +import Image from "next/image"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; + +interface ContentProps { + text?: string; + title: string; + paywallUp?: string; + createdAt: string; + authorName: string; + imgLink: string | null; +} + +const ArticleDetailContent = ({ text, title, paywallUp, createdAt, authorName, imgLink }: ContentProps) => { + const isPremium = !!paywallUp; + const formattedPaywallUp = paywallUp ? paywallUp.split("\n") : []; + const router = useRouter(); + + const handleAccesstion = () => { + // 결제 페이지 이동 작업 + router.push('/'); + } + + return ( +
    +

    + {new Date(createdAt).toLocaleDateString()}
    + BY. {authorName} +

    + {title} + {isPremium ? ( + <> +
    + {formattedPaywallUp.slice(0, 4).map((line, index) => ( +

    + {line} +

    + ))} +
    + {formattedPaywallUp.slice(4, 7).map((line, index) => ( +

    + {line} +

    + ))} +
    +
    +
    +
    +

    + 투자 칼럼을 읽으며
    조각투자로 부의 길을 걸어보세요! +

    +

    + 모아가이드를 구독하고 자료를 이어서 받아보세요 +

    +
    + 3초만에 가입하고 계속 보기 +
    +
    + + ) : ( +
    + {text?.split("\n\n").map((paragraph, index) => ( +

    + {paragraph} +

    + ))} +
    + )} +
    + ); +}; + +export default ArticleDetailContent; \ No newline at end of file diff --git a/src/components/learning/article/ArticleDetailHeader.tsx b/src/components/learning/article/ArticleDetailHeader.tsx new file mode 100644 index 0000000..42f98d4 --- /dev/null +++ b/src/components/learning/article/ArticleDetailHeader.tsx @@ -0,0 +1,33 @@ +import Image from 'next/image'; +import defaultImage from '../../../../public/images/learning/learning_img.svg'; +import { getValidImageSrc } from '@/utils/checkImageProperty'; + +interface HeaderProps { + categoryName: string; + title: string; + createdAt: string; + authorName: string; + imgLink: string | null; +} + +const ArticleDetailHeader = ({ categoryName, title, createdAt, authorName, imgLink }: HeaderProps) => { + return ( +
    + {title} + {/* todo: 모바일 작업 */} +
    +

    {categoryName}

    +
    +

    {title}

    +
    +
    + ); +}; + +export default ArticleDetailHeader; \ No newline at end of file diff --git a/src/components/learning/article/BackButton.tsx b/src/components/learning/article/BackButton.tsx new file mode 100644 index 0000000..99ed31e --- /dev/null +++ b/src/components/learning/article/BackButton.tsx @@ -0,0 +1,26 @@ +'use client'; + +import { useRouter } from 'next/navigation'; + +const BackButton = () => { + const router = useRouter(); + + const handleBack = () => { + router.back(); + }; + + return ( +
    + +
    + ); +}; + +export default BackButton; \ No newline at end of file diff --git a/src/components/learning/article/RelatedArticles.tsx b/src/components/learning/article/RelatedArticles.tsx new file mode 100644 index 0000000..4945e5d --- /dev/null +++ b/src/components/learning/article/RelatedArticles.tsx @@ -0,0 +1,76 @@ +import { useEffect, useState } from "react"; +import { RelatedArticle } from "@/types/learning"; +import Image from "next/image"; +import { getRelatedArticles } from "@/factory/Article/GetArticle"; +import { getValidImageSrc } from "@/utils/checkImageProperty"; + +interface RelatedArticlesProps { + articleId: number; +} + +const RelatedArticles = ({ articleId }: RelatedArticlesProps) => { + const [relatedArticles, setRelatedArticles] = useState(""); + + useEffect(() => { + const fetchData = async () => { + try { + const data = await getRelatedArticles(articleId); + setRelatedArticles(data); + } catch (error) { + console.error("Failed to fetch related articles:", error); + setRelatedArticles("관련 컨텐츠가 없습니다."); + } + }; + + fetchData(); + }, [articleId]); + + // 데이터가 없을 때 처리 + if (typeof relatedArticles === "string") { + return ( +
    +

    + 관련 콘텐츠 +

    +

    {relatedArticles}

    +
    + ); + } + + return ( +
    +

    + 관련 콘텐츠 +

    +
    + {relatedArticles.map((item) => ( +
    +
    + {item.article.title} +
    +
    +

    {item.article.title}

    +
    + {new Date(item.article.createdAt).toLocaleDateString()} +
    + ❤️ {item.article.likes} + 👁️ {item.article.views} +
    +
    +
    +
    + ))} +
    +
    + ); +}; + +export default RelatedArticles; \ No newline at end of file diff --git a/src/factory/Article/GetArticle.ts b/src/factory/Article/GetArticle.ts new file mode 100644 index 0000000..f631302 --- /dev/null +++ b/src/factory/Article/GetArticle.ts @@ -0,0 +1,52 @@ +import { axiosInstance, basicAxiosInstance } from "@/service/axiosInstance"; +import { FilteredResponse } from "@/types/filterArticle"; +import { ArticleDetail, RelatedArticle } from "@/types/learning"; + +export const getArticleDetail = async (articleId: number): Promise => { + console.log(articleId); + try { + const response = await axiosInstance.get( + `articles/detail/${articleId}` + ); + return response.data; + } catch (error) { + if (!axiosInstance.defaults.headers.common['Authorization']) { + return null; + } + throw error; + } +}; + +export const getRelatedArticles = async (articleId: number): Promise => { + try { + const response = await axiosInstance.get( + `articles/detail/${articleId}/related` + ); + return response.data; + } catch (error) { + console.error('Failed to fetch related articles:', error); + throw error; + } +}; + +export const fetchContentsWithPage = async ({ queryKey }: { queryKey: [string, string, string, number] }) => { + const [, type, category, page] = queryKey; + + try { + const response = await basicAxiosInstance.get( + `/contents/list`, + { + params: { + type: type || 'all', + category: category || 'all', + page: page || 1, + }, + } + ); + + return response.data; + } catch (error) { + console.error('API 호출 실패:', error); + throw new Error('API 호출 실패'); + } +}; diff --git a/src/factory/Payment/firstpaymentcheck.ts b/src/factory/Payment/firstpaymentcheck.ts new file mode 100644 index 0000000..929dfad --- /dev/null +++ b/src/factory/Payment/firstpaymentcheck.ts @@ -0,0 +1,22 @@ +import { axiosInstance } from '@/service/axiosInstance'; +import { useQuery } from '@tanstack/react-query'; + +const firstpaymentcheck = async () => { + try { + const { data } = await axiosInstance.get('/billing/check/first'); + return data; + } catch (error) { + console.error('Error fetching payment:'); + throw new Error('error paymentcheck.'); + } +}; + +export const useFirstPaymentCheck = () => { + const { data, isLoading } = useQuery({ + queryKey: ['firstpaymentcheck'], + queryFn: firstpaymentcheck, + retry: 1 + }); + + return { data, isLoading }; +}; diff --git a/src/service/auth.ts b/src/service/auth.ts index 2a46484..6e52bc7 100644 --- a/src/service/auth.ts +++ b/src/service/auth.ts @@ -63,37 +63,40 @@ export const finalSignup = async (formData: any, authHeaders: AuthHeaders) => { } }; +export const signupHistory = async (email: string) => { + try { + const response = await basicAxiosInstance.post(`/signup/history?email=${email}`); + return response.data; + } catch (error) { + console.error('history 요청 오류:', error); + throw error; + } +}; + export const login = async (email: string, password: string, rememberMe: boolean) => { try { const formData = new FormData(); formData.append('email', email); formData.append('password', password); - const url = `/login?rememberMe=${rememberMe}`; - const response = await basicAxiosInstance.post(url, formData, { headers: { 'Content-Type': 'multipart/form-data' } }); - const token = response.headers['authorization'] || response.headers['Authorization']; - if (!token) { throw new Error( '토큰을 찾을 수 없습니다. 헤더에서 Authorization이 존재하지 않습니다.' ); } - const accessToken = token.replace('Bearer ', ''); setToken(accessToken); - const { setMember } = useMemberStore.getState(); const userInfo = response.data.user; if (!userInfo) { throw new Error('사용자 정보를 응답에서 찾을 수 없습니다.'); } - setMember({ memberEmail: userInfo.email, memberNickName: userInfo.nickname, diff --git a/src/types/filterArticle.ts b/src/types/filterArticle.ts new file mode 100644 index 0000000..4c1941f --- /dev/null +++ b/src/types/filterArticle.ts @@ -0,0 +1,23 @@ +export interface FilteredArticle { + articleId: number; + title: string; + type: string; + isPremium: boolean; + views: number; + date: string; + likes: number; + description: string | null; + img_link: string | null; + } + + export interface FilteredContent { + likedByMe: boolean; + article: FilteredArticle; + } + + export interface FilteredResponse { + total: number; + size: number; + page: number; + content: FilteredContent[]; + } \ No newline at end of file diff --git a/src/types/learning.ts b/src/types/learning.ts new file mode 100644 index 0000000..0f05166 --- /dev/null +++ b/src/types/learning.ts @@ -0,0 +1,48 @@ +export interface Article { + articleId: number; + title: string; + type: string; + description: string | null; + imgLink: string | null; + categoryName: string; + premium: boolean; + } + + export interface Content { + likedByMe: boolean; + article: Article; + } + + export interface OverviewResponse { + newsContents: Content[]; + recentContents: Content[]; + popularContents: Content[]; + } + + export interface ArticleDetail { + articleId: number; + categoryName: string; + title: string; + authorName: string; + text: string; + paywallUp: string; + imgLink: string | null; + views: number; + likes: number; + createdAt: string; + } + + // 아티클 관련 정보 + export interface RelatedArticle { + likedByMe: boolean; + article: RelatedArticleContent; + } + + export interface RelatedArticleContent { + articleId: number; + title: string; + imgLink: string | null; + createdAt: string; + views: number; + likes: number; + } diff --git a/src/utils/checkImageProperty.ts b/src/utils/checkImageProperty.ts new file mode 100644 index 0000000..f589dbc --- /dev/null +++ b/src/utils/checkImageProperty.ts @@ -0,0 +1,11 @@ +import defaultImage from '../../public/images/learning/learning_img.svg'; + +export const getValidImageSrc = (imgLink: string | null) => { + if (!imgLink || imgLink === '테스트') { + return defaultImage; + } + if (imgLink.startsWith('http://') || imgLink.startsWith('https://')) { + return imgLink; + } + return defaultImage; + }; \ No newline at end of file diff --git a/src/utils/isfirstpaymentcheck.ts b/src/utils/isfirstpaymentcheck.ts new file mode 100644 index 0000000..23f0198 --- /dev/null +++ b/src/utils/isfirstpaymentcheck.ts @@ -0,0 +1,7 @@ +import { useFirstPaymentCheck } from '@/factory/Payment/firstpaymentcheck'; + +export const isfirstpaymentcheck = () => { + const { data } = useFirstPaymentCheck(); + + return data?.status; +}; diff --git a/src/utils/serverCookies.ts b/src/utils/serverCookies.ts new file mode 100644 index 0000000..6f9c241 --- /dev/null +++ b/src/utils/serverCookies.ts @@ -0,0 +1,5 @@ +import { cookies } from 'next/headers'; + +export const getCookie = (key: string): string | undefined => { + return cookies().get(key)?.value; +}; diff --git a/tailwind.config.ts b/tailwind.config.ts index c73dc17..f8f4dd4 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -100,6 +100,6 @@ const config: Config = { } } }, - plugins: [require('@tailwindcss/line-clamp')] + plugins: [] }; export default config;