From 704237344834fe42c0f8adee698b7798623d9c76 Mon Sep 17 00:00:00 2001 From: gahusb Date: Mon, 16 Feb 2026 19:02:04 +0900 Subject: [PATCH] =?UTF-8?q?=EC=82=AC=EC=A3=BC=20=ED=92=80=EC=9D=B4=20?= =?UTF-8?q?=EA=B3=A0=EB=8F=84=ED=99=94,=20NAS=20=EB=B0=B0=ED=8F=AC=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 46 + .env.example | 25 + .gitignore | 5 + Dockerfile | 78 + SETUP.md | 2 +- SETUP_GUIDE.md | 61 + app/api/analyze/route.ts | 127 ++ app/api/health/route.ts | 9 + app/auth/callback/route.ts | 31 + .../result/CompatibilityDetailUnlock.tsx | 198 ++ app/compatibility/result/page.tsx | 558 ++++-- app/layout.tsx | 13 +- app/login/page.tsx | 163 ++ app/mypage/page.tsx | 185 ++ app/page.tsx | 347 ++-- app/result/page.tsx | 483 +++-- app/result/saved/[id]/SavedInterpretation.tsx | 33 + app/result/saved/[id]/page.tsx | 78 + app/saju/page.tsx | 71 + app/tojeong/result/TojeongDetailUnlock.tsx | 134 ++ app/tojeong/result/page.tsx | 53 +- build_error.log | 47 + components/AccordionItem.tsx | 71 + components/AiInterpretationSection.tsx | 335 ++++ components/TokenPurchaseModal.tsx | 292 +++ components/UserMenu.tsx | 73 + docker-compose.integration.yml | 55 + docker-compose.yml | 48 + lib/ai-interpretation.ts | 648 ++++--- lib/ensure-profile.ts | 43 + lib/pdf-utils.ts | 97 +- lib/saju-ai-prompt.ts | 223 +++ lib/saju-calculator.ts | 461 ++++- lib/supabase/client.ts | 9 + lib/supabase/server.ts | 30 + lib/supabaseClient.ts | 7 + middleware.ts | 20 + next.config.ts | 12 +- nginx-config.conf | 84 + package-lock.json | 1652 ++++++++++++++++- package.json | 13 +- scripts/deploy-nas.cjs | 214 +++ supabase/schema.sql | 76 + utils/supabase/middleware.ts | 48 + 44 files changed, 6280 insertions(+), 978 deletions(-) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 Dockerfile create mode 100644 SETUP_GUIDE.md create mode 100644 app/api/analyze/route.ts create mode 100644 app/api/health/route.ts create mode 100644 app/auth/callback/route.ts create mode 100644 app/compatibility/result/CompatibilityDetailUnlock.tsx create mode 100644 app/login/page.tsx create mode 100644 app/mypage/page.tsx create mode 100644 app/result/saved/[id]/SavedInterpretation.tsx create mode 100644 app/result/saved/[id]/page.tsx create mode 100644 app/saju/page.tsx create mode 100644 app/tojeong/result/TojeongDetailUnlock.tsx create mode 100644 build_error.log create mode 100644 components/AccordionItem.tsx create mode 100644 components/AiInterpretationSection.tsx create mode 100644 components/TokenPurchaseModal.tsx create mode 100644 components/UserMenu.tsx create mode 100644 docker-compose.integration.yml create mode 100644 docker-compose.yml create mode 100644 lib/ensure-profile.ts create mode 100644 lib/saju-ai-prompt.ts create mode 100644 lib/supabase/client.ts create mode 100644 lib/supabase/server.ts create mode 100644 lib/supabaseClient.ts create mode 100644 middleware.ts create mode 100644 nginx-config.conf create mode 100644 scripts/deploy-nas.cjs create mode 100644 supabase/schema.sql create mode 100644 utils/supabase/middleware.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..04e880c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,46 @@ +# Dependencies +node_modules +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Next.js +.next +out +dist + +# Environment files +.env*.local +.env.nas + +# Git +.git +.gitignore + +# IDE +.vscode +.idea +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log + +# Testing +coverage +.nyc_output + +# Build artifacts +build +*.tsbuildinfo + +# Misc +README.md +SETUP.md +SETUP_GUIDE.md +build_error.log diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b91267e --- /dev/null +++ b/.env.example @@ -0,0 +1,25 @@ +# 환경 변수 예시 파일 +# .env.local (개발용) 또는 .env.nas (운영용)로 복사하여 사용하세요 + +# Base Path (로컬 개발: 비워두기, NAS 배포: /saju) +NEXT_PUBLIC_BASE_PATH= + +# 카카오 JavaScript 키 +# https://developers.kakao.com/ 에서 발급 +NEXT_PUBLIC_KAKAO_APP_KEY=your_kakao_app_key_here + +# Supabase +# https://supabase.com/ 프로젝트 설정에서 확인 +NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co +NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key_here + +# PortOne (구 아임포트) +# https://portone.io/ 에서 발급 +NEXT_PUBLIC_PORTONE_IMP_CODE=your_portone_imp_code_here + +# OpenAI API +# https://platform.openai.com/api-keys 에서 발급 +OPENAI_API_KEY=sk-proj-your_openai_api_key_here + +# Node 환경 (개발: development, 운영: production) +NODE_ENV=development diff --git a/.gitignore b/.gitignore index 5ef6a52..8ea8552 100644 --- a/.gitignore +++ b/.gitignore @@ -30,8 +30,13 @@ yarn-debug.log* yarn-error.log* .pnpm-debug.log* +# deployment +robocopy.log +deploy.log + # env files (can opt-in for committing if needed) .env* +!.env.example # vercel .vercel diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a6e81ca --- /dev/null +++ b/Dockerfile @@ -0,0 +1,78 @@ +# ============================================ +# Stage 1: Dependencies +# ============================================ +FROM node:20-alpine AS deps + +WORKDIR /app + +# Package files만 먼저 복사 (캐싱 최적화) +COPY package.json package-lock.json ./ + +# 의존성 설치 +RUN npm ci --only=production + +# ============================================ +# Stage 2: Builder +# ============================================ +FROM node:20-alpine AS builder + +WORKDIR /app + +# 의존성 복사 +COPY package.json package-lock.json ./ +RUN npm ci + +# 소스 코드 복사 +COPY . . + +# 빌드 시점에 환경 변수 주입 (NEXT_PUBLIC_* 변수만) +ARG NEXT_PUBLIC_BASE_PATH +ARG NEXT_PUBLIC_KAKAO_APP_KEY +ARG NEXT_PUBLIC_SUPABASE_URL +ARG NEXT_PUBLIC_SUPABASE_ANON_KEY +ARG NEXT_PUBLIC_PORTONE_IMP_CODE + +ENV NEXT_PUBLIC_BASE_PATH=$NEXT_PUBLIC_BASE_PATH +ENV NEXT_PUBLIC_KAKAO_APP_KEY=$NEXT_PUBLIC_KAKAO_APP_KEY +ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL +ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY +ENV NEXT_PUBLIC_PORTONE_IMP_CODE=$NEXT_PUBLIC_PORTONE_IMP_CODE + +# Next.js 빌드 (standalone 모드) +RUN npm run build + +# ============================================ +# Stage 3: Runner +# ============================================ +FROM node:20-alpine AS runner + +WORKDIR /app + +# 보안을 위해 non-root 유저 생성 +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +# 필요한 파일만 복사 +COPY --from=builder /app/public ./public +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/.next/static ./.next/static + +# 권한 설정 +RUN chown -R nextjs:nodejs /app + +USER nextjs + +# 포트 설정 +EXPOSE 3000 + +# 환경 변수 +ENV NODE_ENV=production +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" + +# 헬스체크 +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD node -e "require('http').get('http://localhost:3000/api/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})" + +# 실행 +CMD ["node", "server.js"] diff --git a/SETUP.md b/SETUP.md index e53b365..e7926f0 100644 --- a/SETUP.md +++ b/SETUP.md @@ -176,4 +176,4 @@ npm start ## 라이선스 -© 2025 쟁승메이드. All rights reserved. +© 2025 쟁승메이드. All rights reserved. \ No newline at end of file diff --git a/SETUP_GUIDE.md b/SETUP_GUIDE.md new file mode 100644 index 0000000..d161979 --- /dev/null +++ b/SETUP_GUIDE.md @@ -0,0 +1,61 @@ + +# 🚀 사주 웹사이트: 유저 시스템 및 결제 연동 가이드 + +## 1. Supabase (데이터베이스 & 인증) 설정 +이 프로젝트는 유저 정보, 사주 기록, 결제 내역을 저장하기 위해 Supabase를 사용합니다. + +### 1-1. Supabase 프로젝트 생성 +1. [Supabase](https://supabase.com/)에 가입하고 'New Project'를 생성합니다. +2. Database Password를 안전한 곳에 저장해두세요. +3. Region은 'Seoul (South Korea)'를 선택하는 것이 속도 면에서 좋습니다. + +### 1-2. 환경 변수 설정 (.env.local) +프로젝트 생성 후 `Project Settings > API`에서 `URL`과 `anon public key`를 복사하여 프로젝트 루트의 `.env.local` 파일에 추가하세요. + +```bash +NEXT_PUBLIC_SUPABASE_URL=your_supabase_url +NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key +``` + +### 1-3. 데이터베이스 테이블 생성 +Supabase 대시보드의 `SQL Editor` 메뉴로 이동하여, 프로젝트 내 `supabase/schema.sql` 파일의 내용을 복사해 실행하세요. +- `users` 테이블 (Auth 연동) +- `saju_records` 테이블 (사주 결과 저장) +- `payments` 테이블 (결제 내역) + +### 1-4. 소셜 로그인 설정 (선택) +`Authentication > Providers`에서 Google, Kakao 등 원하는 소셜 로그인을 활성화할 수 있습니다. + +--- + +## 2. PortOne (결제 시스템) 설정 +결제 기능(990원 결제)을 위해 PortOne(구 아임포트)을 사용합니다. + +### 2-1. PortOne 회원가입 및 설정 +1. [PortOne 관리자 콘솔](https://admin.portone.io/)에 가입합니다. +2. `결제 연동 > 식별코드` 메뉴에서 `가맹점 식별코드(IMP Code)`를 확인합니다. +3. `PG사 설정`에서 테스트용으로 'KG이니시스' 또는 '카카오페이' 등을 연동합니다 (테스트 모드). + +### 2-2. 환경 변수 추가 +`.env.local` 파일에 식별코드를 추가하세요. + +```bash +NEXT_PUBLIC_PORTONE_IMP_CODE=imp00000000 # 본인의 식별코드로 변경 +``` + +## 3. OpenAI API 설정 (전문가 사주 해석) +전문가 수준의 AI 해석을 위해 OpenAI API 키가 필요합니다. +[OpenAI Platform](https://platform.openai.com/)에서 API Key를 발급받아 추가하세요. + +```bash +OPENAI_API_KEY=sk-proj-... +``` + +--- + +## 4. 로컬 실행 +모든 설정이 완료되면 로컬 서버를 재시작하세요. + +```bash +npm run dev +``` diff --git a/app/api/analyze/route.ts b/app/api/analyze/route.ts new file mode 100644 index 0000000..8c9e44e --- /dev/null +++ b/app/api/analyze/route.ts @@ -0,0 +1,127 @@ + +import { NextResponse } from 'next/server'; +import OpenAI from 'openai'; +import { createSajuPrompt } from '@/lib/saju-ai-prompt'; +import { performFullAnalysis } from '@/lib/ai-interpretation'; + +export const runtime = 'nodejs'; + +const MOCK_INTERPRETATION = ` +## 1. 일간 분석과 타고난 기질 +(API 키 문제 또는 할당량 초과로 인해 예시 데이터를 보여드립니다.) +귀하는 **갑목(甲木)** 일간으로 태어나, 마치 곧게 뻗은 소나무와 같은 기상을 지니고 있다. 리더십이 강하고 추진력이 뛰어나며, 한번 마음먹은 일은 끝까지 해내는 뚝심이 있다. 겉으로는 강해보여도 속내에는 여린 감성이 숨어있어 의외로 상처를 잘 받기도 한다. + +## 2. 오행 균형과 용신 기반 개운법 +사주에서 **화(火)** 기운이 부족하여 표현력이 다소 약할 수 있다. 붉은색 계통의 옷이나 소품을 활용하고, 밝은 곳에서 활동하는 것이 운을 트이게 한다. + +## 3. 지지 상호작용 해석 +지지 간의 상호작용을 살펴보면, 특별한 합충형이 발견된다. + +## 4. 신살이 삶에 미치는 영향 +역마살이 사주에 자리하고 있어 이동과 변동이 많은 삶을 살게 된다. + +## 5. 재물운과 금전 흐름 +재물창고인 **진토(辰土)**를 깔고 있어 기본적으로 재복은 타고났다. 다만, 돈을 버는 것보다 지키는 힘이 약할 수 있으니 저축 습관이 중요하다. + +## 6. 직업 적성과 진로 +교육, 출판, 건축, 디자인 등 창조적이고 독립적인 분야에서 두각을 나타낼 수 있다. + +## 7. 애정운과 결혼 +자존심이 강해 상대방에게 굽히지 않으려는 성향이 있다. 배우자와의 관계에서는 조금 더 부드러운 태도가 필요하다. + +## 8. 건강운 +간, 담낭, 신경계 통증에 유의해야 한다. 스트레스를 받으면 뭉치는 경향이 있으니 스트레칭과 요가를 추천한다. + +## 9. 현재 대운의 흐름과 기회/위기 +현재 대운은 인생의 전환점이다. 새로운 것을 시작하기보다는 기존의 것을 다지고 내실을 기하는 시기이다. + +## 10. 올해의 세운 분석 +올해는 귀인의 도움을 받을 수 있는 해이다. 주저하지 말고 주변에 도움을 요청하라. + +## 11. 인생의 황금기 예측 +40대 중반부터 50대 초반까지 인생의 가장 화려한 시기를 맞이할 것으로 보인다. + +## 12. 종합 조언 +"서두르지 않아도 봄은 온다." 조급해하지 말고 때를 기다리는 지혜가 필요하다. +`; + +// 사용 가능한 모델 우선순위 (gpt-4o → gpt-4o-mini 폴백) +const MODELS = ['gpt-4o', 'gpt-4o-mini'] as const; + +export async function POST(request: Request) { + try { + const { saju, daeun, daeunList, gender } = await request.json(); + + // 종합 분석 수행 + let analysis; + try { + analysis = performFullAnalysis(saju); + } catch (analysisError: any) { + console.error('Analysis calculation error:', analysisError.message); + return NextResponse.json( + { error: '사주 분석 계산 중 오류가 발생했습니다: ' + analysisError.message }, + { status: 500 } + ); + } + + if (!process.env.OPENAI_API_KEY) { + console.warn('OpenAI API Key is missing'); + return NextResponse.json({ interpretation: MOCK_INTERPRETATION, analysis }); + } + + const openai = new OpenAI({ + apiKey: process.env.OPENAI_API_KEY, + }); + + const prompt = createSajuPrompt(saju, daeun, gender, analysis, daeunList || []); + + // 모델 폴백: gpt-4o 실패 시 gpt-4o-mini로 재시도 + let interpretation: string | null = null; + let usedModel = ''; + + for (const model of MODELS) { + try { + console.log(`Generating analysis with model: ${model}`); + const completion = await openai.chat.completions.create({ + messages: [{ role: 'system', content: prompt }], + model, + max_tokens: model === 'gpt-4o' ? 8192 : 4096, + temperature: 0.75, + }); + interpretation = completion.choices[0].message.content; + usedModel = model; + console.log(`Successfully generated with model: ${model}`); + break; + } catch (modelError: any) { + console.warn(`Model ${model} failed:`, modelError.message || modelError.status); + // 인증 오류(401) 또는 할당량 초과(429)는 모든 모델에 해당하므로 바로 mock 반환 + if (modelError.status === 401) { + console.warn('OpenAI API Key is invalid (401). Returning mock data.'); + return NextResponse.json({ interpretation: MOCK_INTERPRETATION, analysis }); + } + if (modelError.status === 429 || (modelError.error && modelError.error.code === 'insufficient_quota')) { + console.warn('OpenAI Quota Exceeded. Returning mock data.'); + return NextResponse.json({ interpretation: MOCK_INTERPRETATION, analysis }); + } + // 마지막 모델이 아니면 다음 모델로 폴백 + if (model === MODELS[MODELS.length - 1]) { + throw modelError; // 모든 모델 실패 + } + console.log(`Falling back to next model...`); + } + } + + return NextResponse.json({ interpretation, analysis }); + } catch (error: any) { + console.error('Error generating interpretation:', error.message || error); + + if (error.response) { + console.error('OpenAI Error Response:', error.response.status, error.response.data); + } + + return NextResponse.json( + { error: error.message || 'Failed to generate interpretation' }, + { status: 500 } + ); + } +} diff --git a/app/api/health/route.ts b/app/api/health/route.ts new file mode 100644 index 0000000..f23f88a --- /dev/null +++ b/app/api/health/route.ts @@ -0,0 +1,9 @@ +import { NextResponse } from 'next/server'; + +export async function GET() { + return NextResponse.json({ + status: 'ok', + timestamp: new Date().toISOString(), + service: 'saju-web', + }); +} diff --git a/app/auth/callback/route.ts b/app/auth/callback/route.ts new file mode 100644 index 0000000..9a95faf --- /dev/null +++ b/app/auth/callback/route.ts @@ -0,0 +1,31 @@ + +import { NextResponse } from 'next/server' +// The client you created from the Server-Side Auth instructions +import { createClient } from '@/lib/supabase/server' + +export async function GET(request: Request) { + const { searchParams, origin } = new URL(request.url) + const code = searchParams.get('code') + // if "next" is in param, use it as the redirect URL + const next = searchParams.get('next') ?? '/' + + if (code) { + const supabase = await createClient() + const { error } = await supabase.auth.exchangeCodeForSession(code) + if (!error) { + const forwardedHost = request.headers.get('x-forwarded-host') // original origin before load balancer + const isLocalEnv = process.env.NODE_ENV === 'development' + if (isLocalEnv) { + // we can be sure that there is no load balancer in between, so no need to watch for X-Forwarded-Host + return NextResponse.redirect(`${origin}${next}`) + } else if (forwardedHost) { + return NextResponse.redirect(`https://${forwardedHost}${next}`) + } else { + return NextResponse.redirect(`${origin}${next}`) + } + } + } + + // return the user to an error page with instructions + return NextResponse.redirect(`${origin}/auth/auth-code-error`) +} diff --git a/app/compatibility/result/CompatibilityDetailUnlock.tsx b/app/compatibility/result/CompatibilityDetailUnlock.tsx new file mode 100644 index 0000000..9b2e9fe --- /dev/null +++ b/app/compatibility/result/CompatibilityDetailUnlock.tsx @@ -0,0 +1,198 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { createBrowserClient } from '@supabase/ssr'; +import { useRouter } from 'next/navigation'; +import TokenPurchaseModal from '@/components/TokenPurchaseModal'; +import { ensureProfile } from '@/lib/ensure-profile'; + +interface Props { + allPros: string[]; + allCons: string[]; + advice: string; + element1: string; + element2: string; + element1Kr: string; + element2Kr: string; +} + +export default function CompatibilityDetailUnlock({ allPros, allCons, advice, element1, element2, element1Kr, element2Kr }: Props) { + const [isUnlocked, setIsUnlocked] = useState(false); + const [user, setUser] = useState(null); + const [credits, setCredits] = useState(0); + const [showModal, setShowModal] = useState(false); + const [loading, setLoading] = useState(false); + const router = useRouter(); + + const supabase = createBrowserClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! + ); + + useEffect(() => { + const init = async () => { + const { data: { user } } = await supabase.auth.getUser(); + if (user) { + setUser(user); + const currentCredits = await ensureProfile(supabase, user); + setCredits(currentCredits); + } + }; + init(); + }, []); + + const handleUnlock = async () => { + if (!user) { + if (confirm('로그인이 필요합니다. 로그인 페이지로 이동하시겠습니까?')) { + router.push('/login'); + } + return; + } + + if (credits < 1) { + setShowModal(true); + return; + } + + // 토큰 차감 + setLoading(true); + const { error } = await supabase + .from('profiles') + .update({ credits: credits - 1 }) + .eq('id', user.id); + + if (error) { + alert('토큰 차감에 실패했습니다. 다시 시도해주세요.'); + setLoading(false); + return; + } + + setCredits(credits - 1); + setIsUnlocked(true); + setLoading(false); + }; + + const handlePurchaseComplete = async () => { + setShowModal(false); + // 크레딧 새로고침 + if (user) { + const { data: profile } = await supabase + .from('profiles') + .select('credits') + .eq('id', user.id) + .single(); + if (profile) setCredits(profile.credits || 0); + } + }; + + if (!isUnlocked) { + return ( + <> +
+ {/* 블러 처리된 미리보기 */} +
+
+
+

+ 두 사람의 장점 +

+
+ {allPros.slice(0, 3).map((_, i) => ( +
+ ))} +
+
+
+

+ ⚠️주의할 점 +

+
+ {allCons.slice(0, 3).map((_, i) => ( +
+ ))} +
+
+
+
+
+
+
+
+ + {/* 잠금 오버레이 */} +
+
+
🔐
+

상세 궁합 해석

+

+ {element1Kr}({element1})과 {element2Kr}({element2})의
+ 오행 기반 맞춤 해석을 확인하세요. +

+

+ 토큰 1개 사용 | 보유: {credits}개 +

+ +
+
+
+ + setShowModal(false)} + onPurchaseComplete={handlePurchaseComplete} + user={user} + supabase={supabase} + /> + + ); + } + + // Unlocked content + return ( + <> +
+
+

+ 두 사람의 장점 +

+
    + {allPros.map((pro, i) => ( +
  • + + {pro} +
  • + ))} +
+
+ +
+

+ ⚠️주의할 점 +

+
    + {allCons.map((con, i) => ( +
  • + + {con} +
  • + ))} +
+
+
+ +
+

💡 조언

+

{advice}

+

+ 궁합은 참고사항이에요. 서로를 이해하고 존중하며 노력한다면 어떤 궁합이든 행복한 관계를 만들 수 있답니다. +

+
+ + ); +} diff --git a/app/compatibility/result/page.tsx b/app/compatibility/result/page.tsx index bd92ce0..7bf6799 100644 --- a/app/compatibility/result/page.tsx +++ b/app/compatibility/result/page.tsx @@ -2,6 +2,7 @@ import Link from 'next/link'; import { calculateSaju, FIVE_ELEMENTS } from '@/lib/saju-calculator'; import PDFButton from '../../components/PDFButton'; import ShareButtons from '../../components/ShareButtons'; +import CompatibilityDetailUnlock from './CompatibilityDetailUnlock'; interface PageProps { searchParams: Promise<{ @@ -18,102 +19,325 @@ interface PageProps { }>; } +// 오행 한글 매핑 +const elementKrMap: Record = { + '木': '목', '火': '화', '土': '토', '金': '금', '水': '수' +}; + +// Deterministic hash for seeded score variation +function seededOffset(seed: string, index: number): number { + let hash = 0; + const str = seed + index.toString(); + for (let i = 0; i < str.length; i++) { + hash = ((hash << 5) - hash) + str.charCodeAt(i); + hash |= 0; + } + return ((hash % 11) - 5); // -5 ~ +5 +} + +// 오행 관계 타입 판별 +type ElementRelation = 'same' | 'produce' | 'produced' | 'overcome' | 'overcomed'; + +function getElementRelation(el1: string, el2: string): ElementRelation { + const produceMap: Record = { + '木': '火', '火': '土', '土': '金', '金': '水', '水': '木' + }; + if (el1 === el2) return 'same'; + if (produceMap[el1] === el2) return 'produce'; + if (produceMap[el2] === el1) return 'produced'; + const overcomeMap: Record = { + '木': '土', '火': '金', '土': '水', '金': '木', '水': '火' + }; + if (overcomeMap[el1] === el2) return 'overcome'; + return 'overcomed'; +} + +// 지지 관계 판별 +function getBranchRelation(b1: string, b2: string): { sixHarmony: boolean; threeHarmony: boolean; conflict: boolean } { + const sixHarmony: Record = { + '子': '丑', '丑': '子', '寅': '亥', '亥': '寅', + '卯': '戌', '戌': '卯', '辰': '酉', '酉': '辰', + '巳': '申', '申': '巳', '午': '未', '未': '午' + }; + const threeHarmonyGroups = [ + ['申', '子', '辰'], ['寅', '午', '戌'], + ['亥', '卯', '未'], ['巳', '酉', '丑'] + ]; + const conflictMap: Record = { + '子': '午', '午': '子', '丑': '未', '未': '丑', + '寅': '申', '申': '寅', '卯': '酉', '酉': '卯', + '辰': '戌', '戌': '辰', '巳': '亥', '亥': '巳' + }; + + const isThreeHarmony = threeHarmonyGroups.some(g => g.includes(b1) && g.includes(b2)); + return { + sixHarmony: sixHarmony[b1] === b2, + threeHarmony: isThreeHarmony, + conflict: conflictMap[b1] === b2, + }; +} + +// 오행 관계별 상세 해석 +function getElementInterpretation(el1: string, el2: string, relation: ElementRelation) { + const el1Kr = elementKrMap[el1]; + const el2Kr = elementKrMap[el2]; + + const interpretations: Record = { + // 상생: el1이 el2를 생함 + '木_火': { + pros: [ + `${el1Kr}(나무)이 ${el2Kr}(불)을 키워주는 관계예요. 한 분이 자연스럽게 상대방에게 에너지를 전해주거든요.`, + '성장을 응원하고 지지해주는 따뜻한 관계가 될 수 있어요.', + '서로의 열정을 자극해서 함께 발전해 나갈 수 있는 좋은 조합이에요.', + '함께 있으면 자연스럽게 활력이 넘치고, 창의적인 아이디어가 샘솟을 거예요.', + ], + cons: [ + '한쪽이 너무 많이 희생하면 에너지가 소진될 수 있어요. 균형이 중요해요.', + '불꽃처럼 열정적인 만큼 감정 기복이 클 수 있으니 차분한 대화가 필요해요.', + '서로에 대한 기대가 커질 수 있어서, 적절한 거리감을 유지하는 것도 좋아요.', + ], + advice: '서로의 에너지를 아낌없이 주되, 자기 자신도 잘 챙기는 게 이 관계의 핵심이에요.', + }, + '火_土': { + pros: [ + `${el1Kr}(불)이 ${el2Kr}(흙)을 따뜻하게 만들어주는 관계예요. 서로에게 안정감을 줄 수 있거든요.`, + '열정과 안정이 조화를 이루어 실질적인 성과를 만들어낼 수 있어요.', + '서로 다른 강점을 가지고 있어서 팀워크가 좋은 편이에요.', + '어려운 순간에도 든든하게 의지할 수 있는 관계가 될 거예요.', + ], + cons: [ + '급한 성격과 느긋한 성격이 부딪힐 수 있어요. 템포를 맞추는 연습이 필요해요.', + '때로는 답답함을 느낄 수 있지만, 그만큼 서로를 보완해주는 관계이기도 해요.', + '가끔은 서로의 속도 차이를 인정하고 기다려주는 여유가 필요하답니다.', + ], + advice: '서로의 리듬을 존중하면서 함께 걸어가면, 오래오래 행복한 관계가 될 거예요.', + }, + '土_金': { + pros: [ + `${el1Kr}(흙)이 ${el2Kr}(쇠)를 품고 있는 관계예요. 안정 속에서 가치를 만들어내는 조합이에요.`, + '현실적이고 실용적인 면에서 잘 맞아서 함께 무언가를 이뤄내기 좋아요.', + '서로를 단단하게 만들어주는 든든한 파트너가 될 수 있어요.', + '물질적으로나 정서적으로 안정감 있는 관계를 만들어갈 수 있답니다.', + ], + cons: [ + '둘 다 고집이 있을 수 있어서 의견 충돌 시 양보하는 연습이 필요해요.', + '너무 현실적인 면만 추구하다 보면 로맨스가 부족해질 수 있어요.', + '변화를 두려워하기보다 함께 새로운 시도를 해보는 것도 좋겠어요.', + ], + advice: '안정감 위에 가끔 달콤한 서프라이즈를 더하면 완벽한 관계가 될 거예요.', + }, + '金_水': { + pros: [ + `${el1Kr}(쇠)이 ${el2Kr}(물)을 만들어내는 관계예요. 깊은 지혜와 감성이 만나는 조합이에요.`, + '서로의 내면을 잘 이해하고, 깊은 대화를 나눌 수 있는 사이가 될 거예요.', + '원칙과 유연함이 조화를 이루어 균형 잡힌 관계를 만들 수 있어요.', + '서로에게 영감을 주고 정서적으로 풍요로운 관계가 될 수 있답니다.', + ], + cons: [ + '감정 표현 방식이 다를 수 있어요. 직접적으로 마음을 표현하는 연습을 해보세요.', + '때로는 너무 이성적이거나 너무 감성적인 면이 부딪힐 수 있어요.', + '서로의 감정 표현 스타일을 이해하고 존중하는 것이 중요해요.', + ], + advice: '머리와 가슴의 균형, 이성과 감성의 조화가 이 관계의 매력 포인트예요.', + }, + '水_木': { + pros: [ + `${el2Kr}(물)이 ${el1Kr}(나무)를 키워주는 관계예요. 성장과 발전의 에너지가 넘치는 조합이에요.`, + '새로운 것을 시작하고 함께 성장해 나가기에 최적의 파트너예요.', + '서로에게 자양분이 되어 끊임없이 발전하는 관계가 될 수 있어요.', + '자유롭고 창의적인 에너지가 넘쳐서 함께하면 즐거울 거예요.', + ], + cons: [ + '방향성 없이 흘러갈 수 있으니 함께 목표를 세우는 것이 중요해요.', + '너무 자유로운 성향이 겹치면 책임감이 부족해질 수 있어요.', + '각자의 공간을 존중하되, 함께하는 시간도 소중히 여기세요.', + ], + advice: '함께 성장하는 기쁨을 느끼면서, 때로는 뿌리를 내리는 안정감도 챙겨보세요.', + }, + // 동일 오행 + 'same': { + pros: [ + '같은 오행이라 기본적인 가치관과 성향이 비슷해요. 서로를 이해하기 쉬운 관계거든요.', + '말하지 않아도 통하는 부분이 많아서 편안한 관계를 유지할 수 있어요.', + '취미나 관심사가 비슷해서 함께 즐길 거리가 많을 거예요.', + '서로의 마음을 직감적으로 알아차릴 수 있는 특별한 케미가 있어요.', + ], + cons: [ + '너무 비슷해서 오히려 새로운 자극이 부족할 수 있어요.', + '같은 약점을 공유하기 때문에 함께 빠질 수 있는 함정이 있어요.', + '때로는 의도적으로 다른 관점을 찾아보는 노력이 필요해요.', + ], + advice: '닮은 점을 즐기되, 각자만의 개성과 차이도 만들어가면 더 풍성한 관계가 될 거예요.', + }, + // 상극 + '木_土': { + pros: [ + '긴장감이 있지만 그만큼 서로에게 강한 끌림을 느낄 수 있는 관계예요.', + '서로 다른 관점을 가지고 있어서 새로운 시각을 배울 수 있어요.', + '도전적인 관계이지만 극복할수록 더 단단해지는 사이가 될 거예요.', + ], + cons: [ + `${el1Kr}(나무)이 ${el2Kr}(흙)을 극하는 관계라 의견 충돌이 생기기 쉬워요.`, + '주도권 다툼이 일어날 수 있으니 서로 양보하는 자세가 중요해요.', + '상대방을 변화시키려 하기보다 있는 그대로 받아들이는 연습을 해보세요.', + '감정이 격해질 때는 일단 한 발 물러서서 냉각 시간을 갖는 것이 좋아요.', + ], + advice: '부딪히는 만큼 성장할 수 있는 관계예요. 서로를 가르치려 하지 말고 배우려는 마음이 중요해요.', + }, + '火_金': { + pros: [ + '강렬한 에너지가 만나는 관계라 서로에게 깊은 인상을 남길 수 있어요.', + '서로의 강한 면을 인정하고 존중할 수 있다면 최강의 파트너가 될 수 있어요.', + '갈등을 극복한 뒤에는 누구보다 단단한 신뢰가 생기는 관계예요.', + ], + cons: [ + `${el1Kr}(불)이 ${el2Kr}(쇠)를 녹이는 관계라 갈등이 뜨거울 수 있어요.`, + '둘 다 자존심이 강해서 먼저 사과하기 어려울 수 있어요.', + '감정적으로 상처를 주기 쉬우니 말 한마디 한마디를 신중하게 하세요.', + '서로의 영역을 존중하고 간섭을 줄이는 것이 관계 유지의 비결이에요.', + ], + advice: '불꽃이 튀는 관계이지만, 그 열기를 잘 다스리면 누구보다 강한 유대감을 만들 수 있어요.', + }, + '土_水': { + pros: [ + '서로 다른 성향이지만, 그래서 오히려 배울 점이 많은 관계예요.', + '현실적인 면과 유연한 면이 만나 균형을 찾을 수 있는 조합이에요.', + '서로의 부족한 점을 채워줄 수 있는 잠재력이 있는 관계거든요.', + ], + cons: [ + `${el1Kr}(흙)이 ${el2Kr}(물)을 막는 관계라 소통에 장벽이 생기기 쉬워요.`, + '고집과 유연함이 부딪히면서 답답함을 느낄 수 있어요.', + '서로의 방식을 존중하고, 중간 지점을 찾으려는 노력이 필요해요.', + '대화가 막힐 때는 글이나 편지로 마음을 전하는 것도 좋은 방법이에요.', + ], + advice: '서로의 차이를 인정하고 존중하는 것이 이 관계의 가장 큰 과제이자 선물이에요.', + }, + '金_木': { + pros: [ + '서로에게 자극을 주는 관계라 성장의 계기가 될 수 있어요.', + '갈등을 잘 해결하면 오히려 더 깊은 이해와 공감이 생기는 사이예요.', + '상대방을 통해 자신의 새로운 면을 발견할 수 있는 관계거든요.', + ], + cons: [ + `${el1Kr}(쇠)이 ${el2Kr}(나무)를 자르는 관계라 비판적인 면이 나올 수 있어요.`, + '상대방의 약점을 지적하기보다 장점을 칭찬하는 습관을 들여보세요.', + '서로를 통제하려 하면 관계가 힘들어질 수 있으니 자유를 존중해주세요.', + '작은 것부터 서로 칭찬하고 감사하는 연습을 해보면 관계가 훨씬 좋아질 거예요.', + ], + advice: '날카로운 면이 있지만, 서로를 다듬어가는 과정에서 보석 같은 관계가 될 수 있어요.', + }, + '水_火': { + pros: [ + '상반된 에너지가 만나서 강한 끌림을 느낄 수 있는 관계예요.', + '열정과 차분함이 만나 독특한 균형을 이룰 수 있는 조합이에요.', + '서로에게서 완전히 새로운 세계를 경험할 수 있는 흥미로운 관계거든요.', + ], + cons: [ + `${el1Kr}(물)이 ${el2Kr}(불)을 끄는 관계라 서로의 열정을 꺾을 수 있어요.`, + '한쪽이 너무 뜨겁고 한쪽이 너무 차가우면 갈등이 생기기 쉬워요.', + '감정의 온도차를 줄이기 위해 서로의 감정을 자주 확인하는 것이 중요해요.', + '큰 결정은 충분히 대화한 뒤에 함께 내리는 것이 좋아요.', + ], + advice: '물과 불이 만나면 증기가 되듯, 서로의 에너지를 잘 합치면 엄청난 힘을 발휘할 수 있어요.', + }, + }; + + // 상생 관계 매핑 + if (relation === 'produce') { + return interpretations[`${el1}_${produceTarget(el1)}`] || interpretations['same']; + } + if (relation === 'produced') { + return interpretations[`${el2}_${produceTarget(el2)}`] || interpretations['same']; + } + if (relation === 'same') { + return interpretations['same']; + } + // 상극 + const key = `${el1}_${overcomeTarget(el1)}`; + if (interpretations[key]) return interpretations[key]; + const reverseKey = `${el2}_${overcomeTarget(el2)}`; + if (interpretations[reverseKey]) return interpretations[reverseKey]; + return interpretations['same']; +} + +function produceTarget(el: string): string { + const map: Record = { '木': '火', '火': '土', '土': '金', '金': '水', '水': '木' }; + return map[el] || ''; +} + +function overcomeTarget(el: string): string { + const map: Record = { '木': '土', '火': '金', '土': '水', '金': '木', '水': '火' }; + return map[el] || ''; +} + +// 지지 관계 추가 해석 +function getBranchInterpretation(branchRel: { sixHarmony: boolean; threeHarmony: boolean; conflict: boolean }) { + const extraPros: string[] = []; + const extraCons: string[] = []; + + if (branchRel.sixHarmony) { + extraPros.push('두 분의 일지가 육합(六合) 관계예요! 천생연분이라고 할 만큼 자연스럽게 잘 맞는 조합이에요.'); + extraPros.push('만나면 왠지 편안하고 마음이 통하는 느낌을 받으실 거예요.'); + } + if (branchRel.threeHarmony) { + extraPros.push('일지가 삼합(三合)에 속해 있어요. 함께 있으면 시너지가 나는 좋은 궁합이에요.'); + } + if (branchRel.conflict) { + extraCons.push('일지가 충(沖) 관계에 있어요. 처음에는 강하게 끌리지만, 갈등도 생기기 쉬운 조합이에요.'); + extraCons.push('충 관계는 변화와 자극이 많아서, 서로 인내하고 이해하려는 노력이 꼭 필요해요.'); + } + + return { extraPros, extraCons }; +} + +// 세부 항목별 한 줄 해석 +function getDetailedComment(category: string, score: number): string { + if (category === '연애운') { + if (score >= 80) return '함께하면 설렘이 끊이지 않는 로맨틱한 관계가 될 거예요.'; + if (score >= 65) return '서로에게 좋은 감정을 느끼며 자연스럽게 가까워질 수 있어요.'; + return '처음에는 어색할 수 있지만, 시간이 지나면 깊은 정이 쌓일 거예요.'; + } + if (category === '결혼운') { + if (score >= 80) return '결혼 후에도 서로를 존중하며 행복한 가정을 꾸릴 수 있어요.'; + if (score >= 65) return '일상의 소소한 행복을 함께 나누며 안정적인 결혼생활이 가능해요.'; + return '결혼생활에서는 서로의 역할 분담과 소통이 특히 중요한 커플이에요.'; + } + if (category === '금전운') { + if (score >= 80) return '함께 재물을 모으고 불리는 데 시너지가 나는 조합이에요.'; + if (score >= 65) return '경제적으로 안정적인 파트너십을 만들어갈 수 있어요.'; + return '금전 문제에 대해 미리 충분히 대화하고 합의하는 것이 중요해요.'; + } + // 사업운 + if (score >= 80) return '비즈니스 파트너로도 훌륭한 조합이에요. 함께하면 성공 가능성이 높아요.'; + if (score >= 65) return '각자의 강점을 살려 역할을 나누면 좋은 결과를 만들 수 있어요.'; + return '사업적으로는 신중한 접근이 필요해요. 역할과 책임을 명확히 하세요.'; +} + export default async function CompatibilityResultPage({ searchParams }: PageProps) { const params = await searchParams; const { year1, month1, day1, hour1, gender1, year2, month2, day2, hour2, gender2 } = params; - // Person 1 Saju - const saju1 = calculateSaju( - parseInt(year1), - parseInt(month1), - parseInt(day1), - hour1 ? parseInt(hour1) : null, - gender1 - ); + const saju1 = calculateSaju(parseInt(year1), parseInt(month1), parseInt(day1), hour1 ? parseInt(hour1) : null, gender1); + const saju2 = calculateSaju(parseInt(year2), parseInt(month2), parseInt(day2), hour2 ? parseInt(hour2) : null, gender2); - // Person 2 Saju - const saju2 = calculateSaju( - parseInt(year2), - parseInt(month2), - parseInt(day2), - hour2 ? parseInt(hour2) : null, - gender2 - ); + const element1 = saju1.day.element; + const element2 = saju2.day.element; + const branch1 = saju1.day.branch; + const branch2 = saju2.day.branch; + + const elementRelation = getElementRelation(element1, element2); + const branchRel = getBranchRelation(branch1, branch2); // 궁합 점수 계산 const calculateCompatibility = () => { - let score = 50; // 기본 점수 + let score = 50; + if (element1 === element2) score += 10; + else if (elementRelation === 'produce' || elementRelation === 'produced') score += 25; + else score -= 10; // overcome/overcomed - // 오행 상생/상극 관계 - const element1 = saju1.day.element; - const element2 = saju2.day.element; - - const produceMap: { [key: string]: string } = { - '木': '火', '火': '土', '土': '金', '金': '水', '水': '木' - }; - const overcomeMap: { [key: string]: string } = { - '木': '土', '火': '金', '土': '水', '金': '木', '水': '火' - }; - - // 같은 오행: 보통 - if (element1 === element2) { - score += 10; - } - // 상생 관계: 매우 좋음 - else if (produceMap[element1] === element2 || produceMap[element2] === element1) { - score += 25; - } - // 상극 관계: 주의 필요 - else if (overcomeMap[element1] === element2 || overcomeMap[element2] === element1) { - score -= 10; - } - - // 지지 삼합/육합 관계 확인 - const branch1 = saju1.day.branch; - const branch2 = saju2.day.branch; - - // 육합 (六合) - 특별히 좋은 궁합 - const sixHarmony: { [key: string]: string } = { - '子': '丑', '丑': '子', - '寅': '亥', '亥': '寅', - '卯': '戌', '戌': '卯', - '辰': '酉', '酉': '辰', - '巳': '申', '申': '巳', - '午': '未', '未': '午' - }; - - if (sixHarmony[branch1] === branch2) { - score += 20; - } - - // 삼합 (三合) - 좋은 궁합 - const threeHarmony = [ - ['申', '子', '辰'], // 수국 - ['寅', '午', '戌'], // 화국 - ['亥', '卯', '未'], // 목국 - ['巳', '酉', '丑'] // 금국 - ]; - - for (const harmony of threeHarmony) { - if (harmony.includes(branch1) && harmony.includes(branch2)) { - score += 15; - break; - } - } - - // 충 (沖) - 나쁜 궁합 - const conflict: { [key: string]: string } = { - '子': '午', '午': '子', - '丑': '未', '未': '丑', - '寅': '申', '申': '寅', - '卯': '酉', '酉': '卯', - '辰': '戌', '戌': '辰', - '巳': '亥', '亥': '巳' - }; - - if (conflict[branch1] === branch2) { - score -= 20; - } + if (branchRel.sixHarmony) score += 20; + if (branchRel.threeHarmony) score += 15; + if (branchRel.conflict) score -= 20; return Math.min(100, Math.max(0, score)); }; @@ -144,13 +368,25 @@ export default async function CompatibilityResultPage({ searchParams }: PageProp return 'from-gray-400 to-gray-500'; }; - // 세부 궁합 점수 + // Deterministic seed for detailed scores + const seed = `${year1}${month1}${day1}${year2}${month2}${day2}`; const detailedScores = [ - { name: '연애운', score: compatibilityScore + Math.floor(Math.random() * 10 - 5), icon: '💑' }, - { name: '결혼운', score: compatibilityScore + Math.floor(Math.random() * 10 - 5), icon: '💍' }, - { name: '금전운', score: compatibilityScore + Math.floor(Math.random() * 10 - 5), icon: '💰' }, - { name: '사업운', score: compatibilityScore + Math.floor(Math.random() * 10 - 5), icon: '💼' }, - ].map(item => ({ ...item, score: Math.min(100, Math.max(0, item.score)) })); + { name: '연애운', score: compatibilityScore + seededOffset(seed, 1), icon: '💑' }, + { name: '결혼운', score: compatibilityScore + seededOffset(seed, 2), icon: '💍' }, + { name: '금전운', score: compatibilityScore + seededOffset(seed, 3), icon: '💰' }, + { name: '사업운', score: compatibilityScore + seededOffset(seed, 4), icon: '💼' }, + ].map(item => ({ + ...item, + score: Math.min(100, Math.max(0, item.score)), + comment: getDetailedComment(item.name, Math.min(100, Math.max(0, compatibilityScore + seededOffset(seed, ['연애운', '결혼운', '금전운', '사업운'].indexOf(item.name) + 1)))), + })); + + // 오행 기반 상세 해석 + const interp = getElementInterpretation(element1, element2, elementRelation); + const branchInterp = getBranchInterpretation(branchRel); + + const allPros = [...interp.pros, ...branchInterp.extraPros]; + const allCons = [...interp.cons, ...branchInterp.extraCons]; return (
@@ -162,18 +398,8 @@ export default async function CompatibilityResultPage({ searchParams }: PageProp 🔮 사주보기
- - 다시 보기 - - - 처음으로 - + 다시 보기 + 처음으로
@@ -183,27 +409,18 @@ export default async function CompatibilityResultPage({ searchParams }: PageProp
{/* Header */}
-

- 💕 궁합 결과 -

-

- 두 사람의 사주팔자를 비교한 결과입니다 -

+

💕 궁합 결과

+

두 사람의 사주팔자를 비교한 결과입니다

{/* 두 사람 정보 */}
- {/* Person 1 */}
👤

첫 번째 사람

-

- {saju1.birthDate.year}년 {saju1.birthDate.month}월 {saju1.birthDate.day}일 -

-

- {gender1 === 'male' ? '남성' : '여성'} -

+

{saju1.birthDate.year}년 {saju1.birthDate.month}월 {saju1.birthDate.day}일

+

{gender1 === 'male' ? '남성' : '여성'}

일간 (日干)

@@ -212,24 +429,17 @@ export default async function CompatibilityResultPage({ searchParams }: PageProp ({saju1.day.stemKr})

- {saju1.day.element} ({['木', '火', '土', '金', '水'].indexOf(saju1.day.element) >= 0 - ? ['목', '화', '토', '금', '수'][['木', '火', '土', '金', '水'].indexOf(saju1.day.element)] - : ''}) + {element1} ({elementKrMap[element1] || ''})

- {/* Person 2 */}
👤

두 번째 사람

-

- {saju2.birthDate.year}년 {saju2.birthDate.month}월 {saju2.birthDate.day}일 -

-

- {gender2 === 'male' ? '남성' : '여성'} -

+

{saju2.birthDate.year}년 {saju2.birthDate.month}월 {saju2.birthDate.day}일

+

{gender2 === 'male' ? '남성' : '여성'}

일간 (日干)

@@ -238,9 +448,7 @@ export default async function CompatibilityResultPage({ searchParams }: PageProp ({saju2.day.stemKr})

- {saju2.day.element} ({['木', '火', '土', '金', '水'].indexOf(saju2.day.element) >= 0 - ? ['목', '화', '토', '금', '수'][['木', '火', '土', '金', '水'].indexOf(saju2.day.element)] - : ''}) + {element2} ({elementKrMap[element2] || ''})

@@ -259,10 +467,7 @@ export default async function CompatibilityResultPage({ searchParams }: PageProp
-
+
@@ -281,89 +486,38 @@ export default async function CompatibilityResultPage({ searchParams }: PageProp {item.score}점 -
+
+

{item.comment}

))} - {/* 궁합 해석 */} -
- {/* 장점 */} -
-

- - 두 사람의 장점 -

-
    -
  • - - 서로의 부족한 점을 잘 보완해줄 수 있습니다. -
  • -
  • - - 대화와 소통이 원활하게 이루어질 수 있습니다. -
  • -
  • - - 함께 있을 때 편안함을 느낄 수 있습니다. -
  • -
-
- - {/* 주의점 */} -
-

- ⚠️ - 주의할 점 -

-
    -
  • - - 서로의 가치관 차이를 이해하려는 노력이 필요합니다. -
  • -
  • - - 감정적인 대화보다는 이성적인 대화를 나누세요. -
  • -
  • - - 작은 문제도 소통으로 해결하려는 자세가 중요합니다. -
  • -
-
-
- - {/* 조언 */} -
-

💡 조언

-

- 궁합은 참고사항일 뿐입니다. 서로를 이해하고 존중하며 노력한다면 - 어떤 궁합이든 행복한 관계를 만들 수 있습니다. - 사주는 가능성을 보여줄 뿐, 최종 결정은 두 사람의 마음과 노력에 달려있습니다. -

-
+ {/* 궁합 상세 해석 - 블러 잠금 영역 */} + {/* 다른 메뉴 */}
- +
💕

다시 보기

다른 궁합 확인하기

- +
📜

사주 보기

내 사주 확인하기

@@ -389,9 +543,7 @@ export default async function CompatibilityResultPage({ searchParams }: PageProp
🔮 사주보기
-

- 쟁승메이드가 제공하는 무료 사주 서비스 -

+

쟁승메이드가 제공하는 무료 사주 서비스

문의: bgg8988@gmail.com | 쟁승메이드

© 2025 쟁승메이드. All rights reserved.

diff --git a/app/layout.tsx b/app/layout.tsx index 7b1524d..daf22c5 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,3 +1,4 @@ + import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; @@ -27,23 +28,13 @@ export default function RootLayout({ return ( + - {kakaoAppKey && ( -