diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 5f35e97..43f4462 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -22,7 +22,7 @@ jobs: elif [ "${{ github.ref }}" = "refs/heads/develop" ]; then echo "username=parknari02" >> $GITHUB_OUTPUT echo "repo=STARLIGHT_FE" >> $GITHUB_OUTPUT - echo "branch=main" >> $GITHUB_OUTPUT + echo "branch=develop" >> $GITHUB_OUTPUT fi - name: Pushes to another repository diff --git a/email-template.html b/email-template.html new file mode 100644 index 0000000..c4786e5 --- /dev/null +++ b/email-template.html @@ -0,0 +1,980 @@ + + + + + + + + 이메일 전달용 + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + diff --git a/next.config.ts b/next.config.ts index 5a59ab4..dc4bad4 100644 --- a/next.config.ts +++ b/next.config.ts @@ -6,6 +6,10 @@ const nextConfig = { hostname: 'kr.object.ncloudstorage.com', pathname: '/**', }, + { + hostname: 'starlight-s3.kr.object.ncloudstorage.com', + pathname: '/**', + }, { hostname: 'k.kakaocdn.net', pathname: '/**', diff --git a/public/images/landing/homeimage.png b/public/images/landing/homeimage.png index 91f4217..4632efe 100644 Binary files a/public/images/landing/homeimage.png and b/public/images/landing/homeimage.png differ diff --git a/public/images/landing/landing_expert1.png b/public/images/landing/landing_expert1.png index 5abf570..9d5623f 100644 Binary files a/public/images/landing/landing_expert1.png and b/public/images/landing/landing_expert1.png differ diff --git a/public/images/landing/landing_expert2.png b/public/images/landing/landing_expert2.png index 0e9ee14..7d2f7f2 100644 Binary files a/public/images/landing/landing_expert2.png and b/public/images/landing/landing_expert2.png differ diff --git a/public/images/landing/landing_expert3.png b/public/images/landing/landing_expert3.png index 2b70cbd..67f7a2c 100644 Binary files a/public/images/landing/landing_expert3.png and b/public/images/landing/landing_expert3.png differ diff --git a/src/app/_components/common/Button.tsx b/src/app/_components/common/Button.tsx index 66661b2..01151a1 100644 --- a/src/app/_components/common/Button.tsx +++ b/src/app/_components/common/Button.tsx @@ -2,7 +2,7 @@ import React from 'react'; interface ButtonProps { text: string; - icon?: React.ReactNode; // ← 아이콘 추가 + icon?: React.ReactNode; size?: 'S' | 'M' | 'L'; color?: 'primary' | 'secondary' | string; rounded?: string; @@ -10,6 +10,7 @@ interface ButtonProps { className?: string; disabled?: boolean; type?: 'button' | 'submit' | 'reset'; + iconPosition?: 'left' | 'right' | string; } function Button({ @@ -17,6 +18,7 @@ function Button({ icon, size = 'M', color = 'primary', + iconPosition = 'right', rounded = '', onClick, className = '', @@ -59,8 +61,9 @@ function Button({ disabled={disabled} className={`flex items-center justify-center rounded-[8px] font-medium transition ${paddingClasses[size]} ${textClass} ${colorClasses} ${rounded} ${className} ${disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'} `} > + {icon && iconPosition === 'left' && {icon}} {text} - {icon && {icon}} + {icon && iconPosition === 'right' && {icon}} ); } diff --git a/src/app/_components/landing/Landing.tsx b/src/app/_components/landing/Landing.tsx index 56a2136..80e176c 100644 --- a/src/app/_components/landing/Landing.tsx +++ b/src/app/_components/landing/Landing.tsx @@ -3,13 +3,21 @@ import Image from 'next/image'; import useStickyCta from '@/hooks/useStickyCta'; import StickyBar from './StickyBar'; import { useRouter } from 'next/navigation'; -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import UploadReportModal from '../common/UploadReportModal'; +import LoginModal from '../common/LoginModal'; +import { useAuthStore } from '@/store/auth.store'; const Landing = () => { const { ctaRef, showSticky } = useStickyCta(); const router = useRouter(); const [isModalOpen, setIsModalOpen] = useState(false); + const [isLoginModalOpen, setIsLoginModalOpen] = useState(false); + const { isAuthenticated, checkAuth } = useAuthStore(); + + useEffect(() => { + checkAuth(); + }, [checkAuth]); return ( <> @@ -23,13 +31,25 @@ const Landing = () => {
@@ -52,6 +72,11 @@ const Landing = () => { setIsModalOpen(false)} /> )} + setIsLoginModalOpen(false)} + /> + ); diff --git a/src/app/_components/landing/LandingChecklist.tsx b/src/app/_components/landing/LandingChecklist.tsx index e87109d..ac72c9c 100644 --- a/src/app/_components/landing/LandingChecklist.tsx +++ b/src/app/_components/landing/LandingChecklist.tsx @@ -2,12 +2,15 @@ import Image from 'next/image'; import ArrowIcon from '@/assets/icons/chevron_right.svg'; import { useRouter } from 'next/navigation'; +import { useCountdown } from '@/hooks/useCountdown'; const LandingChecklist = () => { const router = useRouter(); + const timeLeft = useCountdown('2026-02-28T23:59:59'); + return ( -
-
+
+

2026년 지원사업,
@@ -16,16 +19,22 @@ const LandingChecklist = () => {

- 2026 지원사업 대비 모든 기능 무료 프로모션 (~1/10) + 2026 지원사업 대비 모든 기능 무료 프로모션 (~2/28)

- {['10일', '4시간', '19분', '20초'].map((time) => ( + {[ + { value: timeLeft.days, label: '일' }, + { value: timeLeft.hours, label: '시간' }, + { value: timeLeft.minutes, label: '분' }, + { value: timeLeft.seconds, label: '초' }, + ].map((item, index) => (
- {time} + {item.value} + {item.label}
))}
diff --git a/src/app/_components/landing/LandingPaySection.tsx b/src/app/_components/landing/LandingPaySection.tsx index 032eac7..0af2b37 100644 --- a/src/app/_components/landing/LandingPaySection.tsx +++ b/src/app/_components/landing/LandingPaySection.tsx @@ -1,70 +1,128 @@ 'use client'; import Check from '@/assets/icons/big_check.svg'; +import BCheck from '@/assets/icons/black_check.svg'; import RightIcon from '@/assets/icons/white_right.svg'; +import Polygon from '@/assets/icons/polygon.svg'; import { useRouter } from 'next/navigation'; +import { useState, useEffect } from 'react'; +import LoginModal from '../common/LoginModal'; +import { useAuthStore } from '@/store/auth.store'; const LandingPaySection = () => { const router = useRouter(); + const [isLoginModalOpen, setIsLoginModalOpen] = useState(false); + const { isAuthenticated, checkAuth } = useAuthStore(); + + useEffect(() => { + checkAuth(); + }, [checkAuth]); return ( -
-
- - - - - - - 300,000원{' '} - - / 시간당 비대면 멘토링 - - +
+
+ + 멘토링 비용{' '} + + + + 30만 원짜리 멘토링, 80% 비용 + 절감 + + + + 시간당 30만원이던 멘토링을 비대면 구조로 전환해 비용을 합리적으로 + 만들었어요.
-
-
-

Lite 이용권의 기능

+
+
+
+

+ 시간당 대면 멘토링 +

+
+ +
+

+ 300,000원 + + / 1회 + +

+ +
+ +

+ 전문가 비대면 멘토링 1회 +

+
+ +
+
  • 멘토의 사전 문서 검토 부족으로 인한 시간 소요
  • +
  • 멘토링 시간 대비 핵심 피드백 시간 부족
  • +
  • 높은 비용 대비 얻는 피드백의 효율이 낮음
  • +
    +
    + -
    -

    - 49,000원{' '} - - / 시간당 비대면 멘토링 - -

    - -
    - -

    전문가 비대면 멘토링 1회

    +
    +
    +

    + 스타라이트의 비대면 멘토링 +

    -
    -
  • 사업계획서 PDF/텍스트 기반 심층 검토
  • -
  • 강·약점 구체 코멘트
  • -
  • AI 리포트 무제한 포함
  • +
    +

    + 0원 + + / 1회 49,000원 + +

    + +
    + +

    전문가 비대면 멘토링 1회

    +
    + +
    +
  • 사업계획서 PDF/텍스트 기반 심층 검토
  • +
  • 강·약점 구체 코멘트
  • +
  • AI 리포트 무제한 포함
  • +
    +
    - - -
    -

    - *전문가 대면 멘토링 평균 약 30만 원 수준에서 구조 개선을 통해 최대 - 약 4.9만 원대까지 절감했습니다. -

    -

    - *전문가 대면 멘토링 평균 비용은 1시간 기준 일반적인 시장 시세를 - 참고하였습니다. -

    -
    + + + setIsLoginModalOpen(false)} + /> + +
    +

    + *전문가 대면 멘토링 평균 약 30만 원 수준에서 구조 개선을 통해 최대 약 + 4.9만 원대까지 절감했습니다. +

    +

    + *전문가 대면 멘토링 평균 비용은 1시간 기준 일반적인 시장 시세를 + 참고하였습니다. +

    ); diff --git a/src/app/_components/landing/LandingRelation.tsx b/src/app/_components/landing/LandingRelation.tsx index 1372aec..b431e85 100644 --- a/src/app/_components/landing/LandingRelation.tsx +++ b/src/app/_components/landing/LandingRelation.tsx @@ -39,6 +39,15 @@ const LandingRelation = () => { return (
    + 랜딩 관련기관 +
    관련 기관 @@ -70,15 +79,6 @@ const LandingRelation = () => { ))}
    - - 랜딩 관련기관
    ); }; diff --git a/src/app/_components/landing/StickyBar.tsx b/src/app/_components/landing/StickyBar.tsx index e9f239d..5e15984 100644 --- a/src/app/_components/landing/StickyBar.tsx +++ b/src/app/_components/landing/StickyBar.tsx @@ -1,8 +1,10 @@ 'use client'; import { useRouter } from 'next/navigation'; -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import UploadReportModal from '../common/UploadReportModal'; +import LoginModal from '../common/LoginModal'; +import { useAuthStore } from '@/store/auth.store'; interface StickyBarProps { show: boolean; @@ -11,6 +13,12 @@ interface StickyBarProps { const StickyBar = ({ show }: StickyBarProps) => { const router = useRouter(); const [isModalOpen, setIsModalOpen] = useState(false); + const [isLoginModalOpen, setIsLoginModalOpen] = useState(false); + const { isAuthenticated, checkAuth } = useAuthStore(); + + useEffect(() => { + checkAuth(); + }, [checkAuth]); return ( <>
    {
    @@ -41,6 +61,7 @@ const StickyBar = ({ show }: StickyBarProps) => { {isModalOpen && ( setIsModalOpen(false)} /> )} + setIsLoginModalOpen(false)} /> ); }; diff --git a/src/app/business/components/editor/GeneralSection.tsx b/src/app/business/components/editor/GeneralSection.tsx index 613f101..691f9f7 100644 --- a/src/app/business/components/editor/GeneralSection.tsx +++ b/src/app/business/components/editor/GeneralSection.tsx @@ -24,7 +24,7 @@ const GeneralSection = ({ editor, onEditorFocus }: GeneralSectionProps) => { onEditorFocus(editor)} - className="prose max-w-none cursor-text placeholder:text-gray-400 focus:outline-none [&_img]:h-auto [&_img]:max-h-[400px] [&_img]:max-w-full [&_img]:object-contain" + className="prose max-w-none cursor-text placeholder:text-gray-400 focus:outline-none [&_img]:h-auto [&_img]:max-w-full [&_img]:object-contain" /> )} diff --git a/src/app/business/components/editor/OverviewSection.tsx b/src/app/business/components/editor/OverviewSection.tsx index b82d029..f7d0d92 100644 --- a/src/app/business/components/editor/OverviewSection.tsx +++ b/src/app/business/components/editor/OverviewSection.tsx @@ -88,7 +88,7 @@ const OverviewSection = ({ onEditorFocus(editorFeatures)} - className="prose max-w-none cursor-text placeholder:text-gray-400 focus:outline-none [&_img]:h-auto [&_img]:max-h-[400px] [&_img]:max-w-full [&_img]:object-contain" + className="prose max-w-none cursor-text placeholder:text-gray-400 focus:outline-none [&_img]:h-auto [&_img]:max-w-full [&_img]:object-contain" placeholder="아이템의 핵심기능은 무엇이며, 어떤 기능을 구현·작동 하는지 설명해주세요." /> @@ -115,7 +115,7 @@ const OverviewSection = ({ onEditorFocus(editorSkills)} - className="prose max-w-none cursor-text placeholder:text-gray-400 focus:outline-none [&_img]:h-auto [&_img]:max-h-[400px] [&_img]:max-w-full [&_img]:object-contain" + className="prose max-w-none cursor-text placeholder:text-gray-400 focus:outline-none [&_img]:h-auto [&_img]:max-w-full [&_img]:object-contain" placeholder="아이템의 핵심기능은 무엇이며, 어떤 기능을 구현·작동 하는지 설명해주세요." /> @@ -141,7 +141,7 @@ const OverviewSection = ({ onEditorFocus(editorGoals)} - className="prose max-w-none cursor-text placeholder:text-gray-400 focus:outline-none [&_img]:h-auto [&_img]:max-h-[400px] [&_img]:max-w-full [&_img]:object-contain" + className="prose max-w-none cursor-text placeholder:text-gray-400 focus:outline-none [&_img]:h-auto [&_img]:max-w-full [&_img]:object-contain" placeholder="본 사업을 통해 달성하고 싶은 궁극적인 목표에 대해 설명" /> diff --git a/src/app/expert/components/MentorCard.tsx b/src/app/expert/components/MentorCard.tsx index 92288f9..5858b38 100644 --- a/src/app/expert/components/MentorCard.tsx +++ b/src/app/expert/components/MentorCard.tsx @@ -1,7 +1,9 @@ 'use client'; +import Button from '@/app/_components/common/Button'; import { MentorCardProps } from '@/types/expert/expert.props'; import Image from 'next/image'; import { useRouter } from 'next/navigation'; +import WhitePlus from '@/assets/icons/white_plus.svg'; type ExtraProps = { onApplied?: () => void; @@ -12,7 +14,7 @@ const MentorCard = ({ careers, tags, image, - workingperiod, + oneLineIntroduction, id, }: MentorCardProps & ExtraProps) => { const router = useRouter(); @@ -23,8 +25,7 @@ const MentorCard = ({ return (
    - {workingperiod}년 경력 + {oneLineIntroduction}
    -
    +
    {careers.map((career) => career.careerTitle).join(' / ')}
    -
    +
    {tags.map((tag, i) => (
    + +
    + ); }; diff --git a/src/app/expert/detail/components/ExpertDetailSidebar.tsx b/src/app/expert/detail/components/ExpertDetailSidebar.tsx index 09c1223..eb5bb3e 100644 --- a/src/app/expert/detail/components/ExpertDetailSidebar.tsx +++ b/src/app/expert/detail/components/ExpertDetailSidebar.tsx @@ -50,7 +50,7 @@ const ExpertDetailSidebar = ({ expert }: ExpertDetailSidebarProps) => { const ButtonIcon = shouldShowCreateButton ? WhitePlus : hasRequested - ? GrayPlus + ? GrayCheck : disabled && !isSelectedPlanOver70 ? GrayCheck : disabled diff --git a/src/app/globals.css b/src/app/globals.css index 7741acb..44cb7aa 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -7,7 +7,7 @@ @theme inline { /* Colors */ - --color-primary-50: #f8f6ff; + --color-primary-50: #eae5ff; --color-primary-100: #d2caff; --color-primary-200: #bdb1ff; --color-primary-300: #9f8dff; diff --git a/src/app/mypage/components/MyAccount.tsx b/src/app/mypage/components/MyAccount.tsx index 65dc11e..47bdd1d 100644 --- a/src/app/mypage/components/MyAccount.tsx +++ b/src/app/mypage/components/MyAccount.tsx @@ -1,18 +1,15 @@ 'use client'; import Image from 'next/image'; -import React, { useState } from 'react'; -import PayHistoryModal from './PayHistoryModal'; import { useUserStore } from '@/store/user.store'; import Naver from '@/assets/icons/naver_profile.svg'; import KaKao from '@/assets/icons/kakao_profile.svg'; const MyAccount = () => { - const [isModal, setIsModal] = useState(false); const { user } = useUserStore(); return ( -
    -
    +
    +
    내 계정
    @@ -49,29 +46,6 @@ const MyAccount = () => {
    - -
    -
    -
    요금제 관리
    - -
    - -
    - -
    -
    Lite 요금제
    -
    - 잔여횟수 총 1/5회 -
    -
    -
    - - {isModal && setIsModal(false)} />}
    ); }; diff --git a/src/app/page.tsx b/src/app/page.tsx index 772740c..74074e8 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -3,6 +3,7 @@ import LandingBlackSection from './_components/landing/LandingBlackSection'; import LandingChecklist from './_components/landing/LandingChecklist'; import LandingPaySection from './_components/landing/LandingPaySection'; import LandingRelation from './_components/landing/LandingRelation'; +//워크플로우 테스트 const page = () => { return ( diff --git a/src/app/pay/complete/page.tsx b/src/app/pay/complete/page.tsx index 6febf70..8430769 100644 --- a/src/app/pay/complete/page.tsx +++ b/src/app/pay/complete/page.tsx @@ -66,7 +66,23 @@ function PayCompleteInner() { return; } - setState('FAIL'); + // setState('FAIL'); + + setState('LOADING'); + // 프로모션 기간 임시 결제완료 처리 + const dummyData: OrderConfirmResponseDto = { + buyerId: 0, + paymentKey: 'dummy-payment-key', + orderId: 'dummy-order-id', + amount: 0, + status: 'PAID', + approvedAt: Date.now(), + receiptUrl: null, + method: 'CARD', + provider: null, + }; + setData(dummyData); + setState('SUCCESS'); }, [searchParams]); useEffect(() => { diff --git a/src/app/pay/page.tsx b/src/app/pay/page.tsx index d84a7d9..ca74d88 100644 --- a/src/app/pay/page.tsx +++ b/src/app/pay/page.tsx @@ -1,6 +1,7 @@ 'use client'; import Script from 'next/script'; +import { useRouter } from 'next/navigation'; import Button from '../_components/common/Button'; import OrderDetails from './components/OrderDetails'; import PaymentAmount from './components/PaymentAmount'; @@ -17,6 +18,7 @@ const EXPERT_PRODUCT_CODE: UsageProductCode = 'AI_REPORT_1'; const Page = () => { const { selectedMentor } = useExpertStore(); + const router = useRouter(); const planId = useBusinessStore((s) => s.planId); const expertId = selectedMentor?.id ?? null; @@ -35,14 +37,21 @@ const Page = () => { return; } - try { - await startPayment({ - productCode: EXPERT_PRODUCT_CODE, - method: DEFAULT_METHOD, - }); - } catch (e) { - console.error('결제 시작 에러:', e); - alert('결제를 시작하는 데 실패했습니다. 다시 시도해 주세요.'); + // 프로모션 기간동안 + // try { + // await startPayment({ + // productCode: EXPERT_PRODUCT_CODE, + // method: DEFAULT_METHOD, + // }); + // } catch (e) { + // console.error('결제 시작 에러:', e); + // alert('결제를 시작하는 데 실패했습니다. 다시 시도해 주세요.'); + // } + + if (planId != null && expertId != null) { + router.push(`/pay/complete?planId=${planId}&expertId=${expertId}`); + } else { + router.push('/pay/complete'); } }; diff --git a/src/assets/icons/black_check.svg b/src/assets/icons/black_check.svg new file mode 100644 index 0000000..c006b30 --- /dev/null +++ b/src/assets/icons/black_check.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/assets/icons/polygon.svg b/src/assets/icons/polygon.svg new file mode 100644 index 0000000..0e62b09 --- /dev/null +++ b/src/assets/icons/polygon.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/hooks/useCountdown.ts b/src/hooks/useCountdown.ts new file mode 100644 index 0000000..ea9b1bc --- /dev/null +++ b/src/hooks/useCountdown.ts @@ -0,0 +1,39 @@ +import { useState, useEffect } from 'react'; + +const calculateTimeLeft = (targetDate: string) => { + if (typeof window === 'undefined') { + return { days: 0, hours: 0, minutes: 0, seconds: 0 }; + } + + const target = new Date(targetDate).getTime(); + const now = Date.now(); + const diff = target - now; + + if (diff > 0) { + return { + days: Math.floor(diff / (1000 * 60 * 60 * 24)), + hours: Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)), + minutes: Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)), + seconds: Math.floor((diff % (1000 * 60)) / 1000), + }; + } + + return { days: 0, hours: 0, minutes: 0, seconds: 0 }; +}; + +export const useCountdown = (targetDate: string) => { + const [timeLeft, setTimeLeft] = useState(() => calculateTimeLeft(targetDate)); + + useEffect(() => { + const updateTimer = () => { + setTimeLeft(calculateTimeLeft(targetDate)); + }; + + updateTimer(); + const interval = setInterval(updateTimer, 1000); + + return () => clearInterval(interval); + }, [targetDate]); + + return timeLeft; +}; diff --git a/src/lib/business/converter/editorToHtml.ts b/src/lib/business/converter/editorToHtml.ts index 1746326..2bcfff5 100644 --- a/src/lib/business/converter/editorToHtml.ts +++ b/src/lib/business/converter/editorToHtml.ts @@ -77,11 +77,30 @@ export const convertToHtml = (node: JSONNode | null | undefined): string => { return `${content}`; } + // editorToHtml.ts 수정 + if (node.type === 'bulletList') { const items = (node.content || []) .map((item) => { - const itemContent = (item.content || []).map((child) => convertToHtml(child)).join(''); - return itemContent ? `
  • ${itemContent}
  • ` : ''; + const itemContent = (item.content || []) + .map((child) => { + // 빈 paragraph는 빈 문자열로 변환 (빈 줄 방지) + if (child.type === 'paragraph') { + const paragraphContent = (child.content || []) + .map((c) => convertToHtml(c)) + .join(''); + // 빈 paragraph는 빈 문자열 반환 (불릿 점은 유지되지만 빈 줄은 생성 안 됨) + if (!paragraphContent || paragraphContent.trim() === '') { + return ''; + } + return `

    ${paragraphContent}

    `; + } + return convertToHtml(child); + }) + .join(''); + + // 리스트 아이템은 내용이 없어도 유지 (빈 불릿 점 표시) + return `
  • ${itemContent}
  • `; }) .filter(Boolean); const listStyle = 'list-style-type: disc; padding-left: 1.5rem; margin: 0 0 0.75rem 0;'; @@ -91,8 +110,22 @@ export const convertToHtml = (node: JSONNode | null | undefined): string => { if (node.type === 'orderedList') { const items = (node.content || []) .map((item) => { - const itemContent = (item.content || []).map((child) => convertToHtml(child)).join(''); - return itemContent ? `
  • ${itemContent}
  • ` : ''; + const itemContent = (item.content || []) + .map((child) => { + if (child.type === 'paragraph') { + const paragraphContent = (child.content || []) + .map((c) => convertToHtml(c)) + .join(''); + if (!paragraphContent || paragraphContent.trim() === '') { + return ''; + } + return `

    ${paragraphContent}

    `; + } + return convertToHtml(child); + }) + .join(''); + + return `
  • ${itemContent}
  • `; }) .filter(Boolean); const listStyle = 'list-style-type: decimal; padding-left: 1.5rem; margin: 0 0 0.75rem 0;'; @@ -205,13 +238,13 @@ export const convertToHtml = (node: JSONNode | null | undefined): string => { // width와 height가 있으면 그대로 사용, 없으면 기본 스타일 사용 let style = ''; if (width && height) { - style = `width: ${width}px; height: ${height}px;`; + style = `width: ${width}px; height: ${height}px; object-fit: contain;`; } else if (width) { - style = `width: ${width}px; height: auto;`; + style = `width: ${width}px; height: auto; object-fit: contain;`; } else if (height) { - style = `width: auto; height: ${height}px;`; + style = `width: auto; height: ${height}px; object-fit: contain;`; } else { - style = 'max-width: 400px; height: auto;'; + style = 'max-width: 400px; height: auto; object-fit: contain;'; } // 캡션이 있으면 포함 diff --git a/src/lib/business/editor/editorConstants.ts b/src/lib/business/editor/editorConstants.ts index 55dddab..6885ba9 100644 --- a/src/lib/business/editor/editorConstants.ts +++ b/src/lib/business/editor/editorConstants.ts @@ -15,6 +15,7 @@ import { ResizableImage, SelectTableOnBorderClick, EnsureTrailingParagraph, + CleanEmptyParagraphsInListItems, } from './extensions'; import { uploadImage } from '@/lib/imageUpload'; import { getImageDimensions, clampImageDimensions } from '@/lib/getImageDimensions'; @@ -36,6 +37,7 @@ export const COMMON_EXTENSIONS = [ TableCell, SelectTableOnBorderClick, EnsureTrailingParagraph, + CleanEmptyParagraphsInListItems, ]; // 간단한 에디터 확장 (하이라이트, 볼드, 색상만 가능, 헤딩/표/이미지 비활성화) diff --git a/src/lib/business/editor/extensions.ts b/src/lib/business/editor/extensions.ts index 7507668..7bc002a 100644 --- a/src/lib/business/editor/extensions.ts +++ b/src/lib/business/editor/extensions.ts @@ -998,7 +998,7 @@ export const SelectTableOnBorderClick = Extension.create({ }, }); -// 문서 끝에 항상 빈 paragraph 유지 +// 표와 이미지 다음에만 빈 paragraph 유지 export const EnsureTrailingParagraph = Extension.create({ name: 'ensure-trailing-paragraph', addProseMirrorPlugins() { @@ -1022,8 +1022,54 @@ export const EnsureTrailingParagraph = Extension.create({ return null; } - const tr = newState.tr.insert(doc.content.size, paragraphType.create()); - return tr; + // 표나 이미지 다음에만 paragraph 추가 + if (lastChild.type.name === 'table' || lastChild.type.name === 'image') { + const tr = newState.tr.insert(doc.content.size, paragraphType.create()); + return tr; + } + + return null; + }, + }), + ]; + }, +}); + +// 리스트 아이템 내부의 빈 paragraph 제거 +export const CleanEmptyParagraphsInListItems = Extension.create({ + name: 'clean-empty-paragraphs-in-list-items', + addProseMirrorPlugins() { + return [ + new Plugin({ + appendTransaction: (transactions, oldState, newState) => { + if (!transactions.some((tr) => tr.docChanged)) { + return null; + } + + const tr = newState.tr; + let modified = false; + + newState.doc.descendants((node, pos) => { + if (node.type.name === 'listItem') { + const emptyParagraphs: number[] = []; + + node.forEach((child, offset) => { + if (child.type.name === 'paragraph' && child.content.size === 0) { + emptyParagraphs.push(pos + offset + 1); + } + }); + + // 빈 paragraph가 여러 개 있으면 하나만 남기고 나머지 제거 + if (emptyParagraphs.length > 1) { + for (let i = emptyParagraphs.length - 1; i > 0; i--) { + tr.delete(emptyParagraphs[i], emptyParagraphs[i] + 1); + modified = true; + } + } + } + }); + + return modified ? tr : null; }, }), ]; diff --git a/src/types/expert/expert.props.ts b/src/types/expert/expert.props.ts index 36d93ab..61f733a 100644 --- a/src/types/expert/expert.props.ts +++ b/src/types/expert/expert.props.ts @@ -11,6 +11,7 @@ export interface MentorProps { tags: string[]; categories: string[]; workingperiod: number; + oneLineIntroduction: string; } export interface MentorCardProps { @@ -21,6 +22,7 @@ export interface MentorCardProps { orderIndex: number; careerTitle: string; }[]; + oneLineIntroduction: string; tags: string[]; workingperiod: number; status: 'active' | 'done'; @@ -36,4 +38,5 @@ export const adaptMentor = (e: getExpertResponse) => ({ .map(mappingKorea) .filter(Boolean) as TabLabel[], workingperiod: e.workedPeriod, + oneLineIntroduction: e.oneLineIntroduction, });