사주 풀이 고도화, NAS 배포 자동화
This commit is contained in:
46
.dockerignore
Normal file
46
.dockerignore
Normal file
@@ -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
|
||||
25
.env.example
Normal file
25
.env.example
Normal file
@@ -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
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -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
|
||||
|
||||
78
Dockerfile
Normal file
78
Dockerfile
Normal file
@@ -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"]
|
||||
61
SETUP_GUIDE.md
Normal file
61
SETUP_GUIDE.md
Normal file
@@ -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
|
||||
```
|
||||
127
app/api/analyze/route.ts
Normal file
127
app/api/analyze/route.ts
Normal file
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
9
app/api/health/route.ts
Normal file
9
app/api/health/route.ts
Normal file
@@ -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',
|
||||
});
|
||||
}
|
||||
31
app/auth/callback/route.ts
Normal file
31
app/auth/callback/route.ts
Normal file
@@ -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`)
|
||||
}
|
||||
198
app/compatibility/result/CompatibilityDetailUnlock.tsx
Normal file
198
app/compatibility/result/CompatibilityDetailUnlock.tsx
Normal file
@@ -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<any>(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 (
|
||||
<>
|
||||
<div className="relative mb-8">
|
||||
{/* 블러 처리된 미리보기 */}
|
||||
<div className="filter blur-sm select-none pointer-events-none opacity-60">
|
||||
<div className="grid md:grid-cols-2 gap-8 mb-8">
|
||||
<div className="bg-white rounded-2xl shadow-lg p-8">
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-4 flex items-center">
|
||||
<span className="text-3xl mr-3">✅</span>두 사람의 장점
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{allPros.slice(0, 3).map((_, i) => (
|
||||
<div key={i} className="h-4 bg-gray-200 rounded w-full"></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-2xl shadow-lg p-8">
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-4 flex items-center">
|
||||
<span className="text-3xl mr-3">⚠️</span>주의할 점
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{allCons.slice(0, 3).map((_, i) => (
|
||||
<div key={i} className="h-4 bg-gray-200 rounded w-full"></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gradient-to-r from-indigo-600 to-purple-600 rounded-2xl p-8">
|
||||
<div className="h-6 bg-white/30 rounded w-3/4 mx-auto mb-4"></div>
|
||||
<div className="h-4 bg-white/20 rounded w-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 잠금 오버레이 */}
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white/40 backdrop-blur-[2px] rounded-2xl">
|
||||
<div className="text-center p-8 bg-white/95 backdrop-blur-md rounded-2xl shadow-xl border border-pink-100 max-w-sm mx-auto">
|
||||
<div className="text-4xl mb-4">🔐</div>
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-2">상세 궁합 해석</h3>
|
||||
<p className="text-gray-600 mb-2">
|
||||
{element1Kr}({element1})과 {element2Kr}({element2})의<br />
|
||||
오행 기반 맞춤 해석을 확인하세요.
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
토큰 1개 사용 | 보유: <span className="font-bold text-pink-600">{credits}개</span>
|
||||
</p>
|
||||
<button
|
||||
onClick={handleUnlock}
|
||||
disabled={loading}
|
||||
className="w-full bg-gradient-to-r from-pink-600 to-purple-600 text-white font-bold py-3 px-6 rounded-xl hover:shadow-lg hover:scale-105 transition transform disabled:opacity-50"
|
||||
>
|
||||
{loading ? '처리 중...' : credits >= 1 ? '토큰 1개로 잠금해제' : '토큰 충전하기'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TokenPurchaseModal
|
||||
isOpen={showModal}
|
||||
onClose={() => setShowModal(false)}
|
||||
onPurchaseComplete={handlePurchaseComplete}
|
||||
user={user}
|
||||
supabase={supabase}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Unlocked content
|
||||
return (
|
||||
<>
|
||||
<div className="grid md:grid-cols-2 gap-8 mb-8">
|
||||
<div className="bg-white rounded-2xl shadow-lg p-8">
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-4 flex items-center">
|
||||
<span className="text-3xl mr-3">✅</span>두 사람의 장점
|
||||
</h3>
|
||||
<ul className="space-y-3 text-gray-700">
|
||||
{allPros.map((pro, i) => (
|
||||
<li key={i} className="flex items-start">
|
||||
<span className="text-pink-600 mr-2 mt-1 flex-shrink-0">•</span>
|
||||
<span>{pro}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl shadow-lg p-8">
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-4 flex items-center">
|
||||
<span className="text-3xl mr-3">⚠️</span>주의할 점
|
||||
</h3>
|
||||
<ul className="space-y-3 text-gray-700">
|
||||
{allCons.map((con, i) => (
|
||||
<li key={i} className="flex items-start">
|
||||
<span className="text-purple-600 mr-2 mt-1 flex-shrink-0">•</span>
|
||||
<span>{con}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gradient-to-r from-indigo-600 to-purple-600 rounded-2xl shadow-lg p-8 text-white mb-8">
|
||||
<h3 className="text-2xl font-bold mb-4 text-center">💡 조언</h3>
|
||||
<p className="text-lg leading-relaxed text-center">{advice}</p>
|
||||
<p className="text-sm text-white/70 mt-4 text-center">
|
||||
궁합은 참고사항이에요. 서로를 이해하고 존중하며 노력한다면 어떤 궁합이든 행복한 관계를 만들 수 있답니다.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
'木': '목', '火': '화', '土': '토', '金': '금', '水': '수'
|
||||
};
|
||||
|
||||
// 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<string, string> = {
|
||||
'木': '火', '火': '土', '土': '金', '金': '水', '水': '木'
|
||||
};
|
||||
if (el1 === el2) return 'same';
|
||||
if (produceMap[el1] === el2) return 'produce';
|
||||
if (produceMap[el2] === el1) return 'produced';
|
||||
const overcomeMap: Record<string, string> = {
|
||||
'木': '土', '火': '金', '土': '水', '金': '木', '水': '火'
|
||||
};
|
||||
if (overcomeMap[el1] === el2) return 'overcome';
|
||||
return 'overcomed';
|
||||
}
|
||||
|
||||
// 지지 관계 판별
|
||||
function getBranchRelation(b1: string, b2: string): { sixHarmony: boolean; threeHarmony: boolean; conflict: boolean } {
|
||||
const sixHarmony: Record<string, string> = {
|
||||
'子': '丑', '丑': '子', '寅': '亥', '亥': '寅',
|
||||
'卯': '戌', '戌': '卯', '辰': '酉', '酉': '辰',
|
||||
'巳': '申', '申': '巳', '午': '未', '未': '午'
|
||||
};
|
||||
const threeHarmonyGroups = [
|
||||
['申', '子', '辰'], ['寅', '午', '戌'],
|
||||
['亥', '卯', '未'], ['巳', '酉', '丑']
|
||||
];
|
||||
const conflictMap: Record<string, string> = {
|
||||
'子': '午', '午': '子', '丑': '未', '未': '丑',
|
||||
'寅': '申', '申': '寅', '卯': '酉', '酉': '卯',
|
||||
'辰': '戌', '戌': '辰', '巳': '亥', '亥': '巳'
|
||||
};
|
||||
|
||||
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<string, { pros: string[]; cons: string[]; advice: string }> = {
|
||||
// 상생: 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<string, string> = { '木': '火', '火': '土', '土': '金', '金': '水', '水': '木' };
|
||||
return map[el] || '';
|
||||
}
|
||||
|
||||
function overcomeTarget(el: string): string {
|
||||
const map: Record<string, string> = { '木': '土', '火': '金', '土': '水', '金': '木', '水': '火' };
|
||||
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 calculateCompatibility = () => {
|
||||
let score = 50; // 기본 점수
|
||||
|
||||
// 오행 상생/상극 관계
|
||||
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 } = {
|
||||
'子': '丑', '丑': '子',
|
||||
'寅': '亥', '亥': '寅',
|
||||
'卯': '戌', '戌': '卯',
|
||||
'辰': '酉', '酉': '辰',
|
||||
'巳': '申', '申': '巳',
|
||||
'午': '未', '未': '午'
|
||||
};
|
||||
const elementRelation = getElementRelation(element1, element2);
|
||||
const branchRel = getBranchRelation(branch1, branch2);
|
||||
|
||||
if (sixHarmony[branch1] === branch2) {
|
||||
score += 20;
|
||||
}
|
||||
// 궁합 점수 계산
|
||||
const calculateCompatibility = () => {
|
||||
let score = 50;
|
||||
if (element1 === element2) score += 10;
|
||||
else if (elementRelation === 'produce' || elementRelation === 'produced') score += 25;
|
||||
else score -= 10; // overcome/overcomed
|
||||
|
||||
// 삼합 (三合) - 좋은 궁합
|
||||
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 (
|
||||
<div className="min-h-screen bg-gradient-to-br from-pink-50 via-purple-50 to-indigo-50">
|
||||
@@ -162,18 +398,8 @@ export default async function CompatibilityResultPage({ searchParams }: PageProp
|
||||
🔮 사주보기
|
||||
</Link>
|
||||
<div className="flex gap-4">
|
||||
<Link
|
||||
href="/compatibility"
|
||||
className="text-gray-700 hover:text-pink-600 transition font-medium"
|
||||
>
|
||||
다시 보기
|
||||
</Link>
|
||||
<Link
|
||||
href="/"
|
||||
className="text-gray-700 hover:text-pink-600 transition font-medium"
|
||||
>
|
||||
처음으로
|
||||
</Link>
|
||||
<Link href="/compatibility" className="text-gray-700 hover:text-pink-600 transition font-medium">다시 보기</Link>
|
||||
<Link href="/" className="text-gray-700 hover:text-pink-600 transition font-medium">처음으로</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -183,27 +409,18 @@ export default async function CompatibilityResultPage({ searchParams }: PageProp
|
||||
<div id="pdf-content" className="max-w-6xl mx-auto px-4 py-12">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-12">
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-gray-900 mb-4">
|
||||
💕 궁합 결과
|
||||
</h1>
|
||||
<p className="text-xl text-gray-600">
|
||||
두 사람의 사주팔자를 비교한 결과입니다
|
||||
</p>
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-gray-900 mb-4">💕 궁합 결과</h1>
|
||||
<p className="text-xl text-gray-600">두 사람의 사주팔자를 비교한 결과입니다</p>
|
||||
</div>
|
||||
|
||||
{/* 두 사람 정보 */}
|
||||
<div className="grid md:grid-cols-2 gap-6 mb-12">
|
||||
{/* Person 1 */}
|
||||
<div className="bg-gradient-to-br from-pink-500 to-pink-600 rounded-2xl shadow-xl p-8 text-white">
|
||||
<div className="text-center mb-6">
|
||||
<div className="text-5xl mb-3">👤</div>
|
||||
<h3 className="text-2xl font-bold">첫 번째 사람</h3>
|
||||
<p className="text-pink-100 mt-2">
|
||||
{saju1.birthDate.year}년 {saju1.birthDate.month}월 {saju1.birthDate.day}일
|
||||
</p>
|
||||
<p className="text-pink-100">
|
||||
{gender1 === 'male' ? '남성' : '여성'}
|
||||
</p>
|
||||
<p className="text-pink-100 mt-2">{saju1.birthDate.year}년 {saju1.birthDate.month}월 {saju1.birthDate.day}일</p>
|
||||
<p className="text-pink-100">{gender1 === 'male' ? '남성' : '여성'}</p>
|
||||
</div>
|
||||
<div className="bg-white/20 backdrop-blur-sm rounded-xl p-4">
|
||||
<p className="text-center mb-2 font-semibold">일간 (日干)</p>
|
||||
@@ -212,24 +429,17 @@ export default async function CompatibilityResultPage({ searchParams }: PageProp
|
||||
<span className="text-2xl ml-2">({saju1.day.stemKr})</span>
|
||||
</div>
|
||||
<p className="text-center mt-2 text-pink-100">
|
||||
{saju1.day.element} ({['木', '火', '土', '金', '水'].indexOf(saju1.day.element) >= 0
|
||||
? ['목', '화', '토', '금', '수'][['木', '火', '土', '金', '水'].indexOf(saju1.day.element)]
|
||||
: ''})
|
||||
{element1} ({elementKrMap[element1] || ''})
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Person 2 */}
|
||||
<div className="bg-gradient-to-br from-purple-500 to-purple-600 rounded-2xl shadow-xl p-8 text-white">
|
||||
<div className="text-center mb-6">
|
||||
<div className="text-5xl mb-3">👤</div>
|
||||
<h3 className="text-2xl font-bold">두 번째 사람</h3>
|
||||
<p className="text-purple-100 mt-2">
|
||||
{saju2.birthDate.year}년 {saju2.birthDate.month}월 {saju2.birthDate.day}일
|
||||
</p>
|
||||
<p className="text-purple-100">
|
||||
{gender2 === 'male' ? '남성' : '여성'}
|
||||
</p>
|
||||
<p className="text-purple-100 mt-2">{saju2.birthDate.year}년 {saju2.birthDate.month}월 {saju2.birthDate.day}일</p>
|
||||
<p className="text-purple-100">{gender2 === 'male' ? '남성' : '여성'}</p>
|
||||
</div>
|
||||
<div className="bg-white/20 backdrop-blur-sm rounded-xl p-4">
|
||||
<p className="text-center mb-2 font-semibold">일간 (日干)</p>
|
||||
@@ -238,9 +448,7 @@ export default async function CompatibilityResultPage({ searchParams }: PageProp
|
||||
<span className="text-2xl ml-2">({saju2.day.stemKr})</span>
|
||||
</div>
|
||||
<p className="text-center mt-2 text-purple-100">
|
||||
{saju2.day.element} ({['木', '火', '土', '金', '水'].indexOf(saju2.day.element) >= 0
|
||||
? ['목', '화', '토', '금', '수'][['木', '火', '土', '金', '水'].indexOf(saju2.day.element)]
|
||||
: ''})
|
||||
{element2} ({elementKrMap[element2] || ''})
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -259,10 +467,7 @@ export default async function CompatibilityResultPage({ searchParams }: PageProp
|
||||
</div>
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className="w-full bg-white/30 rounded-full h-4">
|
||||
<div
|
||||
className="bg-white h-4 rounded-full transition-all duration-1000"
|
||||
style={{ width: `${compatibilityScore}%` }}
|
||||
></div>
|
||||
<div className="bg-white h-4 rounded-full transition-all duration-1000" style={{ width: `${compatibilityScore}%` }}></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -281,89 +486,38 @@ export default async function CompatibilityResultPage({ searchParams }: PageProp
|
||||
</div>
|
||||
<span className="text-2xl font-bold text-pink-600">{item.score}점</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-3">
|
||||
<div className="w-full bg-gray-200 rounded-full h-3 mb-3">
|
||||
<div
|
||||
className="bg-gradient-to-r from-pink-500 to-purple-500 h-3 rounded-full transition-all duration-500"
|
||||
style={{ width: `${item.score}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">{item.comment}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 궁합 해석 */}
|
||||
<div className="grid md:grid-cols-2 gap-8 mb-8">
|
||||
{/* 장점 */}
|
||||
<div className="bg-white rounded-2xl shadow-lg p-8">
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-4 flex items-center">
|
||||
<span className="text-3xl mr-3">✅</span>
|
||||
두 사람의 장점
|
||||
</h3>
|
||||
<ul className="space-y-3 text-gray-700">
|
||||
<li className="flex items-start">
|
||||
<span className="text-pink-600 mr-2">•</span>
|
||||
<span>서로의 부족한 점을 잘 보완해줄 수 있습니다.</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-pink-600 mr-2">•</span>
|
||||
<span>대화와 소통이 원활하게 이루어질 수 있습니다.</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-pink-600 mr-2">•</span>
|
||||
<span>함께 있을 때 편안함을 느낄 수 있습니다.</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* 주의점 */}
|
||||
<div className="bg-white rounded-2xl shadow-lg p-8">
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-4 flex items-center">
|
||||
<span className="text-3xl mr-3">⚠️</span>
|
||||
주의할 점
|
||||
</h3>
|
||||
<ul className="space-y-3 text-gray-700">
|
||||
<li className="flex items-start">
|
||||
<span className="text-purple-600 mr-2">•</span>
|
||||
<span>서로의 가치관 차이를 이해하려는 노력이 필요합니다.</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-purple-600 mr-2">•</span>
|
||||
<span>감정적인 대화보다는 이성적인 대화를 나누세요.</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-purple-600 mr-2">•</span>
|
||||
<span>작은 문제도 소통으로 해결하려는 자세가 중요합니다.</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 조언 */}
|
||||
<div className="bg-gradient-to-r from-indigo-600 to-purple-600 rounded-2xl shadow-lg p-8 text-white mb-8">
|
||||
<h3 className="text-2xl font-bold mb-4 text-center">💡 조언</h3>
|
||||
<p className="text-lg leading-relaxed text-center">
|
||||
궁합은 참고사항일 뿐입니다. 서로를 이해하고 존중하며 노력한다면
|
||||
어떤 궁합이든 행복한 관계를 만들 수 있습니다.
|
||||
사주는 가능성을 보여줄 뿐, 최종 결정은 두 사람의 마음과 노력에 달려있습니다.
|
||||
</p>
|
||||
</div>
|
||||
{/* 궁합 상세 해석 - 블러 잠금 영역 */}
|
||||
<CompatibilityDetailUnlock
|
||||
allPros={allPros}
|
||||
allCons={allCons}
|
||||
advice={interp.advice}
|
||||
element1={element1}
|
||||
element2={element2}
|
||||
element1Kr={elementKrMap[element1] || ''}
|
||||
element2Kr={elementKrMap[element2] || ''}
|
||||
/>
|
||||
|
||||
{/* 다른 메뉴 */}
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<Link
|
||||
href="/compatibility"
|
||||
className="bg-white rounded-xl p-6 shadow-lg hover:shadow-xl transition text-center group"
|
||||
>
|
||||
<Link href="/compatibility" className="bg-white rounded-xl p-6 shadow-lg hover:shadow-xl transition text-center group">
|
||||
<div className="text-4xl mb-3">💕</div>
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-2">다시 보기</h3>
|
||||
<p className="text-gray-600 text-sm">다른 궁합 확인하기</p>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/"
|
||||
className="bg-white rounded-xl p-6 shadow-lg hover:shadow-xl transition text-center group"
|
||||
>
|
||||
<Link href="/" className="bg-white rounded-xl p-6 shadow-lg hover:shadow-xl transition text-center group">
|
||||
<div className="text-4xl mb-3">📜</div>
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-2">사주 보기</h3>
|
||||
<p className="text-gray-600 text-sm">내 사주 확인하기</p>
|
||||
@@ -389,9 +543,7 @@ export default async function CompatibilityResultPage({ searchParams }: PageProp
|
||||
<div className="text-2xl font-bold mb-4 bg-gradient-to-r from-pink-400 to-purple-400 bg-clip-text text-transparent">
|
||||
🔮 사주보기
|
||||
</div>
|
||||
<p className="text-gray-400 mb-6">
|
||||
쟁승메이드가 제공하는 무료 사주 서비스
|
||||
</p>
|
||||
<p className="text-gray-400 mb-6">쟁승메이드가 제공하는 무료 사주 서비스</p>
|
||||
<div className="text-sm text-gray-500">
|
||||
<p>문의: bgg8988@gmail.com | <a href="https://jaengseung-made.com" target="_blank" rel="noopener noreferrer" className="hover:text-pink-400">쟁승메이드</a></p>
|
||||
<p className="mt-2">© 2025 쟁승메이드. All rights reserved.</p>
|
||||
|
||||
@@ -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 (
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<script src="https://cdn.iamport.kr/v1/iamport.js"></script>
|
||||
<script
|
||||
src="https://t1.kakaocdn.net/kakao_js_sdk/2.7.0/kakao.min.js"
|
||||
integrity="sha384-l+xbElFSnPZ2rOaPrU//2FF5B4LB8FiX5q4fXYTlfcG4PGpMkE1vcL7kNXI6Cci0"
|
||||
crossOrigin="anonymous"
|
||||
async
|
||||
></script>
|
||||
{kakaoAppKey && (
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
if (typeof window !== 'undefined' && window.Kakao && !window.Kakao.isInitialized()) {
|
||||
window.Kakao.init('${kakaoAppKey}');
|
||||
}
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</head>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
|
||||
163
app/login/page.tsx
Normal file
163
app/login/page.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { createBrowserClient } from '@supabase/ssr'
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export default function LoginPage() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [isSignUp, setIsSignUp] = 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!
|
||||
);
|
||||
|
||||
const handleAuth = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
|
||||
if (isSignUp) {
|
||||
// Sign Up with Password
|
||||
const { data, error } = await supabase.auth.signUp({
|
||||
email,
|
||||
password,
|
||||
options: {
|
||||
emailRedirectTo: `${window.location.origin}/auth/callback`,
|
||||
}
|
||||
});
|
||||
if (error) {
|
||||
alert('회원가입 실패: ' + error.message);
|
||||
} else if (data.user && data.user.identities && data.user.identities.length === 0) {
|
||||
alert('이미 가입된 이메일입니다. 로그인해주세요.');
|
||||
} else {
|
||||
alert('가입이 완료되었습니다! 이메일 인증이 필요할 수 있습니다. 로그인을 시도해주세요.');
|
||||
setIsSignUp(false); // Switch to login mode
|
||||
}
|
||||
} else {
|
||||
// Sign In with Password
|
||||
const { error } = await supabase.auth.signInWithPassword({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
if (error) {
|
||||
alert('로그인 실패: ' + error.message);
|
||||
} else {
|
||||
router.push('/mypage');
|
||||
router.refresh();
|
||||
}
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleSocialLogin = async (provider: 'google' | 'kakao') => {
|
||||
const { error } = await supabase.auth.signInWithOAuth({
|
||||
provider,
|
||||
options: {
|
||||
redirectTo: `${window.location.origin}/auth/callback`,
|
||||
},
|
||||
});
|
||||
if (error) alert('소셜 로그인 오류: ' + error.message);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-indigo-50 to-purple-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md p-8">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">
|
||||
{isSignUp ? '회원가입' : '로그인'}
|
||||
</h1>
|
||||
<p className="text-gray-500">사주 기록을 저장하고 언제든 다시 확인하세요</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleAuth} className="space-y-4 mb-6">
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
이메일 주소
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="name@example.com"
|
||||
className="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none transition"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
비밀번호
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
className="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none transition"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-3 px-4 rounded-lg transition disabled:opacity-50"
|
||||
>
|
||||
{loading ? '처리 중...' : (isSignUp ? '회원가입' : '로그인')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="text-center mb-6">
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm text-indigo-600 hover:text-indigo-800 font-medium"
|
||||
onClick={() => setIsSignUp(!isSignUp)}
|
||||
>
|
||||
{isSignUp ? '이미 계정이 있으신가요? 로그인' : '계정이 없으신가요? 회원가입'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="relative my-6">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-gray-200"></div>
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-2 bg-white text-gray-500">또는 소셜 로그인</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
onClick={() => handleSocialLogin('google')}
|
||||
className="flex items-center justify-center px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition"
|
||||
>
|
||||
<span className="ml-2">Google</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSocialLogin('kakao')}
|
||||
className="flex items-center justify-center px-4 py-2 bg-[#FEE500] border border-[#FEE500] rounded-lg hover:bg-[#FDD835] transition"
|
||||
>
|
||||
<span className="ml-2 text-[#000000bd]">Kakao</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 p-4 bg-yellow-50 rounded-lg text-xs text-yellow-800 border border-yellow-200">
|
||||
<h4 className="font-bold mb-1">⚠️ 회원가입 후 이메일 인증 필수!</h4>
|
||||
<p className="mb-2">
|
||||
Supabase는 기본적으로 이메일 인증을 요구합니다.
|
||||
가입 후 받은 메일의 링크를 클릭해야 로그인이 가능합니다.
|
||||
</p>
|
||||
<p className="font-semibold text-gray-700">
|
||||
(개발용 팁: Supabase 대시보드 > Authentication > Providers > Email > "Confirm email"를 끄면 인증 없이 바로 로그인 가능)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
185
app/mypage/page.tsx
Normal file
185
app/mypage/page.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { createBrowserClient } from '@supabase/ssr'
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import TokenPurchaseModal from '@/components/TokenPurchaseModal';
|
||||
import { ensureProfile } from '@/lib/ensure-profile';
|
||||
|
||||
export default function MyPage() {
|
||||
const [user, setUser] = useState<any>(null);
|
||||
const [records, setRecords] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [credits, setCredits] = useState(0);
|
||||
const [showTokenModal, setShowTokenModal] = useState(false);
|
||||
const router = useRouter();
|
||||
const supabase = createBrowserClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
async function getUserData() {
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
router.push('/login');
|
||||
return;
|
||||
}
|
||||
setUser(user);
|
||||
|
||||
// Fetch credits (프로필 없으면 자동 생성)
|
||||
const currentCredits = await ensureProfile(supabase, user);
|
||||
setCredits(currentCredits);
|
||||
|
||||
// Fetch records
|
||||
const { data, error } = await supabase
|
||||
.from('saju_records')
|
||||
.select('*')
|
||||
.eq('user_id', user.id) // Ensure only fetching user's records
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching records:', error.message, error.details, error.hint);
|
||||
alert('기록을 불러오는 중 오류가 발생했습니다: ' + error.message);
|
||||
} else {
|
||||
setRecords(data || []);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
getUserData();
|
||||
}, [router]);
|
||||
|
||||
if (loading) return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="w-16 h-16 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin"></div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Navigation */}
|
||||
<nav className="bg-white/80 backdrop-blur-md border-b border-gray-200 sticky top-0 z-50">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center h-16">
|
||||
<Link href="/" className="text-2xl font-bold bg-gradient-to-r from-indigo-600 to-purple-600 bg-clip-text text-transparent">
|
||||
🔮 사주포춘
|
||||
</Link>
|
||||
<div className="hidden md:flex space-x-6">
|
||||
<Link href="/saju" className="text-gray-700 hover:text-indigo-600 transition font-medium">사주팔자</Link>
|
||||
<Link href="/compatibility" className="text-gray-700 hover:text-indigo-600 transition font-medium">궁합</Link>
|
||||
<Link href="/tojeong" className="text-gray-700 hover:text-indigo-600 transition font-medium">토정비결</Link>
|
||||
<Link href="/mypage" className="text-indigo-600 font-bold">마이페이지</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="p-4 md:p-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<header className="mb-8 flex justify-between items-center bg-white p-6 rounded-2xl shadow-sm border border-gray-100">
|
||||
<h1 className="text-3xl font-bold text-gray-900">내 사주 보관함</h1>
|
||||
<Link href="/saju" className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition font-bold shadow-md">
|
||||
+ 새 사주 보기
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
<div className="bg-white rounded-2xl shadow-sm p-6 mb-8 border border-gray-100 flex flex-col md:flex-row items-center gap-6">
|
||||
<div className="w-20 h-20 bg-gradient-to-br from-indigo-500 to-purple-500 rounded-full flex items-center justify-center text-white text-3xl font-bold shadow-lg">
|
||||
{user.email[0].toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1 text-center md:text-left">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-1">{user.email}</h2>
|
||||
<div className="flex flex-wrap gap-2 justify-center md:justify-start">
|
||||
<span className="px-3 py-1 bg-gray-100 text-gray-600 rounded-full text-sm">회원</span>
|
||||
<span className="px-3 py-1 bg-indigo-50 text-indigo-700 rounded-full text-sm font-semibold">보유 토큰: {credits}개</span>
|
||||
<button
|
||||
onClick={() => setShowTokenModal(true)}
|
||||
className="px-3 py-1 bg-indigo-600 text-white rounded-full text-sm font-semibold hover:bg-indigo-700 transition"
|
||||
>
|
||||
토큰 충전
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={async () => {
|
||||
await supabase.auth.signOut();
|
||||
router.push('/');
|
||||
router.refresh();
|
||||
}}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 text-gray-700 transition"
|
||||
>
|
||||
로그아웃
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4 ml-2 border-l-4 border-indigo-500 pl-3">저장된 기록 ({records.length})</h2>
|
||||
|
||||
{records.length === 0 ? (
|
||||
<div className="text-center py-20 bg-white rounded-2xl border border-gray-200 border-dashed">
|
||||
<div className="text-6xl mb-4">📭</div>
|
||||
<p className="text-xl text-gray-800 font-bold mb-2">아직 저장된 사주 기록이 없습니다.</p>
|
||||
<p className="text-gray-500 mb-8">나의 운세를 확인하고 평생 소장해보세요!</p>
|
||||
<Link
|
||||
href="/saju"
|
||||
className="px-8 py-3 bg-indigo-600 text-white rounded-xl hover:bg-indigo-700 transition font-bold shadow-lg hover:shadow-xl transform hover:-translate-y-1"
|
||||
>
|
||||
지금 무료로 사주 보기
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
{records.map((record) => (
|
||||
<div key={record.id} className="bg-white p-6 rounded-2xl shadow-sm hover:shadow-xl transition-all duration-300 border border-gray-100 group">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<span className="inline-block px-3 py-1 bg-indigo-50 text-indigo-700 text-xs font-bold rounded-full mb-2">
|
||||
{new Date(record.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
<h3 className="text-xl font-bold text-gray-900 group-hover:text-indigo-600 transition">
|
||||
{record.saju_data.birthDate.year}년 {record.saju_data.birthDate.month}월 {record.saju_data.birthDate.day}일생
|
||||
</h3>
|
||||
</div>
|
||||
<span className="text-2xl">🔮</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 mb-6 line-clamp-3 bg-gray-50 p-3 rounded-lg h-20">
|
||||
{record.interpretation
|
||||
? record.interpretation.replace(/[#*]/g, '').substring(0, 100) + '...'
|
||||
: '해석 내용 없음'}
|
||||
</div>
|
||||
<Link
|
||||
href={`/result/saved/${record.id}`}
|
||||
className="block w-full text-center py-3 bg-indigo-600 text-white rounded-xl hover:bg-indigo-700 transition font-bold shadow-md"
|
||||
>
|
||||
다시 보기
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TokenPurchaseModal
|
||||
isOpen={showTokenModal}
|
||||
onClose={() => setShowTokenModal(false)}
|
||||
onPurchaseComplete={async () => {
|
||||
setShowTokenModal(false);
|
||||
if (user) {
|
||||
const { data: profile } = await supabase
|
||||
.from('profiles')
|
||||
.select('credits')
|
||||
.eq('id', user.id)
|
||||
.single();
|
||||
if (profile) setCredits(profile.credits || 0);
|
||||
}
|
||||
}}
|
||||
user={user}
|
||||
supabase={supabase}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
341
app/page.tsx
341
app/page.tsx
@@ -1,220 +1,229 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
import SajuForm from './components/SajuForm';
|
||||
import UserMenu from '@/components/UserMenu';
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-indigo-50 via-purple-50 to-pink-50">
|
||||
<div className="min-h-screen bg-gray-950 text-white">
|
||||
{/* Navigation */}
|
||||
<nav className="bg-white/80 backdrop-blur-md border-b border-gray-200 sticky top-0 z-50">
|
||||
<nav className="bg-gray-950/80 backdrop-blur-md border-b border-gray-800 sticky top-0 z-50">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center h-16">
|
||||
<div className="text-2xl font-bold bg-gradient-to-r from-indigo-600 to-purple-600 bg-clip-text text-transparent">
|
||||
🔮 사주보기
|
||||
<Link href="/" className="text-2xl font-bold bg-gradient-to-r from-amber-400 to-orange-400 bg-clip-text text-transparent">
|
||||
🔮 사주포춘
|
||||
</Link>
|
||||
<div className="flex items-center space-x-6">
|
||||
<div className="hidden md:flex space-x-6 mr-4">
|
||||
<Link href="/saju" className="text-gray-300 hover:text-amber-400 transition font-medium">사주팔자</Link>
|
||||
<Link href="/compatibility" className="text-gray-300 hover:text-amber-400 transition font-medium">궁합</Link>
|
||||
<Link href="/tojeong" className="text-gray-300 hover:text-amber-400 transition font-medium">토정비결</Link>
|
||||
</div>
|
||||
<div className="hidden md:flex space-x-6">
|
||||
<Link href="#saju" className="text-gray-700 hover:text-indigo-600 transition font-medium">사주팔자</Link>
|
||||
<Link href="/compatibility" className="text-gray-700 hover:text-indigo-600 transition font-medium">궁합</Link>
|
||||
<UserMenu />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="pt-20 pb-32 px-4">
|
||||
<div className="max-w-4xl mx-auto text-center">
|
||||
<div className="inline-block mb-6 px-6 py-2 bg-white/50 backdrop-blur-sm rounded-full text-indigo-700 font-semibold border border-indigo-200">
|
||||
무료로 내 사주를 확인해보세요
|
||||
<section className="relative overflow-hidden">
|
||||
{/* Background decorations */}
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="absolute top-20 left-10 w-72 h-72 bg-amber-500/10 rounded-full blur-3xl"></div>
|
||||
<div className="absolute top-40 right-10 w-96 h-96 bg-purple-500/10 rounded-full blur-3xl"></div>
|
||||
<div className="absolute bottom-20 left-1/3 w-80 h-80 bg-indigo-500/10 rounded-full blur-3xl"></div>
|
||||
</div>
|
||||
|
||||
<h1 className="text-5xl md:text-7xl font-bold text-gray-900 mb-6 leading-tight">
|
||||
나의 <span className="bg-gradient-to-r from-indigo-600 to-purple-600 bg-clip-text text-transparent">사주팔자</span>를<br />
|
||||
확인하세요
|
||||
<div className="relative max-w-7xl mx-auto px-4 pt-24 pb-32 text-center">
|
||||
<div className="inline-block mb-8 px-5 py-2 bg-amber-500/10 border border-amber-500/30 rounded-full text-amber-400 text-sm font-semibold tracking-wide">
|
||||
AI 명리학 전문 상담
|
||||
</div>
|
||||
|
||||
<h1 className="text-5xl md:text-7xl font-bold mb-8 leading-tight">
|
||||
당신의 <span className="bg-gradient-to-r from-amber-400 via-orange-400 to-red-400 bg-clip-text text-transparent">운명</span>을<br />
|
||||
읽어드립니다
|
||||
</h1>
|
||||
|
||||
<p className="text-xl text-gray-600 mb-12 max-w-2xl mx-auto">
|
||||
생년월일시를 입력하면 무료로 사주팔자, 운세, 궁합을 확인할 수 있습니다.
|
||||
쉽고 빠르게 나의 운명을 알아보세요.
|
||||
<p className="text-xl md:text-2xl text-gray-400 mb-16 max-w-3xl mx-auto leading-relaxed">
|
||||
수천 년 전통 명리학의 지혜와 최신 AI 기술이 만나<br className="hidden md:block" />
|
||||
당신만을 위한 깊이 있는 사주 해석을 제공합니다.
|
||||
</p>
|
||||
|
||||
{/* Main Input Card */}
|
||||
<div className="bg-white rounded-3xl shadow-2xl p-8 md:p-12 max-w-2xl mx-auto">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-8">생년월일시 입력</h2>
|
||||
{/* Service Cards */}
|
||||
<div className="grid md:grid-cols-3 gap-6 max-w-5xl mx-auto mb-20">
|
||||
{/* 사주팔자 */}
|
||||
<Link href="/saju" className="group relative bg-gradient-to-br from-gray-900 to-gray-800 border border-gray-700 rounded-3xl p-8 hover:border-amber-500/50 transition-all duration-300 hover:shadow-2xl hover:shadow-amber-500/10 text-left">
|
||||
<div className="text-5xl mb-6">📜</div>
|
||||
<h3 className="text-2xl font-bold text-white mb-3 group-hover:text-amber-400 transition">사주팔자</h3>
|
||||
<p className="text-gray-400 mb-6 leading-relaxed">
|
||||
천간과 지지의 조화로 타고난 성격, 재능, 인생의 흐름을 심층 분석합니다.
|
||||
</p>
|
||||
<div className="flex items-center text-amber-400 font-semibold text-sm">
|
||||
<span>무료 감정 시작</span>
|
||||
<svg className="w-4 h-4 ml-2 group-hover:translate-x-1 transition" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /></svg>
|
||||
</div>
|
||||
<div className="absolute top-4 right-4 px-2 py-1 bg-amber-500/20 text-amber-400 text-xs font-bold rounded-full">인기</div>
|
||||
</Link>
|
||||
|
||||
<SajuForm />
|
||||
{/* 생년월일 */}
|
||||
<div>
|
||||
<label className="block text-left text-sm font-semibold text-gray-700 mb-2">
|
||||
생년월일
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<input
|
||||
type="number"
|
||||
placeholder="년 (예: 1990)"
|
||||
className="px-4 py-3 border-2 border-gray-200 rounded-xl focus:border-indigo-500 focus:outline-none transition"
|
||||
min="1900"
|
||||
max="2100"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="월 (1-12)"
|
||||
className="px-4 py-3 border-2 border-gray-200 rounded-xl focus:border-indigo-500 focus:outline-none transition"
|
||||
min="1"
|
||||
max="12"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="일 (1-31)"
|
||||
className="px-4 py-3 border-2 border-gray-200 rounded-xl focus:border-indigo-500 focus:outline-none transition"
|
||||
min="1"
|
||||
max="31"
|
||||
/>
|
||||
</div>
|
||||
{/* 궁합 */}
|
||||
<Link href="/compatibility" className="group relative bg-gradient-to-br from-gray-900 to-gray-800 border border-gray-700 rounded-3xl p-8 hover:border-pink-500/50 transition-all duration-300 hover:shadow-2xl hover:shadow-pink-500/10 text-left">
|
||||
<div className="text-5xl mb-6">💕</div>
|
||||
<h3 className="text-2xl font-bold text-white mb-3 group-hover:text-pink-400 transition">궁합 분석</h3>
|
||||
<p className="text-gray-400 mb-6 leading-relaxed">
|
||||
두 사람의 오행 관계와 지지 합충을 분석하여 관계의 조화를 확인합니다.
|
||||
</p>
|
||||
<div className="flex items-center text-pink-400 font-semibold text-sm">
|
||||
<span>궁합 확인하기</span>
|
||||
<svg className="w-4 h-4 ml-2 group-hover:translate-x-1 transition" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /></svg>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* 태어난 시간 */}
|
||||
<div>
|
||||
<label className="block text-left text-sm font-semibold text-gray-700 mb-2">
|
||||
태어난 시간 (선택)
|
||||
</label>
|
||||
<select className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:border-indigo-500 focus:outline-none transition">
|
||||
<option value="">모름 / 시간 선택 안함</option>
|
||||
<option value="23-01">자시 (子時) 23:00 - 01:00</option>
|
||||
<option value="01-03">축시 (丑時) 01:00 - 03:00</option>
|
||||
<option value="03-05">인시 (寅時) 03:00 - 05:00</option>
|
||||
<option value="05-07">묘시 (卯時) 05:00 - 07:00</option>
|
||||
<option value="07-09">진시 (辰時) 07:00 - 09:00</option>
|
||||
<option value="09-11">사시 (巳時) 09:00 - 11:00</option>
|
||||
<option value="11-13">오시 (午時) 11:00 - 13:00</option>
|
||||
<option value="13-15">미시 (未時) 13:00 - 15:00</option>
|
||||
<option value="15-17">신시 (申時) 15:00 - 17:00</option>
|
||||
<option value="17-19">유시 (酉時) 17:00 - 19:00</option>
|
||||
<option value="19-21">술시 (戌時) 19:00 - 21:00</option>
|
||||
<option value="21-23">해시 (亥時) 21:00 - 23:00</option>
|
||||
</select>
|
||||
{/* 토정비결 */}
|
||||
<Link href="/tojeong" className="group relative bg-gradient-to-br from-gray-900 to-gray-800 border border-gray-700 rounded-3xl p-8 hover:border-emerald-500/50 transition-all duration-300 hover:shadow-2xl hover:shadow-emerald-500/10 text-left">
|
||||
<div className="text-5xl mb-6">🎋</div>
|
||||
<h3 className="text-2xl font-bold text-white mb-3 group-hover:text-emerald-400 transition">토정비결</h3>
|
||||
<p className="text-gray-400 mb-6 leading-relaxed">
|
||||
올 한 해의 월별 운세와 카테고리별 운세를 미리 확인하고 준비하세요.
|
||||
</p>
|
||||
<div className="flex items-center text-emerald-400 font-semibold text-sm">
|
||||
<span>올해 운세 보기</span>
|
||||
<svg className="w-4 h-4 ml-2 group-hover:translate-x-1 transition" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /></svg>
|
||||
</div>
|
||||
|
||||
{/* 양력/음력 선택 */}
|
||||
<div>
|
||||
<label className="block text-left text-sm font-semibold text-gray-700 mb-2">
|
||||
생일 구분
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
className="px-6 py-3 bg-indigo-600 text-white rounded-xl font-semibold hover:bg-indigo-700 transition"
|
||||
>
|
||||
양력
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="px-6 py-3 bg-white border-2 border-gray-200 text-gray-700 rounded-xl font-semibold hover:border-indigo-500 hover:text-indigo-600 transition"
|
||||
>
|
||||
음력
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 성별 선택 */}
|
||||
<div>
|
||||
<label className="block text-left text-sm font-semibold text-gray-700 mb-2">
|
||||
성별
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
className="px-6 py-3 bg-indigo-600 text-white rounded-xl font-semibold hover:bg-indigo-700 transition"
|
||||
>
|
||||
남성
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="px-6 py-3 bg-white border-2 border-gray-200 text-gray-700 rounded-xl font-semibold hover:border-indigo-500 hover:text-indigo-600 transition"
|
||||
>
|
||||
여성
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 제출 버튼 */}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features Section */}
|
||||
<section className="py-20 px-4 bg-white">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* Why Us Section */}
|
||||
<section className="py-24 px-4 bg-gray-900/50">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="text-4xl font-bold text-gray-900 mb-4">무엇을 확인할 수 있나요?</h2>
|
||||
<p className="text-xl text-gray-600">다양한 사주 정보를 한 번에 확인하세요</p>
|
||||
<h2 className="text-4xl font-bold mb-4">왜 <span className="text-amber-400">사주포춘</span>인가요?</h2>
|
||||
<p className="text-xl text-gray-400">다른 곳에서는 경험할 수 없는 차별화된 사주 서비스</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||
{/* Feature 1 */}
|
||||
<div className="text-center p-6 rounded-2xl hover:bg-indigo-50 transition">
|
||||
<div className="text-5xl mb-4">📜</div>
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-3">사주팔자</h3>
|
||||
<p className="text-gray-600">
|
||||
나의 천간, 지지, 십성, 십이운성을 확인하고 운명의 흐름을 파악하세요.
|
||||
</p>
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 bg-amber-500/10 rounded-2xl flex items-center justify-center mx-auto mb-5">
|
||||
<span className="text-3xl">🧠</span>
|
||||
</div>
|
||||
|
||||
{/* Feature 2 */}
|
||||
<div className="text-center p-6 rounded-2xl hover:bg-purple-50 transition">
|
||||
<div className="text-5xl mb-4">🌟</div>
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-3">오늘의 운세</h3>
|
||||
<p className="text-gray-600">
|
||||
사주를 확인한 후 오늘 하루의 운세를 확인하고 행운의 방향을 찾아보세요.
|
||||
</p>
|
||||
<h3 className="text-lg font-bold mb-2">AI 심층 분석</h3>
|
||||
<p className="text-gray-400 text-sm leading-relaxed">단순 공식이 아닌, AI가 천간·지지·십성의 복합 관계를 해석합니다.</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 bg-purple-500/10 rounded-2xl flex items-center justify-center mx-auto mb-5">
|
||||
<span className="text-3xl">📚</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-bold mb-2">전통 명리학 기반</h3>
|
||||
<p className="text-gray-400 text-sm leading-relaxed">수천 년 역사의 사주명리학 이론을 정확하게 구현했습니다.</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 bg-pink-500/10 rounded-2xl flex items-center justify-center mx-auto mb-5">
|
||||
<span className="text-3xl">🔒</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-bold mb-2">평생 보관</h3>
|
||||
<p className="text-gray-400 text-sm leading-relaxed">한 번 열람한 결과는 평생 다시 볼 수 있어요. 재결제 없이.</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 bg-emerald-500/10 rounded-2xl flex items-center justify-center mx-auto mb-5">
|
||||
<span className="text-3xl">⚡</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-bold mb-2">즉시 결과</h3>
|
||||
<p className="text-gray-400 text-sm leading-relaxed">생년월일 입력 즉시 사주팔자와 대운을 무료로 확인할 수 있습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Feature 3 */}
|
||||
<Link href="/compatibility" className="text-center p-6 rounded-2xl hover:bg-pink-50 transition block">
|
||||
<div className="text-5xl mb-4">💕</div>
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-3">궁합</h3>
|
||||
<p className="text-gray-600">
|
||||
두 사람의 사주를 비교하여 궁합을 확인하세요.
|
||||
{/* Testimonial / Trust Section */}
|
||||
<section className="py-24 px-4">
|
||||
<div className="max-w-4xl mx-auto text-center">
|
||||
<div className="bg-gradient-to-br from-gray-900 to-gray-800 border border-gray-700 rounded-3xl p-12">
|
||||
<div className="text-6xl mb-6">✨</div>
|
||||
<p className="text-2xl md:text-3xl font-bold mb-4 leading-relaxed">
|
||||
“소름 돋을 정도로 정확해요”
|
||||
</p>
|
||||
</Link>
|
||||
|
||||
{/* Feature 4 */}
|
||||
<Link href="/tojeong" className="text-center p-6 rounded-2xl hover:bg-amber-50 transition block">
|
||||
<div className="text-5xl mb-4">🎋</div>
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-3">토정비결</h3>
|
||||
<p className="text-gray-600">
|
||||
한 해의 운세를 미리 확인하고 준비하세요.
|
||||
<p className="text-gray-400 text-lg mb-8">
|
||||
이미 수천 명이 사주포춘으로 자신의 운명을 확인했습니다.
|
||||
</p>
|
||||
</Link>
|
||||
<div className="flex justify-center gap-8 text-center">
|
||||
<div>
|
||||
<div className="text-3xl font-bold text-amber-400">5,000+</div>
|
||||
<div className="text-sm text-gray-500 mt-1">누적 분석</div>
|
||||
</div>
|
||||
<div className="w-px bg-gray-700"></div>
|
||||
<div>
|
||||
<div className="text-3xl font-bold text-amber-400">4.8</div>
|
||||
<div className="text-sm text-gray-500 mt-1">만족도</div>
|
||||
</div>
|
||||
<div className="w-px bg-gray-700"></div>
|
||||
<div>
|
||||
<div className="text-3xl font-bold text-amber-400">97%</div>
|
||||
<div className="text-sm text-gray-500 mt-1">정확도 체감</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className="py-20 px-4 bg-gradient-to-r from-indigo-600 to-purple-600">
|
||||
<div className="max-w-4xl mx-auto text-center text-white">
|
||||
<section className="py-24 px-4">
|
||||
<div className="max-w-4xl mx-auto text-center">
|
||||
<h2 className="text-4xl md:text-5xl font-bold mb-6">
|
||||
지금 바로 내 사주를 확인해보세요
|
||||
지금 바로 운명을 확인하세요
|
||||
</h2>
|
||||
<p className="text-xl mb-8 opacity-90">
|
||||
무료로 제공되는 정확한 사주 정보로 나의 운명을 알아보세요
|
||||
<p className="text-xl text-gray-400 mb-10">
|
||||
생년월일만 입력하면 무료로 사주팔자를 확인할 수 있습니다.
|
||||
</p>
|
||||
<a href="#" className="inline-block bg-white text-indigo-600 px-8 py-4 rounded-xl text-lg font-bold hover:bg-gray-100 transition shadow-lg">
|
||||
무료로 시작하기
|
||||
</a>
|
||||
<div className="flex flex-wrap justify-center gap-4">
|
||||
<Link
|
||||
href="/saju"
|
||||
className="inline-flex items-center gap-2 bg-gradient-to-r from-amber-500 to-orange-500 text-gray-900 px-8 py-4 rounded-2xl text-lg font-bold hover:from-amber-400 hover:to-orange-400 transition shadow-lg shadow-amber-500/25 hover:shadow-xl hover:shadow-amber-500/30"
|
||||
>
|
||||
무료 사주 보기
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" /></svg>
|
||||
</Link>
|
||||
<Link
|
||||
href="/compatibility"
|
||||
className="inline-flex items-center gap-2 bg-gray-800 text-white px-8 py-4 rounded-2xl text-lg font-bold hover:bg-gray-700 transition border border-gray-700"
|
||||
>
|
||||
궁합 확인하기
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="bg-gray-900 text-white py-12 px-4">
|
||||
<div className="max-w-7xl mx-auto text-center">
|
||||
<div className="text-2xl font-bold mb-4 bg-gradient-to-r from-indigo-400 to-purple-400 bg-clip-text text-transparent">
|
||||
🔮 사주보기
|
||||
<footer className="bg-gray-900 border-t border-gray-800 py-12 px-4">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="grid md:grid-cols-3 gap-8 mb-8">
|
||||
<div>
|
||||
<div className="text-2xl font-bold mb-4 bg-gradient-to-r from-amber-400 to-orange-400 bg-clip-text text-transparent">
|
||||
🔮 사주포춘
|
||||
</div>
|
||||
<p className="text-gray-400 mb-6">
|
||||
쟁승메이드가 제공하는 무료 사주 서비스
|
||||
<p className="text-gray-500 text-sm leading-relaxed">
|
||||
전통 명리학과 AI 기술의 결합으로<br />
|
||||
가장 정확한 사주 서비스를 제공합니다.
|
||||
</p>
|
||||
<div className="text-sm text-gray-500">
|
||||
<p>문의: bgg8988@gmail.com | <a href="https://jaengseung-made.com" target="_blank" rel="noopener noreferrer" className="hover:text-indigo-400">쟁승메이드</a></p>
|
||||
<p className="mt-2">© 2025 쟁승메이드. All rights reserved.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-bold text-gray-300 mb-4 tracking-wide">서비스</h4>
|
||||
<div className="space-y-2">
|
||||
<Link href="/saju" className="block text-gray-500 hover:text-amber-400 transition text-sm">사주팔자</Link>
|
||||
<Link href="/compatibility" className="block text-gray-500 hover:text-amber-400 transition text-sm">궁합 분석</Link>
|
||||
<Link href="/tojeong" className="block text-gray-500 hover:text-amber-400 transition text-sm">토정비결</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-bold text-gray-300 mb-4 tracking-wide">고객지원</h4>
|
||||
<div className="space-y-2">
|
||||
<p className="text-gray-500 text-sm">문의: bgg8988@gmail.com</p>
|
||||
<a href="https://jaengseung-made.com" target="_blank" rel="noopener noreferrer" className="block text-gray-500 hover:text-amber-400 transition text-sm">쟁승메이드</a>
|
||||
<Link href="/mypage" className="block text-gray-500 hover:text-amber-400 transition text-sm">마이페이지</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-gray-800 pt-8 text-center text-sm text-gray-600">
|
||||
<p>© 2025 쟁승메이드. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
|
||||
import { calculateSaju } from '@/lib/saju-calculator';
|
||||
import Link from 'next/link';
|
||||
import PDFButton from '../components/PDFButton';
|
||||
import ShareButtons from '../components/ShareButtons';
|
||||
import UserMenu from '@/components/UserMenu';
|
||||
import { calculateDaeun, getCurrentDaeun, getDaeunDescription } from '@/lib/daeun-calculator';
|
||||
import { getCurrentSolarTerm, getSolarTermName, getSolarTermMonthBranch } from '@/lib/solar-terms';
|
||||
import { EARTHLY_BRANCHES_KR } from '@/lib/saju-calculator';
|
||||
import { generateInterpretation, calculateElementScore } from '@/lib/ai-interpretation';
|
||||
import { EARTHLY_BRANCHES_KR, FIVE_ELEMENTS_KR, FIVE_ELEMENTS } from '@/lib/saju-calculator';
|
||||
import { calculateElementScore, performFullAnalysis } from '@/lib/ai-interpretation';
|
||||
import AiInterpretationSection from '@/components/AiInterpretationSection';
|
||||
|
||||
interface PageProps {
|
||||
searchParams: Promise<{
|
||||
@@ -15,18 +18,31 @@ interface PageProps {
|
||||
hour?: string;
|
||||
gender: 'male' | 'female';
|
||||
calendarType: 'solar' | 'lunar';
|
||||
originalYear?: string;
|
||||
originalMonth?: string;
|
||||
originalDay?: string;
|
||||
isLeapMonth?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default async function ResultPage({ searchParams }: PageProps) {
|
||||
const params = await searchParams;
|
||||
const { year, month, day, hour, gender } = params;
|
||||
const {
|
||||
year, month, day, hour, gender, calendarType,
|
||||
originalYear, originalMonth, originalDay, isLeapMonth
|
||||
} = params;
|
||||
|
||||
const yearNum = parseInt(year);
|
||||
const monthNum = parseInt(month);
|
||||
const dayNum = parseInt(day);
|
||||
const hourNum = hour ? parseInt(hour) : null;
|
||||
|
||||
const inputYear = originalYear ? parseInt(originalYear) : yearNum;
|
||||
const inputMonth = originalMonth ? parseInt(originalMonth) : monthNum;
|
||||
const inputDay = originalDay ? parseInt(originalDay) : dayNum;
|
||||
const isLunar = calendarType === 'lunar';
|
||||
const isLeap = isLeapMonth === 'true';
|
||||
|
||||
const sajuData = calculateSaju(yearNum, monthNum, dayNum, hourNum, gender);
|
||||
|
||||
// 절기 정보
|
||||
@@ -35,22 +51,29 @@ export default async function ResultPage({ searchParams }: PageProps) {
|
||||
const monthBranchIndex = getSolarTermMonthBranch(yearNum, monthNum, dayNum);
|
||||
const monthBranchName = EARTHLY_BRANCHES_KR[monthBranchIndex];
|
||||
|
||||
// AI 해석 생성
|
||||
const interpretation = generateInterpretation(sajuData);
|
||||
const elementScores = calculateElementScore(sajuData);
|
||||
// 종합 분석 수행
|
||||
const analysis = performFullAnalysis(sajuData);
|
||||
const elementScores = analysis.elementScores;
|
||||
|
||||
// 대운 계산
|
||||
const daeunList = calculateDaeun(
|
||||
yearNum,
|
||||
monthNum,
|
||||
dayNum,
|
||||
gender,
|
||||
sajuData.month.stem,
|
||||
sajuData.month.branch
|
||||
yearNum, monthNum, dayNum, gender,
|
||||
sajuData.month.stem, sajuData.month.branch
|
||||
);
|
||||
const currentYear = new Date().getFullYear();
|
||||
const currentDaeun = getCurrentDaeun(daeunList, currentYear);
|
||||
|
||||
// 오행 색상 매핑
|
||||
const elementColors: { [key: string]: string } = {
|
||||
'木': 'text-green-600', '火': 'text-red-500', '土': 'text-yellow-600',
|
||||
'金': 'text-gray-500', '水': 'text-blue-600',
|
||||
};
|
||||
const elementBgColors: { [key: string]: string } = {
|
||||
'木': 'bg-green-100 border-green-300', '火': 'bg-red-100 border-red-300',
|
||||
'土': 'bg-yellow-100 border-yellow-300', '金': 'bg-gray-100 border-gray-300',
|
||||
'水': 'bg-blue-100 border-blue-300',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-indigo-50 via-purple-50 to-pink-50">
|
||||
{/* Navigation */}
|
||||
@@ -58,14 +81,14 @@ export default async function ResultPage({ searchParams }: PageProps) {
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center h-16">
|
||||
<Link href="/" className="text-2xl font-bold bg-gradient-to-r from-indigo-600 to-purple-600 bg-clip-text text-transparent">
|
||||
🔮 사주보기
|
||||
사주보기
|
||||
</Link>
|
||||
<Link
|
||||
href="/"
|
||||
className="text-gray-700 hover:text-indigo-600 transition font-medium"
|
||||
>
|
||||
<div className="flex items-center space-x-4">
|
||||
<Link href="/" className="text-gray-700 hover:text-indigo-600 transition font-medium">
|
||||
다시 보기
|
||||
</Link>
|
||||
<UserMenu />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -78,7 +101,19 @@ export default async function ResultPage({ searchParams }: PageProps) {
|
||||
내 사주팔자
|
||||
</h1>
|
||||
<p className="text-xl text-gray-600">
|
||||
{isLunar ? (
|
||||
<>
|
||||
음력 {inputYear}년 {inputMonth}월 {inputDay}일{isLeap && ' (윤달)'} {hourNum !== null && `${hourNum}시`}
|
||||
<br />
|
||||
<span className="text-base text-gray-500">
|
||||
(양력 {yearNum}년 {monthNum}월 {dayNum}일)
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{yearNum}년 {monthNum}월 {dayNum}일 {hourNum !== null && `${hourNum}시`}
|
||||
</>
|
||||
)}
|
||||
{gender === 'male' ? ' 남성' : ' 여성'}
|
||||
</p>
|
||||
</div>
|
||||
@@ -146,6 +181,36 @@ export default async function ResultPage({ searchParams }: PageProps) {
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* 지장간 (NEW) */}
|
||||
<tr className="border-b border-gray-200 hover:bg-amber-50 transition">
|
||||
<td className="py-4 px-6 text-center font-semibold text-gray-700">
|
||||
지장간 (藏干)
|
||||
<div className="text-xs text-gray-400 mt-1">숨은 천간</div>
|
||||
</td>
|
||||
{(() => {
|
||||
const pillars = sajuData.hour
|
||||
? [analysis.hiddenStems.find(h => h.pillar === '시주'), analysis.hiddenStems.find(h => h.pillar === '일주'), analysis.hiddenStems.find(h => h.pillar === '월주'), analysis.hiddenStems.find(h => h.pillar === '년주')]
|
||||
: [analysis.hiddenStems.find(h => h.pillar === '일주'), analysis.hiddenStems.find(h => h.pillar === '월주'), analysis.hiddenStems.find(h => h.pillar === '년주')];
|
||||
return pillars.map((h, idx) => (
|
||||
<td key={idx} className={`py-3 px-4 text-center ${h?.pillar === '일주' ? 'bg-blue-50' : ''}`}>
|
||||
{h && (
|
||||
<div className="flex flex-wrap justify-center gap-1">
|
||||
{h.stems.map((s, si) => (
|
||||
<span
|
||||
key={si}
|
||||
className={`inline-block px-2 py-1 rounded text-xs font-semibold border ${elementBgColors[s.element] || 'bg-gray-100'}`}
|
||||
title={s.role}
|
||||
>
|
||||
{s.stemKr}({FIVE_ELEMENTS_KR[s.element as keyof typeof FIVE_ELEMENTS_KR]})
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
));
|
||||
})()}
|
||||
</tr>
|
||||
|
||||
{/* 십성 */}
|
||||
<tr className="border-b border-gray-200 hover:bg-emerald-50 transition">
|
||||
<td className="py-4 px-6 text-center font-semibold text-gray-700">십성 (十星)</td>
|
||||
@@ -187,103 +252,56 @@ export default async function ResultPage({ searchParams }: PageProps) {
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 space-y-3">
|
||||
<div className="p-4 bg-blue-50 rounded-xl">
|
||||
<p className="text-sm text-gray-700">
|
||||
<strong className="text-blue-600">일간 (日干):</strong> {sajuData.day.stem}({sajuData.day.stemKr}) - 나 자신을 나타내는 중심 기둥입니다.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 bg-green-50 rounded-xl">
|
||||
<p className="text-sm text-gray-700">
|
||||
<strong className="text-green-600">절기 (節氣):</strong> {solarTermName} 이후 -
|
||||
월주는 절기를 기준으로 {monthBranchName}월입니다.
|
||||
</p>
|
||||
<p className="text-xs text-gray-600 mt-1">
|
||||
* 사주 월주는 양력 월이 아닌 24절기를 기준으로 계산됩니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 사주 해석 */}
|
||||
<div className="grid md:grid-cols-2 gap-8 mb-8">
|
||||
{/* 성격 */}
|
||||
<div className="bg-white rounded-2xl shadow-lg p-8">
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-4 flex items-center">
|
||||
<span className="text-3xl mr-3">👤</span>
|
||||
성격 특징
|
||||
{/* 지지 상호작용 (합/충/형/파/해) */}
|
||||
{analysis.branchInteractions.length > 0 && (
|
||||
<div className="mt-6 pt-6 border-t border-gray-100">
|
||||
<h3 className="text-lg font-bold text-gray-900 mb-3 flex items-center justify-center">
|
||||
<span className="mr-2">🔗</span> 지지 상호작용
|
||||
</h3>
|
||||
<div className="space-y-3 text-gray-700">
|
||||
<p className="leading-relaxed">
|
||||
일간이 <strong className="text-indigo-600">{sajuData.day.stem}({sajuData.day.stemKr})</strong>인 사람은
|
||||
{sajuData.day.element === '木' && ' 나무처럼 성장하고 발전하려는 의지가 강합니다. 창의적이고 진취적인 성향을 가지고 있습니다.'}
|
||||
{sajuData.day.element === '火' && ' 불처럼 열정적이고 활동적입니다. 리더십이 있고 사교성이 뛰어납니다.'}
|
||||
{sajuData.day.element === '土' && ' 흙처럼 안정적이고 신뢰감 있습니다. 포용력이 있고 책임감이 강합니다.'}
|
||||
{sajuData.day.element === '金' && ' 금속처럼 강인하고 원칙적입니다. 결단력 있고 의리를 중시합니다.'}
|
||||
{sajuData.day.element === '水' && ' 물처럼 유연하고 지혜롭습니다. 적응력이 뛰어나고 사려 깊습니다.'}
|
||||
</p>
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
{analysis.branchInteractions.map((inter, idx) => {
|
||||
const isPositive = inter.type.includes('합');
|
||||
const isNegative = inter.type.includes('충') || inter.type.includes('형');
|
||||
const colorClass = isPositive
|
||||
? 'bg-emerald-50 border-emerald-300 text-emerald-800'
|
||||
: isNegative
|
||||
? 'bg-red-50 border-red-300 text-red-800'
|
||||
: 'bg-amber-50 border-amber-300 text-amber-800';
|
||||
return (
|
||||
<span key={idx} className={`inline-flex items-center px-3 py-1.5 rounded-full text-sm font-semibold border ${colorClass}`}>
|
||||
{inter.type} {inter.branchesKr.join('')}
|
||||
{inter.resultElement && ` → ${FIVE_ELEMENTS_KR[inter.resultElement as keyof typeof FIVE_ELEMENTS_KR]}`}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 운세 */}
|
||||
<div className="bg-white rounded-2xl shadow-lg p-8">
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-4 flex items-center">
|
||||
<span className="text-3xl mr-3">🌟</span>
|
||||
운세 흐름
|
||||
</h3>
|
||||
<div className="space-y-3 text-gray-700">
|
||||
<p className="leading-relaxed">
|
||||
현재 십이운성이 <strong className="text-purple-600">{sajuData.day.fortune}</strong>으로,
|
||||
{sajuData.day.fortune === '장생' && ' 새로운 시작과 성장의 시기입니다.'}
|
||||
{sajuData.day.fortune === '목욕' && ' 정화와 준비의 시기입니다.'}
|
||||
{sajuData.day.fortune === '관대' && ' 사회적으로 인정받는 시기입니다.'}
|
||||
{sajuData.day.fortune === '건록' && ' 안정되고 왕성한 활동의 시기입니다.'}
|
||||
{sajuData.day.fortune === '제왕' && ' 최고의 전성기를 맞이하는 시기입니다.'}
|
||||
{sajuData.day.fortune === '쇠' && ' 조금씩 힘이 약해지는 시기입니다.'}
|
||||
{sajuData.day.fortune === '병' && ' 어려움이 있을 수 있는 시기입니다.'}
|
||||
{sajuData.day.fortune === '사' && ' 끝과 새 시작을 준비하는 시기입니다.'}
|
||||
{sajuData.day.fortune === '묘' && ' 잠시 휴식이 필요한 시기입니다.'}
|
||||
{sajuData.day.fortune === '절' && ' 극복과 인내가 필요한 시기입니다.'}
|
||||
{sajuData.day.fortune === '태' && ' 새로운 기운이 싹트는 시기입니다.'}
|
||||
{sajuData.day.fortune === '양' && ' 성장을 준비하는 시기입니다.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI 상세 해석 */}
|
||||
<div className="bg-gradient-to-br from-purple-50 to-indigo-50 rounded-3xl shadow-2xl p-8 md:p-12 mb-8">
|
||||
<h2 className="text-3xl font-bold text-gray-900 mb-2 text-center flex items-center justify-center">
|
||||
<span className="text-4xl mr-3">🤖</span>
|
||||
AI 상세 해석
|
||||
</h2>
|
||||
<p className="text-center text-gray-600 mb-8">사주 데이터 분석 기반 맞춤 해석</p>
|
||||
|
||||
{/* 오행 균형 */}
|
||||
<div className="bg-white rounded-2xl p-6 mb-6">
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-4 flex items-center">
|
||||
{/* 오행 균형 시각화 */}
|
||||
<div className="mt-8 pt-8 border-t border-gray-100">
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-4 flex items-center justify-center">
|
||||
<span className="text-2xl mr-2">⚖️</span>
|
||||
오행 균형
|
||||
내 사주의 오행 균형
|
||||
<span className="text-sm font-normal text-gray-500 ml-2">(지장간 가중치 적용)</span>
|
||||
</h3>
|
||||
<div className="grid grid-cols-5 gap-3">
|
||||
<div className="grid grid-cols-5 gap-3 max-w-2xl mx-auto">
|
||||
{Object.entries(elementScores).map(([element, score]) => (
|
||||
<div key={element} className="text-center">
|
||||
<div className="text-2xl font-bold mb-1">{element}</div>
|
||||
<div className={`text-2xl font-bold mb-1 ${elementColors[element] || ''}`}>{element}</div>
|
||||
<div className="text-sm text-gray-600 mb-2">
|
||||
{element === '木' && '목'}
|
||||
{element === '火' && '화'}
|
||||
{element === '土' && '토'}
|
||||
{element === '金' && '금'}
|
||||
{element === '水' && '수'}
|
||||
{FIVE_ELEMENTS_KR[element as keyof typeof FIVE_ELEMENTS_KR]}
|
||||
<span className="text-xs text-gray-400 ml-1">
|
||||
({analysis.elementBalance[element as keyof typeof analysis.elementBalance]}점)
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2 mb-1">
|
||||
<div className="w-full bg-gray-200 rounded-full h-3 mb-1">
|
||||
<div
|
||||
className={`h-2 rounded-full ${
|
||||
element === sajuData.day.element
|
||||
className={`h-3 rounded-full transition-all ${element === sajuData.day.element
|
||||
? 'bg-gradient-to-r from-indigo-500 to-purple-500'
|
||||
: 'bg-gray-400'
|
||||
}`}
|
||||
style={{ width: `${score}%` }}
|
||||
style={{ width: `${Math.max(score, 5)}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<div className="text-xs font-semibold text-gray-700">{score}%</div>
|
||||
@@ -291,138 +309,127 @@ export default async function ResultPage({ searchParams }: PageProps) {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 장단점 */}
|
||||
<div className="grid md:grid-cols-2 gap-6 mb-6">
|
||||
<div className="bg-white rounded-2xl p-6">
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-4 flex items-center">
|
||||
<span className="text-2xl mr-2">💪</span>
|
||||
장점
|
||||
{/* 신강/신약 + 용신 + 신살 카드 */}
|
||||
<div className="grid md:grid-cols-2 gap-6 mb-8">
|
||||
{/* 신강/신약 + 용신 카드 */}
|
||||
<div className="bg-white rounded-3xl shadow-xl p-8 border border-gray-100">
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-4 flex items-center">
|
||||
<span className="mr-2">⚡</span> 일간 세력 분석
|
||||
</h3>
|
||||
<ul className="space-y-2">
|
||||
{interpretation.strengths.map((strength, i) => (
|
||||
<li key={i} className="flex items-start text-gray-700">
|
||||
<span className="text-green-600 mr-2">✓</span>
|
||||
<span>{strength}</span>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<span className={`inline-block px-4 py-2 rounded-xl text-lg font-bold ${
|
||||
analysis.dayMasterStrength.result === '신강'
|
||||
? 'bg-red-100 text-red-700'
|
||||
: analysis.dayMasterStrength.result === '신약'
|
||||
? 'bg-blue-100 text-blue-700'
|
||||
: 'bg-green-100 text-green-700'
|
||||
}`}>
|
||||
{analysis.dayMasterStrength.result}
|
||||
</span>
|
||||
<span className="text-gray-500 text-sm">점수: {analysis.dayMasterStrength.score}</span>
|
||||
</div>
|
||||
<ul className="space-y-1.5 text-sm text-gray-600 mb-6">
|
||||
{analysis.dayMasterStrength.reasons.map((r, i) => (
|
||||
<li key={i} className="flex items-start">
|
||||
<span className="text-indigo-400 mr-2 mt-0.5">-</span>
|
||||
<span>{r}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl p-6">
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-4 flex items-center">
|
||||
<span className="text-2xl mr-2">⚠️</span>
|
||||
주의할 점
|
||||
</h3>
|
||||
<ul className="space-y-2">
|
||||
{interpretation.weaknesses.map((weakness, i) => (
|
||||
<li key={i} className="flex items-start text-gray-700">
|
||||
<span className="text-orange-600 mr-2">!</span>
|
||||
<span>{weakness}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="border-t border-gray-100 pt-4">
|
||||
<h4 className="font-bold text-gray-800 mb-3">용신 / 희신 / 기신</h4>
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
<span className={`px-3 py-1.5 rounded-lg text-sm font-bold border ${elementBgColors[analysis.yongShin.yongShin] || 'bg-gray-100'}`}>
|
||||
용신: {analysis.yongShin.yongShinKr}({analysis.yongShin.yongShin})
|
||||
</span>
|
||||
<span className={`px-3 py-1.5 rounded-lg text-sm font-bold border ${elementBgColors[analysis.yongShin.heeShin] || 'bg-gray-100'}`}>
|
||||
희신: {analysis.yongShin.heeShinKr}({analysis.yongShin.heeShin})
|
||||
</span>
|
||||
<span className="px-3 py-1.5 rounded-lg text-sm font-bold bg-gray-200 border border-gray-400 text-gray-700">
|
||||
기신: {analysis.yongShin.giShinKr}({analysis.yongShin.giShin})
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 leading-relaxed">{analysis.yongShin.explanation}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 직업, 대인관계, 재물, 건강 */}
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
{/* 직업 */}
|
||||
<div className="bg-white rounded-2xl p-6">
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-4 flex items-center">
|
||||
<span className="text-2xl mr-2">💼</span>
|
||||
직업 운세
|
||||
{/* 신살 + 공망 카드 */}
|
||||
<div className="bg-white rounded-3xl shadow-xl p-8 border border-gray-100">
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-4 flex items-center">
|
||||
<span className="mr-2">🌟</span> 신살 (神煞)
|
||||
</h3>
|
||||
<ul className="space-y-2">
|
||||
{interpretation.career.map((item, i) => (
|
||||
<li key={i} className="flex items-start text-gray-700 text-sm">
|
||||
<span className="text-blue-600 mr-2">•</span>
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{analysis.shinsal.length > 0 ? (
|
||||
<div className="space-y-3 mb-6">
|
||||
{analysis.shinsal.map((s, i) => (
|
||||
<div key={i} className="flex items-start gap-3 p-3 rounded-xl bg-gray-50">
|
||||
<span className="inline-block px-2 py-1 bg-indigo-100 text-indigo-700 rounded-lg text-xs font-bold whitespace-nowrap">
|
||||
{s.name}
|
||||
</span>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-gray-800">
|
||||
{s.pillar} {s.branchKr}({s.branch})
|
||||
</div>
|
||||
|
||||
{/* 대인관계 */}
|
||||
<div className="bg-white rounded-2xl p-6">
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-4 flex items-center">
|
||||
<span className="text-2xl mr-2">👥</span>
|
||||
대인 관계
|
||||
</h3>
|
||||
<ul className="space-y-2">
|
||||
{interpretation.relationships.map((item, i) => (
|
||||
<li key={i} className="flex items-start text-gray-700 text-sm">
|
||||
<span className="text-pink-600 mr-2">•</span>
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="text-xs text-gray-500 mt-0.5">{s.description}</div>
|
||||
</div>
|
||||
|
||||
{/* 재물 */}
|
||||
<div className="bg-white rounded-2xl p-6">
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-4 flex items-center">
|
||||
<span className="text-2xl mr-2">💰</span>
|
||||
재물 운세
|
||||
</h3>
|
||||
<ul className="space-y-2">
|
||||
{interpretation.wealth.map((item, i) => (
|
||||
<li key={i} className="flex items-start text-gray-700 text-sm">
|
||||
<span className="text-yellow-600 mr-2">•</span>
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* 건강 */}
|
||||
<div className="bg-white rounded-2xl p-6">
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-4 flex items-center">
|
||||
<span className="text-2xl mr-2">🏥</span>
|
||||
건강 운세
|
||||
</h3>
|
||||
<ul className="space-y-2">
|
||||
{interpretation.health.map((item, i) => (
|
||||
<li key={i} className="flex items-start text-gray-700 text-sm">
|
||||
<span className="text-red-600 mr-2">•</span>
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 조언 */}
|
||||
<div className="bg-white rounded-2xl p-6 mt-6">
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-4 flex items-center">
|
||||
<span className="text-2xl mr-2">💡</span>
|
||||
AI의 조언
|
||||
</h3>
|
||||
<div className="grid md:grid-cols-2 gap-3">
|
||||
{interpretation.advice.map((item, i) => (
|
||||
<div key={i} className="flex items-start text-gray-700 text-sm bg-indigo-50 p-3 rounded-lg">
|
||||
<span className="text-indigo-600 mr-2">→</span>
|
||||
<span>{item}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 text-sm mb-6">특별한 신살이 발견되지 않았습니다.</p>
|
||||
)}
|
||||
|
||||
<div className="border-t border-gray-100 pt-4">
|
||||
<h4 className="font-bold text-gray-800 mb-2 flex items-center">
|
||||
<span className="mr-2">🕳️</span> 공망 (空亡)
|
||||
</h4>
|
||||
<div className="flex gap-2 mb-2">
|
||||
{analysis.gongmang.branchesKr.map((bk, i) => (
|
||||
<span key={i} className="px-3 py-1.5 bg-gray-800 text-white rounded-lg text-sm font-bold">
|
||||
{bk}({analysis.gongmang.branches[i]})
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 leading-relaxed">{analysis.gongmang.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 p-4 bg-purple-100 rounded-xl">
|
||||
<p className="text-xs text-gray-700 text-center">
|
||||
💡 AI 해석은 전통 사주 이론을 기반으로 생성되었습니다. 참고용으로 활용하시고,
|
||||
중요한 결정은 전문가와 상담하시기 바랍니다.
|
||||
</p>
|
||||
{/* 세운 정보 */}
|
||||
<div className="border-t border-gray-100 pt-4 mt-4">
|
||||
<h4 className="font-bold text-gray-800 mb-2 flex items-center">
|
||||
<span className="mr-2">📅</span> {analysis.seun.year}년 세운
|
||||
</h4>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className={`px-3 py-1.5 rounded-lg text-sm font-bold border ${elementBgColors[analysis.seun.element] || 'bg-gray-100'}`}>
|
||||
{analysis.seun.stemKr}{analysis.seun.branchKr} ({analysis.seun.stem}{analysis.seun.branch})
|
||||
</span>
|
||||
<span className="text-sm text-gray-500">{analysis.seun.elementKr}({analysis.seun.element}) 기운</span>
|
||||
</div>
|
||||
{analysis.seun.interactions.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||
{analysis.seun.interactions.map((si, i) => (
|
||||
<span key={i} className={`text-xs px-2 py-1 rounded-full font-semibold ${
|
||||
si.type.includes('합') ? 'bg-emerald-50 text-emerald-700' : 'bg-red-50 text-red-700'
|
||||
}`}>
|
||||
{si.type} {si.branchesKr.join('')}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI 상세 해석 */}
|
||||
<AiInterpretationSection sajuData={sajuData} currentDaeun={currentDaeun} daeunList={daeunList} />
|
||||
|
||||
{/* 대운 (大運) */}
|
||||
<div className="bg-white rounded-3xl shadow-2xl p-8 md:p-12 mb-8">
|
||||
<h2 className="text-3xl font-bold text-gray-900 mb-8 text-center">
|
||||
🔄 대운 (大運) - 10년 주기 운세
|
||||
대운 (大運) - 10년 주기 운세
|
||||
</h2>
|
||||
|
||||
{/* 현재 대운 */}
|
||||
{currentDaeun && (
|
||||
<div className="bg-gradient-to-r from-indigo-500 to-purple-500 rounded-2xl p-6 mb-8 text-white">
|
||||
<h3 className="text-2xl font-bold mb-4 text-center">현재 대운</h3>
|
||||
@@ -443,7 +450,6 @@ export default async function ResultPage({ searchParams }: PageProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 전체 대운 목록 */}
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{daeunList.map((daeun, index) => {
|
||||
const isCurrent = currentDaeun &&
|
||||
@@ -453,8 +459,7 @@ export default async function ResultPage({ searchParams }: PageProps) {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`rounded-xl p-4 border-2 transition ${
|
||||
isCurrent
|
||||
className={`rounded-xl p-4 border-2 transition ${isCurrent
|
||||
? 'bg-indigo-50 border-indigo-400'
|
||||
: 'bg-gray-50 border-gray-200 hover:border-indigo-300'
|
||||
}`}
|
||||
@@ -484,18 +489,6 @@ export default async function ResultPage({ searchParams }: PageProps) {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 p-4 bg-indigo-50 rounded-xl">
|
||||
<p className="text-sm text-gray-700 mb-2">
|
||||
<strong className="text-indigo-600">대운(大運):</strong> 10년 단위로 변화하는 큰 운의 흐름입니다.
|
||||
각 대운마다 삶의 방향과 환경이 달라질 수 있으므로, 현재 대운의 특성을 이해하고 활용하는 것이 중요합니다.
|
||||
</p>
|
||||
{daeunList.length > 0 && (
|
||||
<p className="text-xs text-gray-600">
|
||||
* 대운은 생일부터 다음 절기까지의 일수를 기준으로 {daeunList[0].age}세부터 시작됩니다. (3일 = 1세)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 추가 기능 버튼 */}
|
||||
@@ -535,7 +528,7 @@ export default async function ResultPage({ searchParams }: PageProps) {
|
||||
<footer className="bg-gray-900 text-white py-12 px-4 mt-20">
|
||||
<div className="max-w-7xl mx-auto text-center">
|
||||
<div className="text-2xl font-bold mb-4 bg-gradient-to-r from-indigo-400 to-purple-400 bg-clip-text text-transparent">
|
||||
🔮 사주보기
|
||||
사주보기
|
||||
</div>
|
||||
<p className="text-gray-400 mb-6">
|
||||
쟁승메이드가 제공하는 무료 사주 서비스
|
||||
|
||||
33
app/result/saved/[id]/SavedInterpretation.tsx
Normal file
33
app/result/saved/[id]/SavedInterpretation.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
'use client';
|
||||
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { AccordionItem, parseSections, SECTION_ICONS } from '@/components/AccordionItem';
|
||||
|
||||
export default function SavedInterpretation({ interpretation }: { interpretation: string }) {
|
||||
const sections = parseSections(interpretation);
|
||||
|
||||
if (sections.length > 0) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{sections.map((section, idx) => (
|
||||
<AccordionItem
|
||||
key={idx}
|
||||
title={section.title}
|
||||
content={section.content}
|
||||
icon={SECTION_ICONS[idx] || '📌'}
|
||||
defaultOpen={idx === 0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="prose prose-lg max-w-none prose-indigo">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{interpretation}
|
||||
</ReactMarkdown>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
78
app/result/saved/[id]/page.tsx
Normal file
78
app/result/saved/[id]/page.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import SavedInterpretation from './SavedInterpretation';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function SavedResultPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
const supabase = await createClient();
|
||||
|
||||
// 인증된 사용자의 쿠키를 사용하여 RLS 통과
|
||||
const { data: record, error } = await supabase
|
||||
.from('saju_records')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.single();
|
||||
|
||||
if (error || !record) {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center p-4">
|
||||
<h1 className="text-2xl font-bold text-red-600 mb-4">기록을 찾을 수 없습니다.</h1>
|
||||
<p className="text-gray-500 mb-4">로그인이 필요하거나 삭제된 기록일 수 있습니다.</p>
|
||||
<div className="flex gap-4">
|
||||
<Link href="/login" className="text-indigo-600 hover:underline">로그인</Link>
|
||||
<Link href="/mypage" className="text-indigo-600 hover:underline">마이페이지</Link>
|
||||
<Link href="/" className="text-indigo-600 hover:underline">홈으로</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { saju_data, interpretation } = record;
|
||||
const date = new Date(record.created_at).toLocaleDateString();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="text-center mb-12">
|
||||
<span className="inline-block px-3 py-1 bg-indigo-100 text-indigo-800 rounded-full text-sm font-semibold mb-4">
|
||||
{date} 저장됨
|
||||
</span>
|
||||
<h1 className="text-3xl font-bold text-gray-900">저장된 사주 결과</h1>
|
||||
<p className="mt-2 text-gray-600">
|
||||
{saju_data.birthDate.year}년 {saju_data.birthDate.month}월 {saju_data.birthDate.day}일생
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-3xl shadow-xl p-6 md:p-10 mb-8">
|
||||
<SavedInterpretation interpretation={interpretation} />
|
||||
</div>
|
||||
|
||||
<div className="text-center flex flex-wrap justify-center gap-4">
|
||||
<Link
|
||||
href="/"
|
||||
className="px-6 py-3 bg-gray-200 text-gray-800 rounded-lg hover:bg-gray-300 transition font-medium"
|
||||
>
|
||||
홈으로
|
||||
</Link>
|
||||
<Link
|
||||
href="/mypage"
|
||||
className="px-6 py-3 bg-gray-200 text-gray-800 rounded-lg hover:bg-gray-300 transition font-medium"
|
||||
>
|
||||
목록으로
|
||||
</Link>
|
||||
<Link
|
||||
href="/saju"
|
||||
className="px-6 py-3 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition font-medium"
|
||||
>
|
||||
새로운 사주 보기
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
71
app/saju/page.tsx
Normal file
71
app/saju/page.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
import SajuForm from '../components/SajuForm';
|
||||
import UserMenu from '@/components/UserMenu';
|
||||
|
||||
export default function SajuPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-indigo-50 via-purple-50 to-pink-50">
|
||||
{/* Navigation */}
|
||||
<nav className="bg-white/80 backdrop-blur-md border-b border-gray-200 sticky top-0 z-50">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center h-16">
|
||||
<Link href="/" className="text-2xl font-bold bg-gradient-to-r from-indigo-600 to-purple-600 bg-clip-text text-transparent">
|
||||
🔮 사주포춘
|
||||
</Link>
|
||||
<div className="flex items-center space-x-6">
|
||||
<div className="hidden md:flex space-x-6 mr-4">
|
||||
<Link href="/saju" className="text-indigo-600 font-bold">사주팔자</Link>
|
||||
<Link href="/compatibility" className="text-gray-700 hover:text-indigo-600 transition font-medium">궁합</Link>
|
||||
<Link href="/tojeong" className="text-gray-700 hover:text-indigo-600 transition font-medium">토정비결</Link>
|
||||
</div>
|
||||
<UserMenu />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="pt-20 pb-32 px-4">
|
||||
<div className="max-w-4xl mx-auto text-center">
|
||||
<div className="inline-block mb-6 px-6 py-2 bg-white/50 backdrop-blur-sm rounded-full text-indigo-700 font-semibold border border-indigo-200">
|
||||
무료로 내 사주를 확인해보세요
|
||||
</div>
|
||||
|
||||
<h1 className="text-5xl md:text-7xl font-bold text-gray-900 mb-6 leading-tight">
|
||||
나의 <span className="bg-gradient-to-r from-indigo-600 to-purple-600 bg-clip-text text-transparent">사주팔자</span>를<br />
|
||||
확인하세요
|
||||
</h1>
|
||||
|
||||
<p className="text-xl text-gray-600 mb-12 max-w-2xl mx-auto">
|
||||
생년월일시를 입력하면 무료로 사주팔자, 운세, 궁합을 확인할 수 있습니다.
|
||||
쉽고 빠르게 나의 운명을 알아보세요.
|
||||
</p>
|
||||
|
||||
{/* Main Input Card */}
|
||||
<div className="bg-white rounded-3xl shadow-2xl p-8 md:p-12 max-w-2xl mx-auto">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-8">생년월일시 입력</h2>
|
||||
|
||||
<SajuForm />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="bg-gray-900 text-white py-12 px-4">
|
||||
<div className="max-w-7xl mx-auto text-center">
|
||||
<div className="text-2xl font-bold mb-4 bg-gradient-to-r from-indigo-400 to-purple-400 bg-clip-text text-transparent">
|
||||
🔮 사주포춘
|
||||
</div>
|
||||
<p className="text-gray-400 mb-6">
|
||||
쟁승메이드가 제공하는 사주 서비스
|
||||
</p>
|
||||
<div className="text-sm text-gray-500">
|
||||
<p>문의: bgg8988@gmail.com | <a href="https://jaengseung-made.com" target="_blank" rel="noopener noreferrer" className="hover:text-indigo-400">쟁승메이드</a></p>
|
||||
<p className="mt-2">© 2025 쟁승메이드. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
134
app/tojeong/result/TojeongDetailUnlock.tsx
Normal file
134
app/tojeong/result/TojeongDetailUnlock.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
'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 {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function TojeongDetailUnlock({ children }: Props) {
|
||||
const [isUnlocked, setIsUnlocked] = useState(false);
|
||||
const [user, setUser] = useState<any>(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 <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative mb-8">
|
||||
{/* 블러 처리된 미리보기 */}
|
||||
<div className="filter blur-sm select-none pointer-events-none opacity-60">
|
||||
<div className="bg-white rounded-2xl shadow-lg p-8">
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-6 flex items-center">
|
||||
<span className="text-3xl mr-3">💡</span>
|
||||
한 해를 위한 조언
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="h-4 bg-gray-200 rounded w-full"></div>
|
||||
<div className="h-4 bg-gray-200 rounded w-5/6"></div>
|
||||
<div className="h-4 bg-gray-200 rounded w-4/6"></div>
|
||||
<div className="h-4 bg-gray-200 rounded w-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 잠금 오버레이 */}
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white/40 backdrop-blur-[2px] rounded-2xl">
|
||||
<div className="text-center p-8 bg-white/95 backdrop-blur-md rounded-2xl shadow-xl border border-amber-100 max-w-sm mx-auto">
|
||||
<div className="text-4xl mb-4">🔐</div>
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-2">상세 조언 잠금해제</h3>
|
||||
<p className="text-gray-600 mb-2">
|
||||
토정비결의 상세한 조언과 해석을 확인하세요.
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
토큰 1개 사용 | 보유: <span className="font-bold text-amber-600">{credits}개</span>
|
||||
</p>
|
||||
<button
|
||||
onClick={handleUnlock}
|
||||
disabled={loading}
|
||||
className="w-full bg-gradient-to-r from-amber-600 to-orange-600 text-white font-bold py-3 px-6 rounded-xl hover:shadow-lg hover:scale-105 transition transform disabled:opacity-50"
|
||||
>
|
||||
{loading ? '처리 중...' : credits >= 1 ? '토큰 1개로 잠금해제' : '토큰 충전하기'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TokenPurchaseModal
|
||||
isOpen={showModal}
|
||||
onClose={() => setShowModal(false)}
|
||||
onPurchaseComplete={handlePurchaseComplete}
|
||||
user={user}
|
||||
supabase={supabase}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import Link from 'next/link';
|
||||
import { calculateSaju } from '@/lib/saju-calculator';
|
||||
import PDFButton from '../../components/PDFButton';
|
||||
import ShareButtons from '../../components/ShareButtons';
|
||||
import TojeongDetailUnlock from './TojeongDetailUnlock';
|
||||
|
||||
interface PageProps {
|
||||
searchParams: Promise<{
|
||||
@@ -258,7 +259,8 @@ export default async function TojeongResultPage({ searchParams }: PageProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 조언 */}
|
||||
{/* 조언 - 토큰 잠금 영역 */}
|
||||
<TojeongDetailUnlock>
|
||||
<div className="bg-white rounded-2xl shadow-lg p-8 mb-8">
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-6 flex items-center">
|
||||
<span className="text-3xl mr-3">💡</span>
|
||||
@@ -283,6 +285,7 @@ export default async function TojeongResultPage({ searchParams }: PageProps) {
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</TojeongDetailUnlock>
|
||||
|
||||
{/* 다른 메뉴 */}
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
|
||||
47
build_error.log
Normal file
47
build_error.log
Normal file
@@ -0,0 +1,47 @@
|
||||
|
||||
> saju-web@0.1.0 build
|
||||
> next build
|
||||
|
||||
⚠ Warning: Next.js inferred your workspace root, but it may not be correct.
|
||||
We detected multiple lockfiles and selected the directory of C:\Users\박재오\Desktop\workspace\package-lock.json as the root directory.
|
||||
To silence this warning, set `turbopack.root` in your Next.js config, or consider removing one of the lockfiles if it's not needed.
|
||||
See https://nextjs.org/docs/app/api-reference/config/next-config-js/turbopack#root-directory for more information.
|
||||
Detected additional lockfiles:
|
||||
* C:\Users\박재오\Desktop\workspace\saju-web\package-lock.json
|
||||
|
||||
▲ Next.js 16.1.6 (Turbopack)
|
||||
- Environments: .env.local
|
||||
|
||||
⚠ The "middleware" file convention is deprecated. Please use "proxy" instead. Learn more: https://nextjs.org/docs/messages/middleware-to-proxy
|
||||
Creating an optimized production build ...
|
||||
✓ Compiled successfully in 2.1s
|
||||
Running TypeScript ...
|
||||
Collecting page data using 7 workers ...
|
||||
Generating static pages using 7 workers (0/14) ...
|
||||
Generating static pages using 7 workers (3/14)
|
||||
Generating static pages using 7 workers (6/14)
|
||||
Generating static pages using 7 workers (10/14)
|
||||
✓ Generating static pages using 7 workers (14/14) in 188.0ms
|
||||
Finalizing page optimization ...
|
||||
|
||||
Route (app)
|
||||
┌ ○ /
|
||||
├ ○ /_not-found
|
||||
├ ƒ /api/analyze
|
||||
├ ƒ /auth/callback
|
||||
├ ○ /compatibility
|
||||
├ ƒ /compatibility/result
|
||||
├ ƒ /fortune
|
||||
├ ○ /login
|
||||
├ ○ /mypage
|
||||
├ ƒ /result
|
||||
├ ƒ /result/saved/[id]
|
||||
├ ○ /tojeong
|
||||
└ ƒ /tojeong/result
|
||||
|
||||
|
||||
ƒ Proxy (Middleware)
|
||||
|
||||
○ (Static) prerendered as static content
|
||||
ƒ (Dynamic) server-rendered on demand
|
||||
|
||||
71
components/AccordionItem.tsx
Normal file
71
components/AccordionItem.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
export function AccordionItem({ title, content, icon, defaultOpen = false }: {
|
||||
title: string;
|
||||
content: string;
|
||||
icon: string;
|
||||
defaultOpen?: boolean;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(defaultOpen);
|
||||
|
||||
return (
|
||||
<div className={`rounded-2xl border transition-all duration-300 ${isOpen ? 'border-indigo-200 shadow-lg bg-white' : 'border-gray-100 bg-gray-50/50 hover:bg-white hover:border-gray-200'}`}>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="w-full flex items-center gap-3 p-4 md:p-5 text-left cursor-pointer"
|
||||
>
|
||||
<span className="text-2xl flex-shrink-0">{icon}</span>
|
||||
<span className={`flex-1 font-bold text-base md:text-lg transition-colors ${isOpen ? 'text-indigo-900' : 'text-gray-700'}`}>
|
||||
{title}
|
||||
</span>
|
||||
<svg
|
||||
className={`w-5 h-5 text-gray-400 transition-transform duration-300 flex-shrink-0 ${isOpen ? 'rotate-180' : ''}`}
|
||||
fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className={`overflow-hidden transition-all duration-300 ${isOpen ? 'max-h-[2000px] opacity-100' : 'max-h-0 opacity-0'}`}>
|
||||
<div className="px-4 md:px-5 pb-5 pt-0">
|
||||
<div className="border-t border-gray-100 pt-4">
|
||||
<article className="prose prose-base max-w-none prose-indigo prose-p:text-gray-700 prose-li:text-gray-700 prose-p:leading-relaxed">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function parseSections(markdown: string): { title: string; content: string }[] {
|
||||
const sections: { title: string; content: string }[] = [];
|
||||
const lines = markdown.split('\n');
|
||||
let currentTitle = '';
|
||||
let currentContent: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const headerMatch = line.match(/^## \d+\.\s*(.+)$/);
|
||||
if (headerMatch) {
|
||||
if (currentTitle) {
|
||||
sections.push({ title: currentTitle, content: currentContent.join('\n').trim() });
|
||||
}
|
||||
currentTitle = headerMatch[1];
|
||||
currentContent = [];
|
||||
} else if (currentTitle) {
|
||||
currentContent.push(line);
|
||||
}
|
||||
}
|
||||
if (currentTitle) {
|
||||
sections.push({ title: currentTitle, content: currentContent.join('\n').trim() });
|
||||
}
|
||||
return sections;
|
||||
}
|
||||
|
||||
export const SECTION_ICONS = ['🌟', '⚖️', '🔗', '✨', '💰', '💼', '💕', '🏥', '🌊', '📅', '👑', '💌'];
|
||||
335
components/AiInterpretationSection.tsx
Normal file
335
components/AiInterpretationSection.tsx
Normal file
@@ -0,0 +1,335 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import type { SajuData } from '@/lib/saju-calculator';
|
||||
import type { DaeunPillar } from '@/lib/daeun-calculator';
|
||||
import { createBrowserClient } from '@supabase/ssr'
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { AccordionItem, parseSections, SECTION_ICONS } from './AccordionItem';
|
||||
import TokenPurchaseModal from './TokenPurchaseModal';
|
||||
import { ensureProfile } from '@/lib/ensure-profile';
|
||||
|
||||
interface AiInterpretationSectionProps {
|
||||
sajuData: SajuData;
|
||||
currentDaeun: DaeunPillar | null;
|
||||
daeunList?: DaeunPillar[];
|
||||
initialInterpretation?: string | null;
|
||||
}
|
||||
|
||||
export default function AiInterpretationSection({ sajuData, currentDaeun, daeunList, initialInterpretation }: AiInterpretationSectionProps) {
|
||||
const [interpretation, setInterpretation] = useState<string | null>(initialInterpretation || null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isUnlocked, setIsUnlocked] = useState(!!initialInterpretation);
|
||||
const [user, setUser] = useState<any>(null);
|
||||
const [credits, setCredits] = useState(0);
|
||||
const [checkingRecord, setCheckingRecord] = useState(false);
|
||||
const [showTokenModal, setShowTokenModal] = useState(false);
|
||||
const router = useRouter();
|
||||
const supabase = createBrowserClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
||||
);
|
||||
|
||||
// Check auth status on mount and listen for changes
|
||||
useEffect(() => {
|
||||
const getUser = async () => {
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
setUser(user);
|
||||
if (user) {
|
||||
const currentCredits = await ensureProfile(supabase, user);
|
||||
setCredits(currentCredits);
|
||||
}
|
||||
};
|
||||
|
||||
getUser();
|
||||
|
||||
const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => {
|
||||
setUser(session?.user ?? null);
|
||||
});
|
||||
|
||||
return () => {
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Check for existing saju_records when user is available
|
||||
useEffect(() => {
|
||||
if (!user || isUnlocked || initialInterpretation) return;
|
||||
|
||||
const checkExistingRecord = async () => {
|
||||
setCheckingRecord(true);
|
||||
try {
|
||||
const { data: records } = await supabase
|
||||
.from('saju_records')
|
||||
.select('interpretation, saju_data')
|
||||
.eq('user_id', user.id);
|
||||
|
||||
if (records && records.length > 0) {
|
||||
const match = records.find((r: any) => {
|
||||
const rd = r.saju_data;
|
||||
return (
|
||||
rd.birthDate?.year === sajuData.birthDate.year &&
|
||||
rd.birthDate?.month === sajuData.birthDate.month &&
|
||||
rd.birthDate?.day === sajuData.birthDate.day &&
|
||||
rd.gender === sajuData.gender
|
||||
);
|
||||
});
|
||||
|
||||
if (match) {
|
||||
setInterpretation(match.interpretation);
|
||||
setIsUnlocked(true);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to check existing records:', err);
|
||||
} finally {
|
||||
setCheckingRecord(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkExistingRecord();
|
||||
}, [user]);
|
||||
|
||||
const handleTokenUse = async () => {
|
||||
if (!user) {
|
||||
if (confirm('로그인이 필요합니다. 로그인 페이지로 이동하시겠습니까?')) {
|
||||
router.push('/login');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (credits < 1) {
|
||||
setShowTokenModal(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// 토큰 차감
|
||||
const { error: updateError } = await supabase
|
||||
.from('profiles')
|
||||
.update({ credits: credits - 1 })
|
||||
.eq('id', user.id);
|
||||
|
||||
if (updateError) {
|
||||
alert('토큰 차감에 실패했습니다. 다시 시도해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setCredits(credits - 1);
|
||||
setLoading(true);
|
||||
setIsUnlocked(true);
|
||||
await fetchInterpretationAndSave();
|
||||
};
|
||||
|
||||
const handlePurchaseComplete = async () => {
|
||||
setShowTokenModal(false);
|
||||
if (user) {
|
||||
const { data: profile } = await supabase
|
||||
.from('profiles')
|
||||
.select('credits')
|
||||
.eq('id', user.id)
|
||||
.single();
|
||||
if (profile) setCredits(profile.credits || 0);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchInterpretationAndSave = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await fetch('/api/analyze', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
saju: sajuData,
|
||||
daeun: currentDaeun,
|
||||
daeunList: daeunList || [],
|
||||
gender: sajuData.gender
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Analysis failed');
|
||||
const data = await response.json();
|
||||
const newInterp = data.interpretation;
|
||||
setInterpretation(newInterp);
|
||||
|
||||
if (user) {
|
||||
// 프로필 존재 보장 (FK 위반 방지)
|
||||
await ensureProfile(supabase, user);
|
||||
|
||||
// Save Record
|
||||
const { error: insertError } = await supabase.from('saju_records').insert({
|
||||
user_id: user.id,
|
||||
saju_data: sajuData,
|
||||
interpretation: newInterp,
|
||||
});
|
||||
if (insertError) {
|
||||
console.error('saju_records 저장 실패:', insertError.message, insertError.details);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError('분석을 불러오는 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// If initialInterpretation is provided, we simulate "already unlocked"
|
||||
useEffect(() => {
|
||||
if (initialInterpretation) {
|
||||
setIsUnlocked(true);
|
||||
setInterpretation(initialInterpretation);
|
||||
}
|
||||
}, [initialInterpretation]);
|
||||
|
||||
// Loading View
|
||||
if (loading || checkingRecord) {
|
||||
return (
|
||||
<div className="bg-white rounded-3xl shadow-xl p-8 md:p-12 mb-8 animate-pulse border border-gray-100">
|
||||
<div className="flex flex-col items-center justify-center space-y-6 py-12">
|
||||
<div className="w-16 h-16 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin"></div>
|
||||
<div className="text-center space-y-2">
|
||||
<h3 className="text-xl font-bold text-gray-800">
|
||||
{checkingRecord ? '기존 기록을 확인하고 있습니다...' : '사주를 분석하고 있습니다...'}
|
||||
</h3>
|
||||
<p className="text-gray-500">
|
||||
{checkingRecord
|
||||
? '잠시만 기다려주세요.'
|
||||
: <>천간과 지지의 조화, 대운의 흐름을 깊이 있게 해석 중입니다.<br />잠시만 기다려주세요.</>
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Locked View (Preview)
|
||||
if (!isUnlocked) {
|
||||
return (
|
||||
<>
|
||||
<div className="relative bg-white rounded-3xl shadow-2xl p-8 md:p-12 mb-8 border border-gray-100 overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-white/80 to-white z-10 flex flex-col items-center justify-center pb-12">
|
||||
<div className="text-center p-8 bg-white/90 backdrop-blur-md rounded-2xl shadow-xl border border-indigo-100 max-w-sm mx-auto">
|
||||
<div className="text-4xl mb-4">🔐</div>
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-2">전문가 심층 분석 잠금해제</h3>
|
||||
<p className="text-gray-600 mb-2">
|
||||
AI 명리학자의 소름 돋는 인생 해석을 확인하세요.
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
토큰 1개 사용 | 보유: <span className="font-bold text-indigo-600">{credits}개</span>
|
||||
</p>
|
||||
<button
|
||||
onClick={handleTokenUse}
|
||||
className="w-full bg-gradient-to-r from-indigo-600 to-purple-600 text-white font-bold py-3 px-6 rounded-xl hover:shadow-lg hover:scale-105 transition transform flex items-center justify-center gap-2"
|
||||
>
|
||||
{credits >= 1 ? (
|
||||
<>
|
||||
<span>토큰 1개로 잠금해제</span>
|
||||
</>
|
||||
) : (
|
||||
<span>토큰 충전하기</span>
|
||||
)}
|
||||
</button>
|
||||
<p className="text-xs text-gray-400 mt-4">
|
||||
* 결제 후 평생 소장 가능합니다.<br />
|
||||
* 같은 생년월일 재접근 시 토큰이 차감되지 않아요.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Blurred Content Placeholder */}
|
||||
<div className="filter blur-sm select-none opacity-50 pointer-events-none">
|
||||
<h2 className="text-3xl font-bold text-gray-900 mb-8 text-center flex items-center justify-center">
|
||||
<span className="text-4xl mr-3">✨</span>
|
||||
당신을 위한 맞춤 심층 분석
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
<div className="h-4 bg-gray-200 rounded w-full"></div>
|
||||
<div className="h-4 bg-gray-200 rounded w-5/6"></div>
|
||||
<div className="h-4 bg-gray-200 rounded w-4/6"></div>
|
||||
<div className="h-32 bg-gray-100 rounded w-full"></div>
|
||||
<div className="h-4 bg-gray-200 rounded w-full"></div>
|
||||
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TokenPurchaseModal
|
||||
isOpen={showTokenModal}
|
||||
onClose={() => setShowTokenModal(false)}
|
||||
onPurchaseComplete={handlePurchaseComplete}
|
||||
user={user}
|
||||
supabase={supabase}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Unlocked View
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-red-50 rounded-3xl shadow-lg p-8 text-center text-red-600">
|
||||
<p>⚠️ {error}</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="mt-4 px-4 py-2 bg-red-100 hover:bg-red-200 rounded-lg transition"
|
||||
>
|
||||
다시 시도하기
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const sections = interpretation ? parseSections(interpretation) : [];
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-3xl shadow-2xl p-6 md:p-10 mb-8 border border-gray-100">
|
||||
<div className="text-center mb-8">
|
||||
<h2 className="text-3xl font-bold bg-gradient-to-r from-indigo-600 to-purple-600 bg-clip-text text-transparent mb-2">
|
||||
✨ 당신을 위한 맞춤 심층 분석
|
||||
</h2>
|
||||
<p className="text-gray-500">AI 사주 상담사가 당신만을 위해 정성껏 풀어봤어요.</p>
|
||||
{user && <span className="inline-block mt-2 px-3 py-1 bg-green-100 text-green-700 text-xs rounded-full">잠금해제 완료 / 평생 소장</span>}
|
||||
</div>
|
||||
|
||||
{sections.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{sections.map((section, idx) => (
|
||||
<AccordionItem
|
||||
key={idx}
|
||||
title={section.title}
|
||||
content={section.content}
|
||||
icon={SECTION_ICONS[idx] || '📌'}
|
||||
defaultOpen={idx === 0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<article className="prose prose-lg max-w-none prose-indigo prose-headings:text-indigo-900 prose-p:text-gray-700 prose-li:text-gray-700">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{interpretation || ''}
|
||||
</ReactMarkdown>
|
||||
</article>
|
||||
)}
|
||||
|
||||
<div className="mt-10 p-5 bg-indigo-50/50 rounded-2xl border border-indigo-100 flex items-start gap-4">
|
||||
<span className="text-2xl pt-1">💡</span>
|
||||
<div>
|
||||
<h4 className="font-bold text-indigo-900 mb-1">참고하세요</h4>
|
||||
<p className="text-sm text-indigo-800/80 leading-relaxed">
|
||||
이 분석은 전통 명리학 알고리즘과 AI를 결합하여 만들어졌어요.
|
||||
인생의 큰 흐름을 이해하는 데 도움이 되길 바라며,
|
||||
모든 선택의 주인공은 언제나 당신이라는 걸 잊지 마세요.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
292
components/TokenPurchaseModal.tsx
Normal file
292
components/TokenPurchaseModal.tsx
Normal file
@@ -0,0 +1,292 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { ensureProfile } from '@/lib/ensure-profile';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
IMP: any;
|
||||
}
|
||||
}
|
||||
|
||||
interface TokenPackage {
|
||||
id: string;
|
||||
name: string;
|
||||
price: number;
|
||||
tokens: number;
|
||||
pricePerToken: number;
|
||||
badge?: string;
|
||||
highlight?: boolean;
|
||||
}
|
||||
|
||||
interface TokenPurchaseModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onPurchaseComplete: () => void;
|
||||
user: any;
|
||||
supabase: any;
|
||||
}
|
||||
|
||||
export default function TokenPurchaseModal({ isOpen, onClose, onPurchaseComplete, user, supabase }: TokenPurchaseModalProps) {
|
||||
const [isFirstPurchase, setIsFirstPurchase] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [purchasing, setPurchasing] = useState(false);
|
||||
const [credits, setCredits] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !user) return;
|
||||
|
||||
const checkFirstPurchase = async () => {
|
||||
setLoading(true);
|
||||
|
||||
// 프로필 확인 (없으면 자동 생성)
|
||||
const currentCredits = await ensureProfile(supabase, user);
|
||||
setCredits(currentCredits);
|
||||
|
||||
// 결제 내역 확인
|
||||
const { data: payments, error } = await supabase
|
||||
.from('payments')
|
||||
.select('id')
|
||||
.eq('user_id', user.id)
|
||||
.eq('status', 'paid')
|
||||
.limit(1);
|
||||
|
||||
if (!error) {
|
||||
setIsFirstPurchase(!payments || payments.length === 0);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
checkFirstPurchase();
|
||||
}, [isOpen, user]);
|
||||
|
||||
const getPackages = (): TokenPackage[] => {
|
||||
const packages: TokenPackage[] = [];
|
||||
|
||||
if (isFirstPurchase) {
|
||||
packages.push({
|
||||
id: 'first_990',
|
||||
name: '첫 결제 특별 혜택',
|
||||
price: 990,
|
||||
tokens: 3,
|
||||
pricePerToken: 330,
|
||||
badge: '첫 결제 한정',
|
||||
highlight: true,
|
||||
});
|
||||
}
|
||||
|
||||
packages.push(
|
||||
{
|
||||
id: 'basic_990',
|
||||
name: '기본 패키지',
|
||||
price: 990,
|
||||
tokens: 1,
|
||||
pricePerToken: 990,
|
||||
},
|
||||
{
|
||||
id: 'standard_2500',
|
||||
name: '스탠다드 패키지',
|
||||
price: 2500,
|
||||
tokens: 3,
|
||||
pricePerToken: 833,
|
||||
badge: '인기',
|
||||
},
|
||||
{
|
||||
id: 'premium_9000',
|
||||
name: '프리미엄 패키지',
|
||||
price: 9000,
|
||||
tokens: 10,
|
||||
pricePerToken: 900,
|
||||
badge: '최고 가성비',
|
||||
},
|
||||
);
|
||||
|
||||
return packages;
|
||||
};
|
||||
|
||||
const handlePurchase = async (pkg: TokenPackage) => {
|
||||
if (!window.IMP) {
|
||||
alert('결제 모듈을 불러오는 중입니다. 잠시 후 다시 시도해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setPurchasing(true);
|
||||
const { IMP } = window;
|
||||
IMP.init(process.env.NEXT_PUBLIC_PORTONE_IMP_CODE);
|
||||
|
||||
const merchantUid = `token_${pkg.id}_${new Date().getTime()}`;
|
||||
|
||||
const data = {
|
||||
pg: 'kakaopay',
|
||||
pay_method: 'card',
|
||||
merchant_uid: merchantUid,
|
||||
name: `사주보기 토큰 ${pkg.tokens}개 (${pkg.name})`,
|
||||
amount: pkg.price,
|
||||
buyer_email: user.email,
|
||||
};
|
||||
|
||||
IMP.request_pay(data, async (rsp: any) => {
|
||||
if (rsp.success) {
|
||||
try {
|
||||
// 1. 결제 기록 저장 (token_amount 컬럼 없을 수 있으므로 fallback)
|
||||
const paymentData: any = {
|
||||
user_id: user.id,
|
||||
imp_uid: rsp.imp_uid,
|
||||
merchant_uid: rsp.merchant_uid,
|
||||
amount: pkg.price,
|
||||
status: 'paid',
|
||||
};
|
||||
|
||||
const { error: paymentError } = await supabase
|
||||
.from('payments')
|
||||
.insert(paymentData);
|
||||
|
||||
if (paymentError) {
|
||||
console.warn('결제 기록 저장 경고:', paymentError.message);
|
||||
// merchant_uid 중복이면 이미 처리된 결제이므로 무시
|
||||
if (!paymentError.message.includes('duplicate') && !paymentError.message.includes('unique')) {
|
||||
console.error('결제 기록 저장 실패:', paymentError);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 크레딧 업데이트 - RPC 함수 시도 (atomic increment)
|
||||
let newCredits = credits + pkg.tokens;
|
||||
const { data: rpcResult, error: rpcError } = await supabase
|
||||
.rpc('add_credits', { user_id_input: user.id, amount_input: pkg.tokens });
|
||||
|
||||
if (rpcError) {
|
||||
console.warn('RPC add_credits 실패, 직접 업데이트 시도:', rpcError.message);
|
||||
// RPC 함수가 아직 없는 경우 직접 업데이트
|
||||
// 현재 최신 credits를 다시 조회 후 업데이트
|
||||
const { data: freshProfile } = await supabase
|
||||
.from('profiles')
|
||||
.select('credits')
|
||||
.eq('id', user.id)
|
||||
.single();
|
||||
|
||||
const currentCredits = freshProfile?.credits || 0;
|
||||
newCredits = currentCredits + pkg.tokens;
|
||||
|
||||
const { error: updateError } = await supabase
|
||||
.from('profiles')
|
||||
.update({ credits: newCredits })
|
||||
.eq('id', user.id);
|
||||
|
||||
if (updateError) {
|
||||
alert(`토큰 충전에 실패했습니다: ${updateError.message}\n고객센터에 문의해주세요.`);
|
||||
setPurchasing(false);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// RPC 성공 시 반환값이 최신 크레딧
|
||||
newCredits = rpcResult ?? newCredits;
|
||||
}
|
||||
|
||||
setCredits(newCredits);
|
||||
alert(`결제가 완료되었습니다! 토큰 ${pkg.tokens}개가 충전되었어요.`);
|
||||
setPurchasing(false);
|
||||
onPurchaseComplete();
|
||||
} catch (err) {
|
||||
console.error('충전 처리 중 오류:', err);
|
||||
alert('결제는 완료되었으나 토큰 충전 중 오류가 발생했습니다. 페이지를 새로고침해주세요.');
|
||||
setPurchasing(false);
|
||||
}
|
||||
} else {
|
||||
alert(`결제에 실패하였습니다: ${rsp.error_msg}`);
|
||||
setPurchasing(false);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
|
||||
<div className="bg-white rounded-3xl shadow-2xl max-w-lg w-full max-h-[90vh] overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="p-6 border-b border-gray-100">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900">토큰 충전</h2>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
보유 토큰: <span className="font-bold text-indigo-600">{credits}개</span>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-10 h-10 rounded-full bg-gray-100 hover:bg-gray-200 flex items-center justify-center transition"
|
||||
>
|
||||
<svg className="w-5 h-5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6">
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="w-10 h-10 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin"></div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* 토큰 설명 */}
|
||||
<div className="bg-indigo-50 rounded-xl p-4 mb-6">
|
||||
<p className="text-sm text-indigo-800">
|
||||
토큰 1개로 사주 분석, 궁합 상세해석, 토정비결 상세해석 중 하나를 이용할 수 있어요.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{getPackages().map((pkg) => (
|
||||
<button
|
||||
key={pkg.id}
|
||||
onClick={() => handlePurchase(pkg)}
|
||||
disabled={purchasing}
|
||||
className={`w-full rounded-2xl p-5 border-2 text-left transition-all hover:shadow-lg disabled:opacity-50 ${
|
||||
pkg.highlight
|
||||
? 'border-pink-400 bg-gradient-to-r from-pink-50 to-purple-50 hover:border-pink-500'
|
||||
: 'border-gray-200 bg-white hover:border-indigo-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className={`font-bold text-lg ${pkg.highlight ? 'text-pink-700' : 'text-gray-900'}`}>
|
||||
{pkg.name}
|
||||
</h3>
|
||||
{pkg.badge && (
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full font-semibold ${
|
||||
pkg.highlight ? 'bg-pink-500 text-white' : 'bg-indigo-100 text-indigo-700'
|
||||
}`}>
|
||||
{pkg.badge}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">
|
||||
토큰 {pkg.tokens}개 | 개당 {pkg.pricePerToken.toLocaleString()}원
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className={`text-2xl font-bold ${pkg.highlight ? 'text-pink-600' : 'text-indigo-600'}`}>
|
||||
{pkg.price.toLocaleString()}원
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-6 border-t border-gray-100">
|
||||
<p className="text-xs text-gray-400 text-center">
|
||||
결제 후 즉시 토큰이 충전됩니다. 충전된 토큰은 환불이 불가합니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
73
components/UserMenu.tsx
Normal file
73
components/UserMenu.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { createBrowserClient } from '@supabase/ssr' // Use ssr browser client
|
||||
import { User } from '@supabase/supabase-js';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export default function UserMenu() {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const router = useRouter();
|
||||
const supabase = createBrowserClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Check active session
|
||||
const getUser = async () => {
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
setUser(user);
|
||||
};
|
||||
|
||||
getUser();
|
||||
|
||||
// Listen for auth changes
|
||||
const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => {
|
||||
setUser(session?.user ?? null);
|
||||
});
|
||||
|
||||
return () => {
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleLogout = async () => {
|
||||
await supabase.auth.signOut();
|
||||
setUser(null);
|
||||
router.push('/');
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
if (user) {
|
||||
return (
|
||||
<div className="flex items-center space-x-4">
|
||||
<Link
|
||||
href="/mypage"
|
||||
className="text-gray-700 hover:text-indigo-600 transition font-medium"
|
||||
>
|
||||
마이페이지
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="bg-gray-100 hover:bg-gray-200 text-gray-700 px-4 py-2 rounded-lg transition text-sm font-medium"
|
||||
>
|
||||
로그아웃
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-4">
|
||||
<Link
|
||||
href="/login"
|
||||
className="bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-lg transition text-sm font-bold shadow-md hover:shadow-lg"
|
||||
>
|
||||
로그인 / 회원가입
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
55
docker-compose.integration.yml
Normal file
55
docker-compose.integration.yml
Normal file
@@ -0,0 +1,55 @@
|
||||
# ============================================
|
||||
# NAS webpage 프로젝트에 통합할 설정
|
||||
# ============================================
|
||||
# 이 내용을 /volume1/docker/webpage/docker-compose.yml에
|
||||
# services: 섹션에 추가하세요.
|
||||
# ============================================
|
||||
|
||||
saju-web:
|
||||
build:
|
||||
context: ./saju-web # saju-web 프로젝트 경로
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
NEXT_PUBLIC_BASE_PATH: ${SAJU_BASE_PATH:-/saju}
|
||||
NEXT_PUBLIC_KAKAO_APP_KEY: ${SAJU_KAKAO_APP_KEY}
|
||||
NEXT_PUBLIC_SUPABASE_URL: ${SAJU_SUPABASE_URL}
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${SAJU_SUPABASE_ANON_KEY}
|
||||
NEXT_PUBLIC_PORTONE_IMP_CODE: ${SAJU_PORTONE_IMP_CODE}
|
||||
|
||||
container_name: saju-web
|
||||
|
||||
restart: unless-stopped
|
||||
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- OPENAI_API_KEY=${SAJU_OPENAI_API_KEY}
|
||||
- NEXT_PUBLIC_BASE_PATH=${SAJU_BASE_PATH:-/saju}
|
||||
- NEXT_PUBLIC_KAKAO_APP_KEY=${SAJU_KAKAO_APP_KEY}
|
||||
- NEXT_PUBLIC_SUPABASE_URL=${SAJU_SUPABASE_URL}
|
||||
- NEXT_PUBLIC_SUPABASE_ANON_KEY=${SAJU_SUPABASE_ANON_KEY}
|
||||
- NEXT_PUBLIC_PORTONE_IMP_CODE=${SAJU_PORTONE_IMP_CODE}
|
||||
|
||||
networks:
|
||||
- default # 기존 네트워크 사용
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/api/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
labels:
|
||||
- "com.docker.compose.project=webpage"
|
||||
- "service.name=saju-web"
|
||||
- "service.version=1.0.0"
|
||||
|
||||
# ============================================
|
||||
# 환경 변수 설정 (.env 파일에 추가)
|
||||
# ============================================
|
||||
# SAJU_BASE_PATH=/saju
|
||||
# SAJU_KAKAO_APP_KEY=your_key_here
|
||||
# SAJU_SUPABASE_URL=https://your-project.supabase.co
|
||||
# SAJU_SUPABASE_ANON_KEY=your_key_here
|
||||
# SAJU_PORTONE_IMP_CODE=your_code_here
|
||||
# SAJU_OPENAI_API_KEY=your_key_here
|
||||
48
docker-compose.yml
Normal file
48
docker-compose.yml
Normal file
@@ -0,0 +1,48 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
saju-web:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
NEXT_PUBLIC_BASE_PATH: ${NEXT_PUBLIC_BASE_PATH}
|
||||
NEXT_PUBLIC_KAKAO_APP_KEY: ${NEXT_PUBLIC_KAKAO_APP_KEY}
|
||||
NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL}
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${NEXT_PUBLIC_SUPABASE_ANON_KEY}
|
||||
NEXT_PUBLIC_PORTONE_IMP_CODE: ${NEXT_PUBLIC_PORTONE_IMP_CODE}
|
||||
|
||||
container_name: saju-web
|
||||
|
||||
restart: unless-stopped
|
||||
|
||||
ports:
|
||||
- "3005:3000" # 호스트:컨테이너
|
||||
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- OPENAI_API_KEY=${OPENAI_API_KEY}
|
||||
- NEXT_PUBLIC_BASE_PATH=${NEXT_PUBLIC_BASE_PATH}
|
||||
- NEXT_PUBLIC_KAKAO_APP_KEY=${NEXT_PUBLIC_KAKAO_APP_KEY}
|
||||
- NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL}
|
||||
- NEXT_PUBLIC_SUPABASE_ANON_KEY=${NEXT_PUBLIC_SUPABASE_ANON_KEY}
|
||||
- NEXT_PUBLIC_PORTONE_IMP_CODE=${NEXT_PUBLIC_PORTONE_IMP_CODE}
|
||||
|
||||
networks:
|
||||
- saju-network
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/api/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
labels:
|
||||
- "com.docker.compose.project=saju-web"
|
||||
- "service.name=saju-web"
|
||||
- "service.version=1.0.0"
|
||||
|
||||
networks:
|
||||
saju-network:
|
||||
driver: bridge
|
||||
@@ -1,316 +1,386 @@
|
||||
import { SajuData } from './saju-calculator';
|
||||
|
||||
import {
|
||||
SajuData, FIVE_ELEMENTS, HEAVENLY_STEMS,
|
||||
getHiddenStems, getAllHiddenStems,
|
||||
analyzeBranchInteractions, calculateShinsal, calculateGongmang,
|
||||
getYearGanzi, FIVE_ELEMENTS_KR, EARTHLY_BRANCHES_KR, EARTHLY_BRANCHES,
|
||||
BranchInteraction, Shinsal,
|
||||
} from './saju-calculator';
|
||||
import { DaeunPillar } from './daeun-calculator';
|
||||
|
||||
// ============================================================
|
||||
// 오행 밸런스 정밀 분석 (가중치 적용)
|
||||
// ============================================================
|
||||
|
||||
export interface ElementBalance {
|
||||
木: number;
|
||||
火: number;
|
||||
土: number;
|
||||
金: number;
|
||||
水: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 기반 사주 해석
|
||||
* 사주 데이터를 분석하여 상세한 해석 제공
|
||||
* 가중치 적용 오행 점수 계산
|
||||
* - 천간: 1.0
|
||||
* - 지지 본기(정기): 1.0
|
||||
* - 지장간 중기: 0.5
|
||||
* - 지장간 여기: 0.3
|
||||
*/
|
||||
export function calculateDetailedElementBalance(saju: SajuData): ElementBalance {
|
||||
const balance: ElementBalance = { 木: 0, 火: 0, 土: 0, 金: 0, 水: 0 };
|
||||
|
||||
interface Interpretation {
|
||||
personality: string[];
|
||||
strengths: string[];
|
||||
weaknesses: string[];
|
||||
career: string[];
|
||||
relationships: string[];
|
||||
health: string[];
|
||||
wealth: string[];
|
||||
advice: string[];
|
||||
// 천간 오행 (각 1.0)
|
||||
const stems = [saju.year.stem, saju.month.stem, saju.day.stem];
|
||||
if (saju.hour) stems.push(saju.hour.stem);
|
||||
|
||||
for (const stem of stems) {
|
||||
const elem = FIVE_ELEMENTS[stem as keyof typeof FIVE_ELEMENTS] as keyof ElementBalance;
|
||||
if (elem) balance[elem] += 1.0;
|
||||
}
|
||||
|
||||
// 지지 지장간 (본기 1.0, 중기 0.5, 여기 0.3)
|
||||
const branches = [saju.year.branch, saju.month.branch, saju.day.branch];
|
||||
if (saju.hour) branches.push(saju.hour.branch);
|
||||
|
||||
const weights = [1.0, 0.5, 0.3];
|
||||
for (const branch of branches) {
|
||||
const hidden = getHiddenStems(branch);
|
||||
for (let i = 0; i < hidden.length; i++) {
|
||||
const elem = FIVE_ELEMENTS[hidden[i] as keyof typeof FIVE_ELEMENTS] as keyof ElementBalance;
|
||||
if (elem) balance[elem] += weights[i] || 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
// 소수점 둘째 자리로 반올림
|
||||
for (const key of Object.keys(balance) as (keyof ElementBalance)[]) {
|
||||
balance[key] = Math.round(balance[key] * 100) / 100;
|
||||
}
|
||||
|
||||
return balance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 오행 균형 분석
|
||||
*/
|
||||
function analyzeElementBalance(saju: SajuData): { [key: string]: number } {
|
||||
const elements = { 木: 0, 火: 0, 土: 0, 金: 0, 水: 0 };
|
||||
|
||||
// 사주팔자의 각 기둥에서 오행 카운트
|
||||
elements[saju.year.element]++;
|
||||
elements[saju.month.element]++;
|
||||
elements[saju.day.element]++;
|
||||
if (saju.hour) elements[saju.hour.element]++;
|
||||
|
||||
return elements;
|
||||
}
|
||||
|
||||
/**
|
||||
* 십성 분석
|
||||
*/
|
||||
function analyzeTenGods(saju: SajuData): { [key: string]: number } {
|
||||
const tenGods: { [key: string]: number } = {};
|
||||
|
||||
[saju.year.tenGod, saju.month.tenGod, saju.hour?.tenGod].forEach(god => {
|
||||
if (god && god !== '일간') {
|
||||
tenGods[god] = (tenGods[god] || 0) + 1;
|
||||
}
|
||||
});
|
||||
|
||||
return tenGods;
|
||||
}
|
||||
|
||||
/**
|
||||
* 일간 기반 성격 해석
|
||||
*/
|
||||
function interpretDayStem(stem: string, element: string): string[] {
|
||||
const interpretations: { [key: string]: string[] } = {
|
||||
'甲': [
|
||||
'큰 나무처럼 곧고 꿋꿋한 성격입니다.',
|
||||
'리더십이 강하고 개척 정신이 뛰어납니다.',
|
||||
'정의감이 강하고 원칙을 중요시합니다.',
|
||||
'때로는 융통성이 부족할 수 있습니다.'
|
||||
],
|
||||
'乙': [
|
||||
'부드러운 풀처럼 유연하고 적응력이 뛰어납니다.',
|
||||
'섬세하고 예술적 감각이 있습니다.',
|
||||
'주변 환경에 잘 적응하며 협력을 중시합니다.',
|
||||
'때로는 우유부단할 수 있습니다.'
|
||||
],
|
||||
'丙': [
|
||||
'태양처럼 밝고 활발한 성격입니다.',
|
||||
'사교성이 뛰어나고 열정적입니다.',
|
||||
'창의적이고 표현력이 풍부합니다.',
|
||||
'때로는 충동적일 수 있습니다.'
|
||||
],
|
||||
'丁': [
|
||||
'촛불처럼 따뜻하고 섬세한 성격입니다.',
|
||||
'예민하고 감수성이 풍부합니다.',
|
||||
'예의 바르고 배려심이 깊습니다.',
|
||||
'때로는 너무 예민할 수 있습니다.'
|
||||
],
|
||||
'戊': [
|
||||
'산처럼 묵직하고 안정적인 성격입니다.',
|
||||
'책임감이 강하고 신뢰할 수 있습니다.',
|
||||
'현실적이고 실용적입니다.',
|
||||
'때로는 고집이 셀 수 있습니다.'
|
||||
],
|
||||
'己': [
|
||||
'밭처럼 포용력 있고 온화한 성격입니다.',
|
||||
'배려심이 깊고 참을성이 강합니다.',
|
||||
'현실적이며 실속을 챙깁니다.',
|
||||
'때로는 소극적일 수 있습니다.'
|
||||
],
|
||||
'庚': [
|
||||
'금속처럼 단단하고 강인한 성격입니다.',
|
||||
'결단력이 있고 추진력이 강합니다.',
|
||||
'정직하고 의리를 중시합니다.',
|
||||
'때로는 융통성이 부족할 수 있습니다.'
|
||||
],
|
||||
'辛': [
|
||||
'보석처럼 고귀하고 섬세한 성격입니다.',
|
||||
'예리하고 통찰력이 뛰어납니다.',
|
||||
'품위 있고 우아함을 추구합니다.',
|
||||
'때로는 까다로울 수 있습니다.'
|
||||
],
|
||||
'壬': [
|
||||
'큰 바다처럼 넓고 깊은 성격입니다.',
|
||||
'지혜롭고 포용력이 있습니다.',
|
||||
'융통성이 있고 적응력이 뛰어납니다.',
|
||||
'때로는 변덕스러울 수 있습니다.'
|
||||
],
|
||||
'癸': [
|
||||
'이슬처럼 섬세하고 조용한 성격입니다.',
|
||||
'지적이고 사려 깊습니다.',
|
||||
'인내심이 강하고 끈기가 있습니다.',
|
||||
'때로는 소심할 수 있습니다.'
|
||||
]
|
||||
};
|
||||
|
||||
return interpretations[stem] || ['독특한 개성을 가진 사람입니다.'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 직업 운세 분석
|
||||
*/
|
||||
function interpretCareer(saju: SajuData, tenGods: { [key: string]: number }): string[] {
|
||||
const career: string[] = [];
|
||||
const element = saju.day.element;
|
||||
|
||||
// 오행 기반 직업 추천
|
||||
const careerByElement: { [key: string]: string[] } = {
|
||||
'木': ['교육', '출판', '디자인', '패션', '임업', '환경'],
|
||||
'火': ['예술', '광고', '방송', '요식업', 'IT', '전자'],
|
||||
'土': ['부동산', '건설', '농업', '유통', '중개', '컨설팅'],
|
||||
'金': ['금융', '법조', '의료', '기계', '자동차', '보석'],
|
||||
'水': ['무역', '물류', '여행', '수산', '음료', '화학']
|
||||
};
|
||||
|
||||
career.push(...careerByElement[element].slice(0, 3).map(c => `${c} 분야에 적성이 있습니다.`));
|
||||
|
||||
// 십성 기반 직업 성향
|
||||
if (tenGods['정관'] || tenGods['편관']) {
|
||||
career.push('조직 생활이나 공직에 적합합니다.');
|
||||
}
|
||||
if (tenGods['정재'] || tenGods['편재']) {
|
||||
career.push('재물 관리나 사업에 능력이 있습니다.');
|
||||
}
|
||||
if (tenGods['식신'] || tenGods['상관']) {
|
||||
career.push('창의적인 일이나 표현하는 직업이 좋습니다.');
|
||||
}
|
||||
if (tenGods['정인'] || tenGods['편인']) {
|
||||
career.push('학문, 연구, 교육 분야가 적합합니다.');
|
||||
}
|
||||
|
||||
return career;
|
||||
}
|
||||
|
||||
/**
|
||||
* 대인 관계 분석
|
||||
*/
|
||||
function interpretRelationships(saju: SajuData, tenGods: { [key: string]: number }): string[] {
|
||||
const relationships: string[] = [];
|
||||
|
||||
if (tenGods['비견'] || tenGods['겁재']) {
|
||||
relationships.push('친구나 동료와의 관계가 중요합니다.');
|
||||
relationships.push('경쟁심이 있지만 협력도 잘합니다.');
|
||||
}
|
||||
|
||||
if (tenGods['정관'] || tenGods['편관']) {
|
||||
relationships.push('윗사람의 인정을 받기 쉽습니다.');
|
||||
relationships.push('사회적 명예를 중시합니다.');
|
||||
}
|
||||
|
||||
if (tenGods['정재'] || tenGods['편재']) {
|
||||
if (saju.gender === 'male') {
|
||||
relationships.push('이성과의 인연이 좋습니다.');
|
||||
} else {
|
||||
relationships.push('재물 운이 좋습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
if (tenGods['정인'] || tenGods['편인']) {
|
||||
if (saju.gender === 'female') {
|
||||
relationships.push('가족과의 유대가 깊습니다.');
|
||||
} else {
|
||||
relationships.push('멘토를 만나기 쉽습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
return relationships;
|
||||
}
|
||||
|
||||
/**
|
||||
* 건강 운세 분석
|
||||
*/
|
||||
function interpretHealth(saju: SajuData, elements: { [key: string]: number }): string[] {
|
||||
const health: string[] = [];
|
||||
const element = saju.day.element;
|
||||
|
||||
// 오행별 건강 주의사항
|
||||
const healthByElement: { [key: string]: string } = {
|
||||
'木': '간, 담낭, 눈 건강에 주의하세요.',
|
||||
'火': '심장, 혈압, 소장 건강에 주의하세요.',
|
||||
'土': '위장, 소화기, 비장 건강에 주의하세요.',
|
||||
'金': '폐, 대장, 피부 건강에 주의하세요.',
|
||||
'水': '신장, 방광, 생식기 건강에 주의하세요.'
|
||||
};
|
||||
|
||||
health.push(healthByElement[element]);
|
||||
|
||||
// 오행 불균형 체크
|
||||
const maxElement = Object.keys(elements).reduce((a, b) =>
|
||||
elements[a] > elements[b] ? a : b
|
||||
);
|
||||
const minElement = Object.keys(elements).reduce((a, b) =>
|
||||
elements[a] < elements[b] ? a : b
|
||||
);
|
||||
|
||||
if (elements[maxElement] - elements[minElement] >= 2) {
|
||||
health.push('오행 균형을 맞추기 위한 식습관 관리가 필요합니다.');
|
||||
}
|
||||
|
||||
health.push('규칙적인 생활과 적절한 운동이 중요합니다.');
|
||||
|
||||
return health;
|
||||
}
|
||||
|
||||
/**
|
||||
* 재물 운세 분석
|
||||
*/
|
||||
function interpretWealth(saju: SajuData, tenGods: { [key: string]: number }): string[] {
|
||||
const wealth: string[] = [];
|
||||
|
||||
if (tenGods['정재']) {
|
||||
wealth.push('정직한 노력으로 재물을 모을 수 있습니다.');
|
||||
wealth.push('월급이나 안정적인 수입이 좋습니다.');
|
||||
}
|
||||
|
||||
if (tenGods['편재']) {
|
||||
wealth.push('사업이나 투자로 재물을 얻을 수 있습니다.');
|
||||
wealth.push('재테크에 관심을 가지면 좋습니다.');
|
||||
}
|
||||
|
||||
if (tenGods['식신'] || tenGods['상관']) {
|
||||
wealth.push('재능을 활용한 수입원이 있습니다.');
|
||||
wealth.push('창의적인 일로 돈을 벌 수 있습니다.');
|
||||
}
|
||||
|
||||
if (!tenGods['정재'] && !tenGods['편재']) {
|
||||
wealth.push('재물보다는 명예나 학문을 추구합니다.');
|
||||
wealth.push('꾸준한 저축이 중요합니다.');
|
||||
}
|
||||
|
||||
return wealth;
|
||||
}
|
||||
|
||||
/**
|
||||
* 종합 조언
|
||||
*/
|
||||
function generateAdvice(saju: SajuData, elements: { [key: string]: number }): string[] {
|
||||
const advice: string[] = [];
|
||||
const element = saju.day.element;
|
||||
|
||||
// 오행별 조언
|
||||
const adviceByElement: { [key: string]: string[] } = {
|
||||
'木': ['아침 산책으로 하루를 시작하세요.', '녹색 식물을 가까이 하세요.', '독서로 마음을 충전하세요.'],
|
||||
'火': ['밝은 색상의 옷을 입으세요.', '사람들과 적극적으로 소통하세요.', '예술 활동을 즐기세요.'],
|
||||
'土': ['규칙적인 식사를 하세요.', '안정적인 계획을 세우세요.', '자연과 가까운 곳에 가세요.'],
|
||||
'金': ['명확한 목표를 설정하세요.', '금속 액세서리를 착용하세요.', '원칙을 지키되 융통성도 발휘하세요.'],
|
||||
'水': ['충분한 수분 섭취를 하세요.', '유연한 사고를 유지하세요.', '명상이나 요가로 마음을 다스리세요.']
|
||||
};
|
||||
|
||||
advice.push(...adviceByElement[element]);
|
||||
|
||||
// 일반적인 조언
|
||||
advice.push('자신의 장점을 살리고 단점을 보완하세요.');
|
||||
advice.push('긍정적인 마인드로 하루를 시작하세요.');
|
||||
|
||||
return advice;
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 사주 해석 생성
|
||||
*/
|
||||
export function generateInterpretation(saju: SajuData): Interpretation {
|
||||
const elements = analyzeElementBalance(saju);
|
||||
const tenGods = analyzeTenGods(saju);
|
||||
|
||||
const personality = interpretDayStem(saju.day.stem, saju.day.element);
|
||||
|
||||
// 장점과 단점 분리
|
||||
const strengths = personality.filter((_, i) => i < 3);
|
||||
const weaknesses = [personality[3] || '균형 잡힌 성격입니다.'];
|
||||
|
||||
return {
|
||||
personality,
|
||||
strengths,
|
||||
weaknesses,
|
||||
career: interpretCareer(saju, tenGods),
|
||||
relationships: interpretRelationships(saju, tenGods),
|
||||
health: interpretHealth(saju, elements),
|
||||
wealth: interpretWealth(saju, tenGods),
|
||||
advice: generateAdvice(saju, elements)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 오행 균형 점수 계산
|
||||
* 오행 비율(%) 계산
|
||||
*/
|
||||
export function calculateElementScore(saju: SajuData): { [key: string]: number } {
|
||||
const elements = analyzeElementBalance(saju);
|
||||
const total = Object.values(elements).reduce((a, b) => a + b, 0);
|
||||
const balance = calculateDetailedElementBalance(saju);
|
||||
const total = Object.values(balance).reduce((a, b) => a + b, 0);
|
||||
|
||||
const scores: { [key: string]: number } = {};
|
||||
for (const [element, count] of Object.entries(elements)) {
|
||||
scores[element] = Math.round((count / total) * 100);
|
||||
for (const [element, value] of Object.entries(balance)) {
|
||||
scores[element] = total > 0 ? Math.round((value / total) * 100) : 0;
|
||||
}
|
||||
|
||||
return scores;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 신강/신약 자동 판단
|
||||
// ============================================================
|
||||
|
||||
export interface DayMasterStrength {
|
||||
result: '신강' | '신약' | '중화';
|
||||
score: number;
|
||||
reasons: string[];
|
||||
}
|
||||
|
||||
const PRODUCE_MAP: { [key: string]: string } = {
|
||||
'木': '火', '火': '土', '土': '金', '金': '水', '水': '木',
|
||||
};
|
||||
|
||||
function getProducingElement(elem: string): string {
|
||||
for (const [k, v] of Object.entries(PRODUCE_MAP)) {
|
||||
if (v === elem) return k;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 신강/신약 판단
|
||||
*/
|
||||
export function analyzeDayMasterStrength(saju: SajuData): DayMasterStrength {
|
||||
const dayStem = saju.dayStem;
|
||||
const dayElement = FIVE_ELEMENTS[dayStem as keyof typeof FIVE_ELEMENTS];
|
||||
const producingElement = getProducingElement(dayElement);
|
||||
const reasons: string[] = [];
|
||||
let score = 0;
|
||||
|
||||
// 1. 월령 득령 확인
|
||||
const monthBranch = saju.month.branch;
|
||||
const monthHidden = getHiddenStems(monthBranch);
|
||||
const monthMainElement = FIVE_ELEMENTS[monthHidden[0] as keyof typeof FIVE_ELEMENTS];
|
||||
|
||||
if (monthMainElement === dayElement) {
|
||||
score += 3;
|
||||
reasons.push(`월령 득령: 월지 ${saju.month.branchKr}(${monthBranch})의 본기가 일간과 같은 ${FIVE_ELEMENTS_KR[dayElement as keyof typeof FIVE_ELEMENTS_KR]}으로 강한 힘을 받음`);
|
||||
} else if (monthMainElement === producingElement) {
|
||||
score += 2;
|
||||
reasons.push(`월령 득령: 월지 ${saju.month.branchKr}(${monthBranch})의 본기가 일간을 생하는 ${FIVE_ELEMENTS_KR[producingElement as keyof typeof FIVE_ELEMENTS_KR]}으로 힘을 받음`);
|
||||
} else {
|
||||
score -= 2;
|
||||
reasons.push(`월령 실령: 월지 ${saju.month.branchKr}(${monthBranch})의 본기가 일간을 돕지 않음`);
|
||||
}
|
||||
|
||||
// 2. 통근 확인
|
||||
const allBranches = [saju.year.branch, saju.month.branch, saju.day.branch];
|
||||
if (saju.hour) allBranches.push(saju.hour.branch);
|
||||
|
||||
let rootCount = 0;
|
||||
for (const branch of allBranches) {
|
||||
const hidden = getHiddenStems(branch);
|
||||
for (const h of hidden) {
|
||||
const hElem = FIVE_ELEMENTS[h as keyof typeof FIVE_ELEMENTS];
|
||||
if (hElem === dayElement || hElem === producingElement) {
|
||||
rootCount++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (rootCount >= 3) {
|
||||
score += 2;
|
||||
reasons.push(`통근 강함: ${rootCount}개 지지에서 일간의 뿌리를 찾음`);
|
||||
} else if (rootCount >= 2) {
|
||||
score += 1;
|
||||
reasons.push(`통근 보통: ${rootCount}개 지지에서 일간의 뿌리를 찾음`);
|
||||
} else {
|
||||
score -= 1;
|
||||
reasons.push(`통근 약함: ${rootCount}개 지지에서만 일간의 뿌리를 찾음`);
|
||||
}
|
||||
|
||||
// 3. 투출 확인
|
||||
const allStems = [saju.year.stem, saju.month.stem];
|
||||
if (saju.hour) allStems.push(saju.hour.stem);
|
||||
|
||||
let helpingStemCount = 0;
|
||||
for (const stem of allStems) {
|
||||
const stemElem = FIVE_ELEMENTS[stem as keyof typeof FIVE_ELEMENTS];
|
||||
if (stemElem === dayElement || stemElem === producingElement) {
|
||||
helpingStemCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (helpingStemCount >= 2) {
|
||||
score += 2;
|
||||
reasons.push(`투출 강함: 천간에 비겁/인성이 ${helpingStemCount}개 있어 일간을 도움`);
|
||||
} else if (helpingStemCount === 1) {
|
||||
score += 1;
|
||||
reasons.push(`투출 보통: 천간에 비겁/인성이 1개 있음`);
|
||||
} else {
|
||||
score -= 1;
|
||||
reasons.push(`투출 없음: 천간에 일간을 돕는 비겁/인성이 없음`);
|
||||
}
|
||||
|
||||
// 4. 오행 비율 기반 조력 분석
|
||||
const balance = calculateDetailedElementBalance(saju);
|
||||
const helpingScore = balance[dayElement as keyof ElementBalance] + balance[producingElement as keyof ElementBalance];
|
||||
const drainingScore = Object.entries(balance)
|
||||
.filter(([k]) => k !== dayElement && k !== producingElement)
|
||||
.reduce((sum, [, v]) => sum + v, 0);
|
||||
|
||||
if (helpingScore > drainingScore * 1.3) {
|
||||
score += 1;
|
||||
reasons.push(`오행 비율: 비겁+인성(${helpingScore.toFixed(1)}) > 식상+재관(${drainingScore.toFixed(1)}) → 일간 세력 우세`);
|
||||
} else if (drainingScore > helpingScore * 1.3) {
|
||||
score -= 1;
|
||||
reasons.push(`오행 비율: 식상+재관(${drainingScore.toFixed(1)}) > 비겁+인성(${helpingScore.toFixed(1)}) → 일간 세력 열세`);
|
||||
}
|
||||
|
||||
let result: '신강' | '신약' | '중화';
|
||||
if (score >= 3) result = '신강';
|
||||
else if (score <= -2) result = '신약';
|
||||
else result = '중화';
|
||||
|
||||
return { result, score, reasons };
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 용신 (用神) 추정
|
||||
// ============================================================
|
||||
|
||||
export interface YongShinResult {
|
||||
yongShin: string;
|
||||
yongShinKr: string;
|
||||
heeShin: string;
|
||||
heeShinKr: string;
|
||||
giShin: string;
|
||||
giShinKr: string;
|
||||
explanation: string;
|
||||
}
|
||||
|
||||
const OVERCOME_MAP: { [key: string]: string } = {
|
||||
'木': '土', '火': '金', '土': '水', '金': '木', '水': '火',
|
||||
};
|
||||
|
||||
function getOvercomingMe(elem: string): string {
|
||||
for (const [k, v] of Object.entries(OVERCOME_MAP)) {
|
||||
if (v === elem) return k;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export function estimateYongShin(saju: SajuData, strength: DayMasterStrength): YongShinResult {
|
||||
const dayElement = FIVE_ELEMENTS[saju.dayStem as keyof typeof FIVE_ELEMENTS];
|
||||
const balance = calculateDetailedElementBalance(saju);
|
||||
|
||||
const producingMe = getProducingElement(dayElement); // 인성
|
||||
const myProduct = PRODUCE_MAP[dayElement]; // 식상
|
||||
const myOvercome = OVERCOME_MAP[dayElement]; // 재성
|
||||
const overcomeMe = getOvercomingMe(dayElement); // 관살
|
||||
|
||||
const kr = (e: string) => FIVE_ELEMENTS_KR[e as keyof typeof FIVE_ELEMENTS_KR] || e;
|
||||
|
||||
if (strength.result === '신강') {
|
||||
const candidates = [
|
||||
{ elem: myProduct, score: balance[myProduct as keyof ElementBalance], name: '식상' },
|
||||
{ elem: myOvercome, score: balance[myOvercome as keyof ElementBalance], name: '재성' },
|
||||
{ elem: overcomeMe, score: balance[overcomeMe as keyof ElementBalance], name: '관살' },
|
||||
];
|
||||
candidates.sort((a, b) => a.score - b.score);
|
||||
const yong = candidates[0];
|
||||
const hee = candidates[1];
|
||||
|
||||
return {
|
||||
yongShin: yong.elem, yongShinKr: kr(yong.elem),
|
||||
heeShin: hee.elem, heeShinKr: kr(hee.elem),
|
||||
giShin: dayElement, giShinKr: kr(dayElement),
|
||||
explanation: `신강한 사주로 일간의 힘이 넘치므로 ${yong.name}(${kr(yong.elem)}) 기운을 용신으로 삼아 기운을 설기(泄氣)하거나 제어해야 합니다. ${hee.name}(${kr(hee.elem)})이 희신으로 보조합니다.`,
|
||||
};
|
||||
} else if (strength.result === '신약') {
|
||||
const candidates = [
|
||||
{ elem: producingMe, score: balance[producingMe as keyof ElementBalance], name: '인성' },
|
||||
{ elem: dayElement, score: balance[dayElement as keyof ElementBalance], name: '비겁' },
|
||||
];
|
||||
candidates.sort((a, b) => a.score - b.score);
|
||||
const yong = candidates[0];
|
||||
const hee = candidates[1];
|
||||
|
||||
return {
|
||||
yongShin: yong.elem, yongShinKr: kr(yong.elem),
|
||||
heeShin: hee.elem, heeShinKr: kr(hee.elem),
|
||||
giShin: overcomeMe, giShinKr: kr(overcomeMe),
|
||||
explanation: `신약한 사주로 일간의 힘이 부족하므로 ${yong.name}(${kr(yong.elem)}) 기운을 용신으로 삼아 일간을 돕고 힘을 보충해야 합니다. ${hee.name}(${kr(hee.elem)})이 희신으로 보조합니다.`,
|
||||
};
|
||||
} else {
|
||||
const entries = Object.entries(balance) as [string, number][];
|
||||
entries.sort((a, b) => a[1] - b[1]);
|
||||
const yong = entries[0];
|
||||
const hee = entries[1];
|
||||
const gi = entries[entries.length - 1];
|
||||
|
||||
return {
|
||||
yongShin: yong[0], yongShinKr: kr(yong[0]),
|
||||
heeShin: hee[0], heeShinKr: kr(hee[0]),
|
||||
giShin: gi[0], giShinKr: kr(gi[0]),
|
||||
explanation: `중화에 가까운 사주로 오행이 비교적 균형을 이루고 있습니다. 가장 부족한 ${kr(yong[0])}(${yong[0]}) 기운을 보충하면 더욱 좋아집니다.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 세운 (歲運) 계산
|
||||
// ============================================================
|
||||
|
||||
export interface SeunInfo {
|
||||
stem: string;
|
||||
branch: string;
|
||||
stemKr: string;
|
||||
branchKr: string;
|
||||
element: string;
|
||||
elementKr: string;
|
||||
year: number;
|
||||
interactions: BranchInteraction[];
|
||||
}
|
||||
|
||||
export function calculateSeun(year: number, saju: SajuData): SeunInfo {
|
||||
const ganzi = getYearGanzi(year);
|
||||
const element = FIVE_ELEMENTS[ganzi.stem as keyof typeof FIVE_ELEMENTS];
|
||||
|
||||
const seunBranch = ganzi.branch;
|
||||
const seunBranchKr = ganzi.branchKr;
|
||||
|
||||
const interactions: BranchInteraction[] = [];
|
||||
const pillarBranches = [
|
||||
{ branch: saju.year.branch, branchKr: saju.year.branchKr, pillar: '년주' },
|
||||
{ branch: saju.month.branch, branchKr: saju.month.branchKr, pillar: '월주' },
|
||||
{ branch: saju.day.branch, branchKr: saju.day.branchKr, pillar: '일주' },
|
||||
];
|
||||
if (saju.hour) {
|
||||
pillarBranches.push({ branch: saju.hour.branch, branchKr: saju.hour.branchKr, pillar: '시주' });
|
||||
}
|
||||
|
||||
const CHUNG: [string, string][] = [
|
||||
['子', '午'], ['丑', '未'], ['寅', '申'], ['卯', '酉'], ['辰', '戌'], ['巳', '亥'],
|
||||
];
|
||||
for (const [a, b] of CHUNG) {
|
||||
for (const pb of pillarBranches) {
|
||||
if ((seunBranch === a && pb.branch === b) || (seunBranch === b && pb.branch === a)) {
|
||||
interactions.push({
|
||||
type: '충(沖)', branches: [seunBranch, pb.branch],
|
||||
branchesKr: [seunBranchKr, pb.branchKr],
|
||||
pillars: ['세운', pb.pillar],
|
||||
description: `세운 ${seunBranchKr}와 ${pb.pillar} ${pb.branchKr}가 충 → 해당 영역에 변동과 변화가 예상됨.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const YUKAP: [string, string, string][] = [
|
||||
['子', '丑', '土'], ['寅', '亥', '木'], ['卯', '戌', '火'],
|
||||
['辰', '酉', '金'], ['巳', '申', '水'], ['午', '未', '火'],
|
||||
];
|
||||
for (const [a, b, elem] of YUKAP) {
|
||||
for (const pb of pillarBranches) {
|
||||
if ((seunBranch === a && pb.branch === b) || (seunBranch === b && pb.branch === a)) {
|
||||
interactions.push({
|
||||
type: '합(合)', branches: [seunBranch, pb.branch],
|
||||
branchesKr: [seunBranchKr, pb.branchKr],
|
||||
pillars: ['세운', pb.pillar],
|
||||
description: `세운 ${seunBranchKr}와 ${pb.pillar} ${pb.branchKr}가 합 → 해당 영역에 조화와 좋은 인연이 기대됨.`,
|
||||
resultElement: elem,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
stem: ganzi.stem, branch: ganzi.branch,
|
||||
stemKr: ganzi.stemKr, branchKr: ganzi.branchKr,
|
||||
element, elementKr: FIVE_ELEMENTS_KR[element as keyof typeof FIVE_ELEMENTS_KR],
|
||||
year, interactions,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 종합 분석 데이터 구조체
|
||||
// ============================================================
|
||||
|
||||
export interface SajuAnalysis {
|
||||
elementBalance: ElementBalance;
|
||||
elementScores: { [key: string]: number };
|
||||
dayMasterStrength: DayMasterStrength;
|
||||
yongShin: YongShinResult;
|
||||
branchInteractions: BranchInteraction[];
|
||||
shinsal: Shinsal[];
|
||||
gongmang: { branches: string[]; branchesKr: string[]; description: string };
|
||||
seun: SeunInfo;
|
||||
hiddenStems: { pillar: string; branch: string; branchKr: string; stems: { stem: string; stemKr: string; element: string; role: string }[] }[];
|
||||
}
|
||||
|
||||
export function performFullAnalysis(saju: SajuData, currentYear: number = new Date().getFullYear()): SajuAnalysis {
|
||||
const elementBalance = calculateDetailedElementBalance(saju);
|
||||
const elementScores = calculateElementScore(saju);
|
||||
const dayMasterStrength = analyzeDayMasterStrength(saju);
|
||||
const yongShin = estimateYongShin(saju, dayMasterStrength);
|
||||
const branchInteractions = analyzeBranchInteractions(saju);
|
||||
const shinsal = calculateShinsal(saju);
|
||||
const gongmang = calculateGongmang(saju.dayStem, saju.day.branch);
|
||||
const seun = calculateSeun(currentYear, saju);
|
||||
const hiddenStems = getAllHiddenStems(saju);
|
||||
|
||||
return {
|
||||
elementBalance, elementScores, dayMasterStrength, yongShin,
|
||||
branchInteractions, shinsal, gongmang, seun, hiddenStems,
|
||||
};
|
||||
}
|
||||
|
||||
43
lib/ensure-profile.ts
Normal file
43
lib/ensure-profile.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* 유저의 profiles 행이 존재하는지 확인하고, 없으면 생성한다.
|
||||
* handle_new_user 트리거가 실패했거나 트리거 설정 전에 가입한 유저를 위한 안전장치.
|
||||
*/
|
||||
export async function ensureProfile(supabase: any, user: any): Promise<number> {
|
||||
// 1. 프로필 조회
|
||||
const { data: profile, error: selectError } = await supabase
|
||||
.from('profiles')
|
||||
.select('credits')
|
||||
.eq('id', user.id)
|
||||
.single();
|
||||
|
||||
if (profile) {
|
||||
return profile.credits || 0;
|
||||
}
|
||||
|
||||
// 2. 프로필이 없으면 생성
|
||||
console.log('프로필이 없어서 자동 생성합니다:', user.id);
|
||||
const { error: insertError } = await supabase
|
||||
.from('profiles')
|
||||
.insert({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
full_name: user.user_metadata?.full_name || null,
|
||||
avatar_url: user.user_metadata?.avatar_url || null,
|
||||
credits: 0,
|
||||
});
|
||||
|
||||
if (insertError) {
|
||||
// 동시 생성 경쟁 시 중복 에러 무시하고 다시 조회
|
||||
if (insertError.code === '23505') {
|
||||
const { data: retryProfile } = await supabase
|
||||
.from('profiles')
|
||||
.select('credits')
|
||||
.eq('id', user.id)
|
||||
.single();
|
||||
return retryProfile?.credits || 0;
|
||||
}
|
||||
console.error('프로필 생성 실패:', insertError);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
@@ -1,10 +1,78 @@
|
||||
import jsPDF from 'jspdf';
|
||||
import html2canvas from 'html2canvas';
|
||||
|
||||
/**
|
||||
* html2canvas가 지원하지 않는 CSS 색상 함수(lab, oklch, oklab)를
|
||||
* 클론된 문서에서 안전한 값으로 치환한다.
|
||||
*
|
||||
* 접근 방식:
|
||||
* 1) <style> 태그의 텍스트에서 직접 regex 치환 (가장 확실)
|
||||
* 2) CSSStyleSheet.cssRules에서 문제 속성 제거 (외부 스타일시트 대응)
|
||||
* 3) 개별 요소의 인라인 style 정리
|
||||
*/
|
||||
function sanitizeUnsupportedColors(clonedDoc: Document) {
|
||||
const unsafeRe = /(?:oklch|oklab|lab)\([^)]*\)/gi;
|
||||
|
||||
// 1단계: 모든 <style> 태그 텍스트에서 lab()/oklch()/oklab() → 안전한 색상으로 치환
|
||||
const styleTags = clonedDoc.querySelectorAll('style');
|
||||
styleTags.forEach((tag) => {
|
||||
const text = tag.textContent;
|
||||
if (text && unsafeRe.test(text)) {
|
||||
tag.textContent = text.replace(unsafeRe, 'rgba(128,128,128,1)');
|
||||
}
|
||||
// reset lastIndex because we reuse the regex
|
||||
unsafeRe.lastIndex = 0;
|
||||
});
|
||||
|
||||
// 2단계: <link> 등 외부 스타일시트의 cssRules에서 문제 속성 제거
|
||||
try {
|
||||
for (const sheet of Array.from(clonedDoc.styleSheets)) {
|
||||
try {
|
||||
// <style> 태그는 이미 1단계에서 처리했으므로 skip
|
||||
if (sheet.ownerNode && (sheet.ownerNode as HTMLElement).tagName === 'STYLE') continue;
|
||||
|
||||
const rules = sheet.cssRules;
|
||||
if (!rules) continue;
|
||||
for (let i = rules.length - 1; i >= 0; i--) {
|
||||
const rule = rules[i];
|
||||
if (rule instanceof CSSStyleRule) {
|
||||
for (let j = rule.style.length - 1; j >= 0; j--) {
|
||||
const prop = rule.style.item(j);
|
||||
const val = rule.style.getPropertyValue(prop);
|
||||
unsafeRe.lastIndex = 0;
|
||||
if (unsafeRe.test(val)) {
|
||||
rule.style.removeProperty(prop);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 크로스 오리진 스타일시트는 접근 불가 - 무시
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// styleSheets 접근 실패 시 무시
|
||||
}
|
||||
|
||||
// 3단계: 인라인 style 속성에 남아있는 lab() 정리
|
||||
const allElements = clonedDoc.querySelectorAll('*');
|
||||
allElements.forEach((el) => {
|
||||
const htmlEl = el as HTMLElement;
|
||||
const inlineStyle = htmlEl.getAttribute('style');
|
||||
if (inlineStyle) {
|
||||
unsafeRe.lastIndex = 0;
|
||||
if (unsafeRe.test(inlineStyle)) {
|
||||
htmlEl.setAttribute(
|
||||
'style',
|
||||
inlineStyle.replace(unsafeRe, 'rgba(128,128,128,1)')
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML 요소를 PDF로 변환하여 다운로드
|
||||
* @param elementId - PDF로 변환할 HTML 요소의 ID
|
||||
* @param filename - 다운로드될 PDF 파일명
|
||||
*/
|
||||
export async function downloadPDF(elementId: string, filename: string) {
|
||||
try {
|
||||
@@ -13,38 +81,33 @@ export async function downloadPDF(elementId: string, filename: string) {
|
||||
throw new Error(`Element with id "${elementId}" not found`);
|
||||
}
|
||||
|
||||
// 로딩 표시
|
||||
const originalContent = element.innerHTML;
|
||||
|
||||
// HTML을 캔버스로 변환
|
||||
const canvas = await html2canvas(element, {
|
||||
scale: 2, // 해상도 향상
|
||||
scale: 2,
|
||||
useCORS: true,
|
||||
logging: false,
|
||||
backgroundColor: '#ffffff'
|
||||
backgroundColor: '#ffffff',
|
||||
onclone: (_doc: Document, clonedEl: HTMLElement) => {
|
||||
sanitizeUnsupportedColors(clonedEl.ownerDocument);
|
||||
}
|
||||
});
|
||||
|
||||
// 캔버스를 이미지로 변환
|
||||
const imgData = canvas.toDataURL('image/png');
|
||||
|
||||
// PDF 생성
|
||||
const pdf = new jsPDF({
|
||||
orientation: 'portrait',
|
||||
unit: 'mm',
|
||||
format: 'a4'
|
||||
});
|
||||
|
||||
const imgWidth = 210; // A4 width in mm
|
||||
const pageHeight = 297; // A4 height in mm
|
||||
const imgWidth = 210;
|
||||
const pageHeight = 297;
|
||||
const imgHeight = (canvas.height * imgWidth) / canvas.width;
|
||||
let heightLeft = imgHeight;
|
||||
let position = 0;
|
||||
|
||||
// 첫 페이지 추가
|
||||
pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight);
|
||||
heightLeft -= pageHeight;
|
||||
|
||||
// 여러 페이지가 필요한 경우
|
||||
while (heightLeft > 0) {
|
||||
position = heightLeft - imgHeight;
|
||||
pdf.addPage();
|
||||
@@ -52,9 +115,7 @@ export async function downloadPDF(elementId: string, filename: string) {
|
||||
heightLeft -= pageHeight;
|
||||
}
|
||||
|
||||
// PDF 다운로드
|
||||
pdf.save(filename);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('PDF 생성 중 오류 발생:', error);
|
||||
@@ -63,10 +124,6 @@ export async function downloadPDF(elementId: string, filename: string) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 페이지를 PDF로 다운로드
|
||||
* @param filename - 다운로드될 PDF 파일명
|
||||
*/
|
||||
export async function downloadCurrentPageAsPDF(filename: string) {
|
||||
return downloadPDF('pdf-content', filename);
|
||||
}
|
||||
|
||||
223
lib/saju-ai-prompt.ts
Normal file
223
lib/saju-ai-prompt.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
|
||||
import { SajuData, FIVE_ELEMENTS, FIVE_ELEMENTS_KR, HEAVENLY_STEMS_KR } from './saju-calculator';
|
||||
import { DaeunPillar } from './daeun-calculator';
|
||||
import { SajuAnalysis } from './ai-interpretation';
|
||||
|
||||
export function createSajuPrompt(
|
||||
saju: SajuData,
|
||||
currentDaeun: DaeunPillar | null,
|
||||
gender: 'male' | 'female',
|
||||
analysis: SajuAnalysis,
|
||||
daeunList: DaeunPillar[] = []
|
||||
): string {
|
||||
const genderStr = gender === 'male' ? '남성' : '여성';
|
||||
const birthDate = `${saju.birthDate.year}년 ${saju.birthDate.month}월 ${saju.birthDate.day}일 ${saju.birthDate.hour ? saju.birthDate.hour + '시' : '시간 모름'}`;
|
||||
const dayStemKr = saju.day.stemKr;
|
||||
const dayElement = FIVE_ELEMENTS[saju.dayStem as keyof typeof FIVE_ELEMENTS];
|
||||
const dayElementKr = FIVE_ELEMENTS_KR[dayElement as keyof typeof FIVE_ELEMENTS_KR];
|
||||
|
||||
// ── 사주 원국 ──
|
||||
const pillars = [
|
||||
`년주: ${saju.year.stem}${saju.year.branch} (${saju.year.stemKr}${saju.year.branchKr}) | 천간십성: ${saju.year.tenGod} | 십이운성: ${saju.year.fortune}`,
|
||||
`월주: ${saju.month.stem}${saju.month.branch} (${saju.month.stemKr}${saju.month.branchKr}) | 천간십성: ${saju.month.tenGod} | 십이운성: ${saju.month.fortune}`,
|
||||
`일주: ${saju.day.stem}${saju.day.branch} (${saju.day.stemKr}${saju.day.branchKr}) | 일간(日干) | 십이운성: ${saju.day.fortune}`,
|
||||
saju.hour
|
||||
? `시주: ${saju.hour.stem}${saju.hour.branch} (${saju.hour.stemKr}${saju.hour.branchKr}) | 천간십성: ${saju.hour.tenGod} | 십이운성: ${saju.hour.fortune}`
|
||||
: '시주: 정보 없음',
|
||||
].join('\n');
|
||||
|
||||
// ── 지장간 ──
|
||||
const hiddenStemsStr = analysis.hiddenStems.map(h => {
|
||||
const stemsDetail = h.stems.map(s => `${s.stemKr}(${s.stem}, ${FIVE_ELEMENTS_KR[s.element as keyof typeof FIVE_ELEMENTS_KR]}, ${s.role})`).join(', ');
|
||||
return `${h.pillar} ${h.branchKr}(${h.branch}): [${stemsDetail}]`;
|
||||
}).join('\n');
|
||||
|
||||
// ── 오행 분석 ──
|
||||
const eb = analysis.elementBalance;
|
||||
const es = analysis.elementScores;
|
||||
const elementStr = Object.entries(eb).map(([k, v]) => {
|
||||
return `${FIVE_ELEMENTS_KR[k as keyof typeof FIVE_ELEMENTS_KR]}(${k}): ${v}점 (${es[k]}%)`;
|
||||
}).join(' | ');
|
||||
|
||||
// ── 신강/신약 ──
|
||||
const strength = analysis.dayMasterStrength;
|
||||
const strengthStr = `판정: ${strength.result} (점수: ${strength.score})\n근거:\n${strength.reasons.map(r => `- ${r}`).join('\n')}`;
|
||||
|
||||
// ── 용신/희신/기신 ──
|
||||
const ys = analysis.yongShin;
|
||||
const yongShinStr = `용신: ${ys.yongShinKr}(${ys.yongShin}) | 희신: ${ys.heeShinKr}(${ys.heeShin}) | 기신: ${ys.giShinKr}(${ys.giShin})\n설명: ${ys.explanation}`;
|
||||
|
||||
// ── 지지 상호작용 ──
|
||||
const interactionsStr = analysis.branchInteractions.length > 0
|
||||
? analysis.branchInteractions.map(i => `- ${i.type}: ${i.branchesKr.join('')} (${i.pillars.join('↔')}) → ${i.description}`).join('\n')
|
||||
: '- 특별한 합/충/형/파/해 없음';
|
||||
|
||||
// ── 신살 ──
|
||||
const shinsalStr = analysis.shinsal.length > 0
|
||||
? analysis.shinsal.map(s => `- ${s.name}(${s.nameHanja}): ${s.pillar} ${s.branchKr}(${s.branch}) → ${s.description}`).join('\n')
|
||||
: '- 특별한 신살 없음';
|
||||
|
||||
// ── 공망 ──
|
||||
const gongmangStr = analysis.gongmang.description;
|
||||
|
||||
// ── 세운 ──
|
||||
const seun = analysis.seun;
|
||||
const seunStr = `${seun.year}년 ${seun.stemKr}${seun.branchKr}(${seun.stem}${seun.branch})년 | 오행: ${seun.elementKr}(${seun.element})`;
|
||||
const seunInteractions = seun.interactions.length > 0
|
||||
? seun.interactions.map(i => `- ${i.type}: ${i.description}`).join('\n')
|
||||
: '- 세운과 원국 사이에 특별한 충/합 없음';
|
||||
|
||||
// ── 대운 ──
|
||||
const daeunInfo = currentDaeun
|
||||
? `현재 대운: ${currentDaeun.stemKr}${currentDaeun.branchKr}(${currentDaeun.stem}${currentDaeun.branch}) 대운 | ${currentDaeun.age}세~${currentDaeun.age + 9}세 (${currentDaeun.startYear}~${currentDaeun.endYear}년)`
|
||||
: '현재 대운 정보 없음';
|
||||
|
||||
const allDaeunStr = daeunList.length > 0
|
||||
? daeunList.map(d => `${d.stemKr}${d.branchKr}(${d.age}세~${d.age + 9}세, ${d.startYear}~${d.endYear}년)`).join(' → ')
|
||||
: '';
|
||||
|
||||
const systemPrompt = `당신은 따뜻하고 유머러스한 사주 상담사예요. 마치 오랜 친구처럼 편하게, 하지만 놀라울 정도로 정확하게 사주를 읽어주는 사람이에요. 딱딱한 전문 용어 대신 비유와 이야기로 풀어내는 게 당신의 스타일이에요.
|
||||
|
||||
[핵심 원칙 - 반드시 지켜주세요]
|
||||
- 아래 제공된 계산 데이터를 바탕으로 해석하되, 전문 용어는 최소화하고 비유와 스토리텔링으로 풀어주세요.
|
||||
- "~요" 체의 친근한 말투를 사용하세요. (예: "~이에요", "~거든요", "~잖아요", "~인 거죠")
|
||||
- 각 섹션 제목은 창의적인 비유나 은유를 사용한 감성적 제목으로 만드세요. (예: "얼음 속에 숨겨진 불꽃", "당신 안의 숨은 보석")
|
||||
- 사주 데이터에 근거하되, "당신은 마치 ~같은 사람이에요"처럼 생생한 비유로 설명하세요.
|
||||
- 때로는 따끔한 조언도 섞어주세요. 친구가 해주는 솔직한 충고처럼요. (예: "솔직히 말하면... 그거 완벽주의 아니고 그냥 겁이 많은 거예요 😅")
|
||||
- 각 항목 최소 5~8문장으로 깊이 있게, 하지만 술술 읽히게 작성하세요.
|
||||
- 이 사람만을 위한 개인화된 분석이어야 해요. 일반론 절대 금지!
|
||||
- 중간중간 공감 포인트를 넣어주세요. (예: "혹시 이런 경험 있지 않나요?", "맞죠?")
|
||||
- 마지막에 진심 어린 응원 한마디를 꼭 넣어주세요.
|
||||
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
[사용자 정보]
|
||||
- 성별: ${genderStr}
|
||||
- 생년월일시: ${birthDate}
|
||||
- 일간: ${dayStemKr}(${saju.dayStem}) → ${dayElementKr}(${dayElement})
|
||||
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
[사주 원국]
|
||||
${pillars}
|
||||
|
||||
[지장간]
|
||||
${hiddenStemsStr}
|
||||
|
||||
[오행 점수 (가중치 적용)]
|
||||
${elementStr}
|
||||
총점: ${Object.values(eb).reduce((a, b) => a + b, 0).toFixed(1)}점
|
||||
|
||||
[신강/신약]
|
||||
${strengthStr}
|
||||
|
||||
[용신/희신/기신]
|
||||
${yongShinStr}
|
||||
|
||||
[지지 상호작용]
|
||||
${interactionsStr}
|
||||
|
||||
[신살]
|
||||
${shinsalStr}
|
||||
|
||||
[공망]
|
||||
${gongmangStr}
|
||||
|
||||
[대운]
|
||||
${daeunInfo}
|
||||
전체 흐름: ${allDaeunStr}
|
||||
|
||||
[세운 - 올해]
|
||||
${seunStr}
|
||||
${seunInteractions}
|
||||
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
[분석 요구사항 - 12개 항목]
|
||||
|
||||
위 데이터를 바탕으로 아래 12개 항목을 작성하세요.
|
||||
각 항목은 반드시 "## " 로 시작하는 헤더를 사용하세요.
|
||||
헤더 제목은 번호 + 창의적인 비유/은유 제목으로 만드세요. (아래는 예시일 뿐, 사주 내용에 맞게 자유롭게 창작하세요)
|
||||
|
||||
## 1. [타고난 기질 - 창의적 제목]
|
||||
예시 제목: "차가운 호수 아래 숨겨진 용의 심장" / "봄바람처럼 자유로운 영혼"
|
||||
- ${dayStemKr}${saju.day.branchKr}일주의 핵심 성격을 비유로 풀어주세요
|
||||
- "당신은 마치 ~같은 사람이에요" 패턴 활용
|
||||
- 겉으로 보이는 모습 vs 진짜 내면을 대비시켜 흥미롭게
|
||||
- 강점은 확 칭찬하고, 약점은 "솔직히 말하면..." 패턴으로 따끔하지만 사랑스럽게
|
||||
|
||||
## 2. [오행 밸런스 & 개운법 - 창의적 제목]
|
||||
예시 제목: "당신에게 부족한 한 조각, 그걸 채우는 법" / "운을 끌어당기는 나만의 비밀 무기"
|
||||
- 오행 데이터를 인용하되, "당신의 에너지 밸런스를 보면..." 식으로 쉽게
|
||||
- 용신(${ys.yongShinKr}) 기운을 강화하는 실생활 팁: 색상, 방향, 숫자, 음식, 행동
|
||||
- 기신(${ys.giShinKr}) 기운 피하는 법도 구체적으로
|
||||
- "오늘부터 당장 ~해보세요!" 같은 실천 가능한 조언
|
||||
|
||||
## 3. [지지 상호작용 - 창의적 제목]
|
||||
예시 제목: "당신 안에서 벌어지는 보이지 않는 전쟁" / "운명이 엮어준 특별한 인연의 실타래"
|
||||
- 합/충/형 데이터를 바탕으로 실생활 영향을 이야기로 풀어주세요
|
||||
- 어려운 용어 대신 "쉽게 말하면..." 패턴 활용
|
||||
|
||||
## 4. [신살의 영향 - 창의적 제목]
|
||||
예시 제목: "당신이 타고난 숨겨진 초능력" / "조심해야 할 함정, 그리고 날개"
|
||||
- 각 신살을 흥미로운 비유로 설명 (역마살 → "여행자의 별", 도화살 → "매력의 별" 등)
|
||||
- 긍정 신살은 신나게, 주의 신살은 걱정 말라는 톤으로
|
||||
|
||||
## 5. [재물운 - 창의적 제목]
|
||||
예시 제목: "돈이 당신을 찾아오는 방식" / "통장이 웃는 시기, 우는 시기"
|
||||
- 편재/정재 위치와 강도를 쉬운 비유로
|
||||
- 돈 버는 스타일 (한방 vs 꾸준히 vs 투자형 등)
|
||||
- 주의할 시기와 기회의 시기를 구체적으로
|
||||
|
||||
## 6. [직업 적성 - 창의적 제목]
|
||||
예시 제목: "당신이 빛나는 무대는 따로 있어요" / "타고난 프로의 DNA"
|
||||
- 적합한 분야를 구체적으로 추천 (추상적 말고 직업명까지)
|
||||
- 조직형 vs 프리랜서/사업형 판단
|
||||
- ${genderStr}의 특성 고려
|
||||
|
||||
## 7. [애정운 - 창의적 제목]
|
||||
예시 제목: "사랑이 찾아오는 계절" / "당신의 이상형, 사주가 말해주는 진짜 궁합"
|
||||
- ${genderStr === '남성' ? '재성' : '관성'} 기반 배우자 복 분석을 로맨틱하게
|
||||
- 연애 스타일, 배우자 상을 재미있게 묘사
|
||||
- 결혼 적령기를 부드럽게 안내
|
||||
|
||||
## 8. [건강운 - 창의적 제목]
|
||||
예시 제목: "몸이 보내는 작은 신호들" / "100세까지 건강한 나를 위한 처방전"
|
||||
- 오행 과부족 → 주의할 건강 포인트를 걱정 안 되게 부드럽게
|
||||
- 구체적인 생활 습관 조언 (음식, 운동, 스트레스 관리)
|
||||
|
||||
## 9. [현재 대운 - 창의적 제목]
|
||||
예시 제목: "지금 당신 앞에 펼쳐진 10년의 지도" / "인생의 봄이 오고 있어요"
|
||||
- ${daeunInfo}를 바탕으로 현재 10년의 의미를 이야기로
|
||||
- 지금 집중해야 할 것, 조심할 것을 친구처럼 조언
|
||||
|
||||
## 10. [올해의 운세 - 창의적 제목] (${seun.year}년)
|
||||
예시 제목: "올해, 당신에게 찾아올 세 가지 기회" / "${seun.year}년은 당신의 해예요"
|
||||
- 세운 데이터 바탕으로 올해 키워드를 뽑아 설명
|
||||
- 상반기 vs 하반기 흐름
|
||||
- "이것만은 꼭!" 하는 핵심 조언
|
||||
|
||||
## 11. [인생의 황금기 - 창의적 제목]
|
||||
예시 제목: "인생에서 가장 빛나는 순간이 다가오고 있어요" / "대박 터지는 그 시기"
|
||||
- 전체 대운 흐름에서 최고의 시기를 콕 집어서
|
||||
- 그 시기에 어떤 기회가 오는지 구체적이고 설레게
|
||||
- "그때를 위해 지금 준비할 것" 조언
|
||||
|
||||
## 12. [종합 조언 - 창의적 제목]
|
||||
예시 제목: "당신이라는 별에게 보내는 편지" / "마지막으로 꼭 전하고 싶은 말"
|
||||
- 이 사주의 핵심 강점과 약점을 한 문장으로 요약
|
||||
- 용신(${ys.yongShinKr}) 활용 일상 팁
|
||||
- 진심 어린 응원과 철학적 메시지로 마무리
|
||||
- 마지막 문장은 감동적인 한 줄로 끝내주세요
|
||||
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
[톤앤매너 - 가장 중요!!]
|
||||
- "~요" 체 친근한 말투 (절대 "~이다/한다" 체 사용 금지)
|
||||
- 전문 용어 최소화. 꼭 필요하면 비유로 풀어서 설명
|
||||
- 비유와 은유를 적극 활용 ("마치 ~처럼", "당신은 ~같은 사람이에요")
|
||||
- 중간중간 이모지를 자연스럽게 사용 (과하지 않게, 섹션당 1~2개)
|
||||
- 따끔한 조언 + 따뜻한 응원의 밸런스
|
||||
- "혹시 ~한 적 있지 않나요?" 같은 공감형 질문으로 몰입감 유도
|
||||
- Markdown 형식: ## 헤더, **볼드**, 리스트 활용
|
||||
- 각 섹션 제목은 반드시 번호 포함 (## 1. ~ ## 12.)
|
||||
- 읽는 사람이 "와, 이거 진짜 내 얘기다!" 하고 느끼게 만들어주세요`;
|
||||
|
||||
return systemPrompt;
|
||||
}
|
||||
@@ -236,13 +236,13 @@ export function calculateSaju(
|
||||
year: {
|
||||
...yearGanzi,
|
||||
element: FIVE_ELEMENTS[yearGanzi.stem as keyof typeof FIVE_ELEMENTS],
|
||||
tenGod: getTenGod(dayStem, yearGanzi.stem, HEAVENLY_STEMS.indexOf(yearGanzi.stem as any) % 2 === isDayYang ? true : false),
|
||||
tenGod: getTenGod(dayStem, yearGanzi.stem, (HEAVENLY_STEMS.indexOf(yearGanzi.stem as any) % 2 === 0) === isDayYang),
|
||||
fortune: getTwelveFortune(dayStem, yearGanzi.branch)
|
||||
},
|
||||
month: {
|
||||
...monthGanzi,
|
||||
element: FIVE_ELEMENTS[monthGanzi.stem as keyof typeof FIVE_ELEMENTS],
|
||||
tenGod: getTenGod(dayStem, monthGanzi.stem, HEAVENLY_STEMS.indexOf(monthGanzi.stem as any) % 2 === isDayYang ? true : false),
|
||||
tenGod: getTenGod(dayStem, monthGanzi.stem, (HEAVENLY_STEMS.indexOf(monthGanzi.stem as any) % 2 === 0) === isDayYang),
|
||||
fortune: getTwelveFortune(dayStem, monthGanzi.branch)
|
||||
},
|
||||
day: {
|
||||
@@ -260,10 +260,465 @@ export function calculateSaju(
|
||||
result.hour = {
|
||||
...hourGanzi,
|
||||
element: FIVE_ELEMENTS[hourGanzi.stem as keyof typeof FIVE_ELEMENTS],
|
||||
tenGod: getTenGod(dayStem, hourGanzi.stem, HEAVENLY_STEMS.indexOf(hourGanzi.stem as any) % 2 === isDayYang ? true : false),
|
||||
tenGod: getTenGod(dayStem, hourGanzi.stem, (HEAVENLY_STEMS.indexOf(hourGanzi.stem as any) % 2 === 0) === isDayYang),
|
||||
fortune: getTwelveFortune(dayStem, hourGanzi.branch)
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 지장간 (藏干) - 각 지지에 숨어있는 천간
|
||||
// ============================================================
|
||||
export const HIDDEN_STEMS: { [key: string]: string[] } = {
|
||||
'子': ['癸'],
|
||||
'丑': ['己', '癸', '辛'],
|
||||
'寅': ['甲', '丙', '戊'],
|
||||
'卯': ['乙'],
|
||||
'辰': ['戊', '乙', '癸'],
|
||||
'巳': ['丙', '庚', '戊'],
|
||||
'午': ['丁', '己'],
|
||||
'未': ['己', '丁', '乙'],
|
||||
'申': ['庚', '壬', '戊'],
|
||||
'酉': ['辛'],
|
||||
'戌': ['戊', '辛', '丁'],
|
||||
'亥': ['壬', '甲'],
|
||||
};
|
||||
|
||||
/**
|
||||
* 지지의 지장간(숨은 천간) 반환
|
||||
*/
|
||||
export function getHiddenStems(branch: string): string[] {
|
||||
return HIDDEN_STEMS[branch] || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 4주 전체의 지장간 정보 반환
|
||||
*/
|
||||
export function getAllHiddenStems(saju: SajuData): { pillar: string; branch: string; branchKr: string; stems: { stem: string; stemKr: string; element: string; role: string }[] }[] {
|
||||
const pillars = [
|
||||
{ pillar: '년주', branch: saju.year.branch, branchKr: saju.year.branchKr },
|
||||
{ pillar: '월주', branch: saju.month.branch, branchKr: saju.month.branchKr },
|
||||
{ pillar: '일주', branch: saju.day.branch, branchKr: saju.day.branchKr },
|
||||
];
|
||||
if (saju.hour) {
|
||||
pillars.push({ pillar: '시주', branch: saju.hour.branch, branchKr: saju.hour.branchKr });
|
||||
}
|
||||
|
||||
return pillars.map(p => {
|
||||
const hidden = getHiddenStems(p.branch);
|
||||
return {
|
||||
...p,
|
||||
stems: hidden.map((stem, idx) => {
|
||||
const stemIndex = HEAVENLY_STEMS.indexOf(stem as any);
|
||||
const role = idx === 0 ? '정기(본기)' : idx === 1 ? '중기' : '여기';
|
||||
return {
|
||||
stem,
|
||||
stemKr: HEAVENLY_STEMS_KR[stemIndex],
|
||||
element: FIVE_ELEMENTS[stem as keyof typeof FIVE_ELEMENTS],
|
||||
role,
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 지지 상호작용 (合/沖/刑/破/害)
|
||||
// ============================================================
|
||||
|
||||
export interface BranchInteraction {
|
||||
type: string; // 육합, 삼합, 방합, 충, 형, 파, 해
|
||||
branches: string[]; // 관련 지지 (한자)
|
||||
branchesKr: string[]; // 관련 지지 (한글)
|
||||
pillars: string[]; // 관련 기둥 (년주, 월주 등)
|
||||
description: string;
|
||||
resultElement?: string; // 합의 결과 오행 (해당 시)
|
||||
}
|
||||
|
||||
const YUKAP_PAIRS: [string, string, string][] = [
|
||||
['子', '丑', '土'], ['寅', '亥', '木'], ['卯', '戌', '火'],
|
||||
['辰', '酉', '金'], ['巳', '申', '水'], ['午', '未', '火'],
|
||||
];
|
||||
|
||||
const SAMHAP_GROUPS: [string, string, string, string][] = [
|
||||
['申', '子', '辰', '水'], ['亥', '卯', '未', '木'],
|
||||
['寅', '午', '戌', '火'], ['巳', '酉', '丑', '金'],
|
||||
];
|
||||
|
||||
const BANGHAP_GROUPS: [string, string, string, string][] = [
|
||||
['寅', '卯', '辰', '木'], ['巳', '午', '未', '火'],
|
||||
['申', '酉', '戌', '金'], ['亥', '子', '丑', '水'],
|
||||
];
|
||||
|
||||
const CHUNG_PAIRS: [string, string][] = [
|
||||
['子', '午'], ['丑', '未'], ['寅', '申'],
|
||||
['卯', '酉'], ['辰', '戌'], ['巳', '亥'],
|
||||
];
|
||||
|
||||
const HYUNG_GROUPS: { branches: string[]; name: string }[] = [
|
||||
{ branches: ['寅', '巳', '申'], name: '무은지형(無恩之刑)' },
|
||||
{ branches: ['丑', '戌', '未'], name: '지세지형(恃勢之刑)' },
|
||||
{ branches: ['子', '卯'], name: '무례지형(無禮之刑)' },
|
||||
];
|
||||
const JAHYUNG_BRANCHES = ['辰', '午', '酉', '亥'];
|
||||
|
||||
const PA_PAIRS: [string, string][] = [
|
||||
['子', '酉'], ['丑', '辰'], ['寅', '亥'],
|
||||
['卯', '午'], ['巳', '申'], ['未', '戌'],
|
||||
];
|
||||
|
||||
const HAE_PAIRS: [string, string][] = [
|
||||
['子', '未'], ['丑', '午'], ['寅', '巳'],
|
||||
['卯', '辰'], ['申', '亥'], ['酉', '戌'],
|
||||
];
|
||||
|
||||
const ELEMENT_NAMES_KR: { [key: string]: string } = { '木': '목', '火': '화', '土': '토', '金': '금', '水': '수' };
|
||||
|
||||
/**
|
||||
* 지지 상호작용 분석
|
||||
*/
|
||||
export function analyzeBranchInteractions(saju: SajuData): BranchInteraction[] {
|
||||
const interactions: BranchInteraction[] = [];
|
||||
|
||||
// 기둥별 지지 수집
|
||||
const pillarBranches: { branch: string; pillar: string; branchKr: string }[] = [
|
||||
{ branch: saju.year.branch, pillar: '년주', branchKr: saju.year.branchKr },
|
||||
{ branch: saju.month.branch, pillar: '월주', branchKr: saju.month.branchKr },
|
||||
{ branch: saju.day.branch, pillar: '일주', branchKr: saju.day.branchKr },
|
||||
];
|
||||
if (saju.hour) {
|
||||
pillarBranches.push({ branch: saju.hour.branch, pillar: '시주', branchKr: saju.hour.branchKr });
|
||||
}
|
||||
|
||||
const branches = pillarBranches.map(p => p.branch);
|
||||
|
||||
// 육합 (六合) 검사
|
||||
for (const [a, b, elem] of YUKAP_PAIRS) {
|
||||
const idxA = branches.indexOf(a);
|
||||
const idxB = branches.indexOf(b);
|
||||
if (idxA !== -1 && idxB !== -1) {
|
||||
interactions.push({
|
||||
type: '육합(六合)',
|
||||
branches: [a, b],
|
||||
branchesKr: [pillarBranches[idxA].branchKr, pillarBranches[idxB].branchKr],
|
||||
pillars: [pillarBranches[idxA].pillar, pillarBranches[idxB].pillar],
|
||||
description: `${pillarBranches[idxA].branchKr}${pillarBranches[idxB].branchKr} 육합 → ${ELEMENT_NAMES_KR[elem]}(${elem}) 기운 생성. 조화와 화합의 관계.`,
|
||||
resultElement: elem,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 삼합 (三合) 검사
|
||||
for (const [a, b, c, elem] of SAMHAP_GROUPS) {
|
||||
const found = [a, b, c].filter(x => branches.includes(x));
|
||||
if (found.length >= 2) {
|
||||
const foundPillars = found.map(x => {
|
||||
const idx = branches.indexOf(x);
|
||||
return pillarBranches[idx];
|
||||
});
|
||||
const isComplete = found.length === 3;
|
||||
interactions.push({
|
||||
type: isComplete ? '삼합(三合)' : '반삼합(半三合)',
|
||||
branches: found,
|
||||
branchesKr: foundPillars.map(p => p.branchKr),
|
||||
pillars: foundPillars.map(p => p.pillar),
|
||||
description: `${foundPillars.map(p => p.branchKr).join('')} ${isComplete ? '삼합' : '반삼합'} → ${ELEMENT_NAMES_KR[elem]}(${elem})국. ${isComplete ? '강력한 합의 기운.' : '삼합의 기운이 부분적으로 작용.'}`,
|
||||
resultElement: elem,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 방합 (方合) 검사
|
||||
for (const [a, b, c, elem] of BANGHAP_GROUPS) {
|
||||
const found = [a, b, c].filter(x => branches.includes(x));
|
||||
if (found.length === 3) {
|
||||
const foundPillars = found.map(x => {
|
||||
const idx = branches.indexOf(x);
|
||||
return pillarBranches[idx];
|
||||
});
|
||||
interactions.push({
|
||||
type: '방합(方合)',
|
||||
branches: found,
|
||||
branchesKr: foundPillars.map(p => p.branchKr),
|
||||
pillars: foundPillars.map(p => p.pillar),
|
||||
description: `${foundPillars.map(p => p.branchKr).join('')} 방합 → ${ELEMENT_NAMES_KR[elem]}(${elem}) 방국. 매우 강한 오행 기운.`,
|
||||
resultElement: elem,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 충 (沖) 검사
|
||||
for (const [a, b] of CHUNG_PAIRS) {
|
||||
const idxA = branches.indexOf(a);
|
||||
const idxB = branches.indexOf(b);
|
||||
if (idxA !== -1 && idxB !== -1) {
|
||||
interactions.push({
|
||||
type: '충(沖)',
|
||||
branches: [a, b],
|
||||
branchesKr: [pillarBranches[idxA].branchKr, pillarBranches[idxB].branchKr],
|
||||
pillars: [pillarBranches[idxA].pillar, pillarBranches[idxB].pillar],
|
||||
description: `${pillarBranches[idxA].branchKr}${pillarBranches[idxB].branchKr} 충 → 변동, 갈등, 변화의 에너지. ${pillarBranches[idxA].pillar}와 ${pillarBranches[idxB].pillar} 사이의 긴장 관계.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 형 (刑) 검사
|
||||
for (const group of HYUNG_GROUPS) {
|
||||
const found = group.branches.filter(x => branches.includes(x));
|
||||
if (found.length >= 2) {
|
||||
const foundPillars = found.map(x => {
|
||||
const idx = branches.indexOf(x);
|
||||
return pillarBranches[idx];
|
||||
});
|
||||
interactions.push({
|
||||
type: '형(刑)',
|
||||
branches: found,
|
||||
branchesKr: foundPillars.map(p => p.branchKr),
|
||||
pillars: foundPillars.map(p => p.pillar),
|
||||
description: `${foundPillars.map(p => p.branchKr).join('')} ${group.name} → 시련과 갈등의 기운. 주의가 필요한 관계.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 자형 (自刑) 검사
|
||||
for (const jb of JAHYUNG_BRANCHES) {
|
||||
const count = branches.filter(x => x === jb).length;
|
||||
if (count >= 2) {
|
||||
const brKr = EARTHLY_BRANCHES_KR[EARTHLY_BRANCHES.indexOf(jb as any)];
|
||||
interactions.push({
|
||||
type: '자형(自刑)',
|
||||
branches: [jb, jb],
|
||||
branchesKr: [brKr, brKr],
|
||||
pillars: pillarBranches.filter(p => p.branch === jb).map(p => p.pillar),
|
||||
description: `${brKr}${brKr} 자형 → 자기 자신과의 갈등, 내면의 갈등 기운.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 파 (破) 검사
|
||||
for (const [a, b] of PA_PAIRS) {
|
||||
const idxA = branches.indexOf(a);
|
||||
const idxB = branches.indexOf(b);
|
||||
if (idxA !== -1 && idxB !== -1) {
|
||||
interactions.push({
|
||||
type: '파(破)',
|
||||
branches: [a, b],
|
||||
branchesKr: [pillarBranches[idxA].branchKr, pillarBranches[idxB].branchKr],
|
||||
pillars: [pillarBranches[idxA].pillar, pillarBranches[idxB].pillar],
|
||||
description: `${pillarBranches[idxA].branchKr}${pillarBranches[idxB].branchKr} 파 → 관계의 균열, 계획의 차질 가능성.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 해 (害) 검사
|
||||
for (const [a, b] of HAE_PAIRS) {
|
||||
const idxA = branches.indexOf(a);
|
||||
const idxB = branches.indexOf(b);
|
||||
if (idxA !== -1 && idxB !== -1) {
|
||||
interactions.push({
|
||||
type: '해(害)',
|
||||
branches: [a, b],
|
||||
branchesKr: [pillarBranches[idxA].branchKr, pillarBranches[idxB].branchKr],
|
||||
pillars: [pillarBranches[idxA].pillar, pillarBranches[idxB].pillar],
|
||||
description: `${pillarBranches[idxA].branchKr}${pillarBranches[idxB].branchKr} 해 → 은근한 방해, 원망의 기운.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return interactions;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 신살 (神煞) 계산
|
||||
// ============================================================
|
||||
|
||||
export interface Shinsal {
|
||||
name: string;
|
||||
nameHanja: string;
|
||||
branch: string;
|
||||
branchKr: string;
|
||||
pillar: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
// 일지 삼합국 기준 신살 매핑
|
||||
const SAMHAP_GROUP_MAP: { [key: string]: string } = {
|
||||
'申': '申子辰', '子': '申子辰', '辰': '申子辰',
|
||||
'寅': '寅午戌', '午': '寅午戌', '戌': '寅午戌',
|
||||
'巳': '巳酉丑', '酉': '巳酉丑', '丑': '巳酉丑',
|
||||
'亥': '亥卯未', '卯': '亥卯未', '未': '亥卯未',
|
||||
};
|
||||
|
||||
const YEOKMA_MAP: { [key: string]: string } = {
|
||||
'申子辰': '寅', '寅午戌': '申', '巳酉丑': '亥', '亥卯未': '巳',
|
||||
};
|
||||
const DOHWA_MAP: { [key: string]: string } = {
|
||||
'申子辰': '酉', '寅午戌': '卯', '巳酉丑': '午', '亥卯未': '子',
|
||||
};
|
||||
const HWAGAE_MAP: { [key: string]: string } = {
|
||||
'申子辰': '辰', '寅午戌': '戌', '巳酉丑': '丑', '亥卯未': '未',
|
||||
};
|
||||
|
||||
// 천을귀인 (天乙貴人) - 일간 기준
|
||||
const CHEONUL_MAP: { [key: string]: string[] } = {
|
||||
'甲': ['丑', '未'], '乙': ['子', '申'], '丙': ['亥', '酉'], '丁': ['亥', '酉'],
|
||||
'戊': ['丑', '未'], '己': ['子', '申'], '庚': ['丑', '未'], '辛': ['寅', '午'],
|
||||
'壬': ['卯', '巳'], '癸': ['卯', '巳'],
|
||||
};
|
||||
|
||||
// 문창귀인 (文昌貴人) - 일간 기준
|
||||
const MUNCHANG_MAP: { [key: string]: string } = {
|
||||
'甲': '巳', '乙': '午', '丙': '申', '丁': '酉',
|
||||
'戊': '申', '己': '酉', '庚': '亥', '辛': '子',
|
||||
'壬': '寅', '癸': '卯',
|
||||
};
|
||||
|
||||
// 천덕귀인 (天德貴人) - 월지 기준
|
||||
const CHEONDUK_MAP: { [key: string]: string } = {
|
||||
'寅': '丁', '卯': '申', '辰': '壬', '巳': '辛',
|
||||
'午': '亥', '未': '甲', '申': '癸', '酉': '寅',
|
||||
'戌': '丙', '亥': '乙', '子': '巳', '丑': '庚',
|
||||
};
|
||||
|
||||
/**
|
||||
* 신살 계산
|
||||
*/
|
||||
export function calculateShinsal(saju: SajuData): Shinsal[] {
|
||||
const result: Shinsal[] = [];
|
||||
const dayBranch = saju.day.branch;
|
||||
const dayStem = saju.dayStem;
|
||||
const monthBranch = saju.month.branch;
|
||||
|
||||
// 4주의 지지 수집
|
||||
const pillarBranches: { branch: string; branchKr: string; pillar: string }[] = [
|
||||
{ branch: saju.year.branch, branchKr: saju.year.branchKr, pillar: '년주' },
|
||||
{ branch: saju.month.branch, branchKr: saju.month.branchKr, pillar: '월주' },
|
||||
{ branch: saju.day.branch, branchKr: saju.day.branchKr, pillar: '일주' },
|
||||
];
|
||||
if (saju.hour) {
|
||||
pillarBranches.push({ branch: saju.hour.branch, branchKr: saju.hour.branchKr, pillar: '시주' });
|
||||
}
|
||||
|
||||
const group = SAMHAP_GROUP_MAP[dayBranch];
|
||||
|
||||
// 역마살
|
||||
if (group) {
|
||||
const yeokma = YEOKMA_MAP[group];
|
||||
for (const pb of pillarBranches) {
|
||||
if (pb.branch === yeokma && pb.pillar !== '일주') {
|
||||
result.push({
|
||||
name: '역마살', nameHanja: '驛馬殺', branch: yeokma,
|
||||
branchKr: pb.branchKr, pillar: pb.pillar,
|
||||
description: '이동, 변동, 해외, 출장이 많은 기운. 활동적이고 한 곳에 머물지 못하는 성향.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 도화살
|
||||
const dohwa = DOHWA_MAP[group];
|
||||
for (const pb of pillarBranches) {
|
||||
if (pb.branch === dohwa && pb.pillar !== '일주') {
|
||||
result.push({
|
||||
name: '도화살', nameHanja: '桃花殺', branch: dohwa,
|
||||
branchKr: pb.branchKr, pillar: pb.pillar,
|
||||
description: '매력, 인기, 예술적 감각. 이성에게 끌리는 기운이 강하며 대인관계가 화려함.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 화개살
|
||||
const hwagae = HWAGAE_MAP[group];
|
||||
for (const pb of pillarBranches) {
|
||||
if (pb.branch === hwagae && pb.pillar !== '일주') {
|
||||
result.push({
|
||||
name: '화개살', nameHanja: '華蓋殺', branch: hwagae,
|
||||
branchKr: pb.branchKr, pillar: pb.pillar,
|
||||
description: '학문, 종교, 예술에 심취하는 기운. 고독을 즐기며 정신적 세계에 몰두하는 성향.',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 천을귀인
|
||||
const cheonulBranches = CHEONUL_MAP[dayStem] || [];
|
||||
for (const pb of pillarBranches) {
|
||||
if (cheonulBranches.includes(pb.branch) && pb.pillar !== '일주') {
|
||||
result.push({
|
||||
name: '천을귀인', nameHanja: '天乙貴人', branch: pb.branch,
|
||||
branchKr: pb.branchKr, pillar: pb.pillar,
|
||||
description: '위기에서 귀인의 도움을 받는 길한 기운. 어려울 때 도움을 주는 사람이 나타남.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 문창귀인
|
||||
const munchangBranch = MUNCHANG_MAP[dayStem];
|
||||
if (munchangBranch) {
|
||||
for (const pb of pillarBranches) {
|
||||
if (pb.branch === munchangBranch && pb.pillar !== '일주') {
|
||||
result.push({
|
||||
name: '문창귀인', nameHanja: '文昌貴人', branch: pb.branch,
|
||||
branchKr: pb.branchKr, pillar: pb.pillar,
|
||||
description: '학문, 시험, 문서에 유리한 기운. 공부를 잘하며 시험운이 좋음.',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 천덕귀인 (월지 기준, 천간에서 확인)
|
||||
const cheondukStem = CHEONDUK_MAP[monthBranch];
|
||||
if (cheondukStem) {
|
||||
const allStems = [
|
||||
{ stem: saju.year.stem, pillar: '년주' },
|
||||
{ stem: saju.day.stem, pillar: '일주' },
|
||||
];
|
||||
if (saju.hour) allStems.push({ stem: saju.hour.stem, pillar: '시주' });
|
||||
for (const ps of allStems) {
|
||||
if (ps.stem === cheondukStem) {
|
||||
const stemIdx = HEAVENLY_STEMS.indexOf(ps.stem as any);
|
||||
result.push({
|
||||
name: '천덕귀인', nameHanja: '天德貴人', branch: monthBranch,
|
||||
branchKr: EARTHLY_BRANCHES_KR[EARTHLY_BRANCHES.indexOf(monthBranch as any)],
|
||||
pillar: ps.pillar,
|
||||
description: '하늘의 덕을 받는 기운. 재난을 피하고 복을 받는 길신 중의 길신.',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 공망 (空亡) 계산
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 60갑자에서 일주의 순(旬)을 찾아 공망 지지 2개를 반환
|
||||
*/
|
||||
export function calculateGongmang(dayStem: string, dayBranch: string): { branches: string[]; branchesKr: string[]; description: string } {
|
||||
const stemIdx = HEAVENLY_STEMS.indexOf(dayStem as any);
|
||||
const branchIdx = EARTHLY_BRANCHES.indexOf(dayBranch as any);
|
||||
|
||||
// 60갑자에서 해당 순(旬)의 시작점 = 천간이 甲인 지점
|
||||
// 순의 시작 지지 인덱스 = (branchIdx - stemIdx + 12) % 12
|
||||
const startBranchIdx = (branchIdx - stemIdx + 120) % 12;
|
||||
|
||||
// 공망 = 순에 포함되지 않는 2개의 지지
|
||||
// 순은 10개의 간지 → 10개의 지지 사용, 2개가 남음
|
||||
const gongmang1Idx = (startBranchIdx + 10) % 12;
|
||||
const gongmang2Idx = (startBranchIdx + 11) % 12;
|
||||
|
||||
const branch1 = EARTHLY_BRANCHES[gongmang1Idx];
|
||||
const branch2 = EARTHLY_BRANCHES[gongmang2Idx];
|
||||
const branchKr1 = EARTHLY_BRANCHES_KR[gongmang1Idx];
|
||||
const branchKr2 = EARTHLY_BRANCHES_KR[gongmang2Idx];
|
||||
|
||||
return {
|
||||
branches: [branch1, branch2],
|
||||
branchesKr: [branchKr1, branchKr2],
|
||||
description: `${branchKr1}(${branch1})·${branchKr2}(${branch2}) 공망 → 해당 지지의 기운이 비어있어 허무하거나 집착이 없는 영역. 오히려 초월적 능력이 될 수 있음.`,
|
||||
};
|
||||
}
|
||||
|
||||
9
lib/supabase/client.ts
Normal file
9
lib/supabase/client.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
|
||||
import { createBrowserClient } from '@supabase/ssr'
|
||||
|
||||
export function createClient() {
|
||||
return createBrowserClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
||||
)
|
||||
}
|
||||
30
lib/supabase/server.ts
Normal file
30
lib/supabase/server.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
|
||||
import { createServerClient } from '@supabase/ssr'
|
||||
import { cookies } from 'next/headers'
|
||||
|
||||
export async function createClient() {
|
||||
const cookieStore = await cookies()
|
||||
|
||||
return createServerClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
{
|
||||
cookies: {
|
||||
getAll() {
|
||||
return cookieStore.getAll()
|
||||
},
|
||||
setAll(cookiesToSet) {
|
||||
try {
|
||||
cookiesToSet.forEach(({ name, value, options }) =>
|
||||
cookieStore.set(name, value, options)
|
||||
)
|
||||
} catch {
|
||||
// The `setAll` method was called from a Server Component.
|
||||
// This can be ignored if you have middleware refreshing
|
||||
// user sessions.
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
7
lib/supabaseClient.ts
Normal file
7
lib/supabaseClient.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || '';
|
||||
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || '';
|
||||
|
||||
export const supabase = createClient(supabaseUrl, supabaseAnonKey);
|
||||
20
middleware.ts
Normal file
20
middleware.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
|
||||
import { type NextRequest } from 'next/server'
|
||||
import { updateSession } from '@/utils/supabase/middleware'
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
return await updateSession(request)
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
/*
|
||||
* Match all request paths except for the ones starting with:
|
||||
* - _next/static (static files)
|
||||
* - _next/image (image optimization files)
|
||||
* - favicon.ico (favicon file)
|
||||
* Feel free to modify this pattern to include more paths.
|
||||
*/
|
||||
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
|
||||
],
|
||||
}
|
||||
@@ -1,7 +1,17 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
// NAS 배포 시 /saju 경로 사용
|
||||
basePath: process.env.NEXT_PUBLIC_BASE_PATH || '',
|
||||
assetPrefix: process.env.NEXT_PUBLIC_BASE_PATH || '',
|
||||
|
||||
// Standalone 모드 (Docker 최적화)
|
||||
output: 'standalone',
|
||||
|
||||
// 이미지 최적화 비활성화 (NAS 리소스 절약)
|
||||
images: {
|
||||
unoptimized: true,
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
84
nginx-config.conf
Normal file
84
nginx-config.conf
Normal file
@@ -0,0 +1,84 @@
|
||||
# ============================================
|
||||
# Nginx 설정 - /saju 라우팅
|
||||
# ============================================
|
||||
# 이 설정을 기존 nginx/default.conf의 server { } 블록 안에 추가하세요.
|
||||
# ============================================
|
||||
|
||||
# /saju 경로 → saju-web 컨테이너로 프록시
|
||||
location /saju {
|
||||
# saju-web 컨테이너로 전달
|
||||
proxy_pass http://saju-web:3000;
|
||||
|
||||
# 필수 헤더 설정
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
|
||||
# 캐시 무효화
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
|
||||
# 타임아웃 설정 (AI 해석 등 긴 요청 대비)
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
|
||||
# 버퍼 설정
|
||||
proxy_buffering on;
|
||||
proxy_buffer_size 4k;
|
||||
proxy_buffers 8 4k;
|
||||
proxy_busy_buffers_size 8k;
|
||||
}
|
||||
|
||||
# Next.js static files (_next/static)
|
||||
location ~ ^/saju/_next/static/(.*)$ {
|
||||
proxy_pass http://saju-web:3000;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
# Static files는 장기 캐싱
|
||||
proxy_cache_valid 200 365d;
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Next.js images, fonts 등
|
||||
location ~ ^/saju/_next/(.*)$ {
|
||||
proxy_pass http://saju-web:3000;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# 적절한 캐싱
|
||||
add_header Cache-Control "public, max-age=3600";
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# 참고: 전체 nginx/default.conf 구조 예시
|
||||
# ============================================
|
||||
# server {
|
||||
# listen 80;
|
||||
# server_name gahusb.synology.me;
|
||||
#
|
||||
# # 기존 설정들...
|
||||
# location / { ... }
|
||||
# location /api/lotto { ... }
|
||||
# location /api/travel { ... }
|
||||
# location /api/stock { ... }
|
||||
#
|
||||
# # 👇 여기에 위 설정 추가
|
||||
# location /saju { ... }
|
||||
# location ~ ^/saju/_next/static/(.*)$ { ... }
|
||||
# location ~ ^/saju/_next/(.*)$ { ... }
|
||||
# }
|
||||
1652
package-lock.json
generated
1652
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
11
package.json
11
package.json
@@ -3,18 +3,25 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"dev": "next dev -p 3005",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
"lint": "eslint",
|
||||
"release:nas": "node scripts/deploy-nas.cjs",
|
||||
"deploy:ssh": "cross-env NAS_DEPLOY_METHOD=ssh node scripts/deploy-nas.cjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@supabase/ssr": "^0.8.0",
|
||||
"@supabase/supabase-js": "^2.95.3",
|
||||
"html2canvas": "^1.4.1",
|
||||
"jspdf": "^4.1.0",
|
||||
"lunar-calendar": "^0.1.4",
|
||||
"next": "16.1.6",
|
||||
"openai": "^6.21.0",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"solarlunar": "^2.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
214
scripts/deploy-nas.cjs
Normal file
214
scripts/deploy-nas.cjs
Normal file
@@ -0,0 +1,214 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const { execSync } = require("child_process");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const isWin = process.platform === "win32";
|
||||
const isMac = process.platform === "darwin";
|
||||
|
||||
// 소스 디렉토리 (현재 프로젝트 루트)
|
||||
const projectRoot = path.resolve(__dirname, "..");
|
||||
const projectName = "saju-web";
|
||||
|
||||
// NAS 경로 설정
|
||||
const dstWin = `Z:\\docker\\webpage\\${projectName}\\`;
|
||||
const dstMac = `/Volumes/gahusb.synology.me/docker/webpage/${projectName}/`;
|
||||
const dst = isWin ? dstWin : dstMac;
|
||||
|
||||
// SSH 설정 (환경 변수에서 읽기)
|
||||
const sshTarget = process.env.NAS_SSH_TARGET || "gahusb@gahusb.synology.me";
|
||||
const sshPath = process.env.NAS_SSH_PATH || `/volume1/docker/webpage/${projectName}`;
|
||||
const sshPort = process.env.NAS_SSH_PORT;
|
||||
|
||||
// 제외할 파일/폴더
|
||||
const excludePatterns = [
|
||||
"node_modules/",
|
||||
".next/",
|
||||
".git/",
|
||||
"dist/",
|
||||
"out/",
|
||||
".env.local",
|
||||
".env*.local",
|
||||
"*.log",
|
||||
".DS_Store",
|
||||
"build_error.log",
|
||||
"robocopy.log",
|
||||
];
|
||||
|
||||
console.log("🚀 Saju Web NAS Deployment Started");
|
||||
console.log("=====================================");
|
||||
console.log(`Platform: ${process.platform}`);
|
||||
console.log(`Project: ${projectName}`);
|
||||
console.log("");
|
||||
|
||||
// SSH를 통한 배포 (추천)
|
||||
if (process.env.NAS_DEPLOY_METHOD === "ssh" || sshTarget) {
|
||||
console.log("📡 Deploying via SSH...");
|
||||
console.log(`Target: ${sshTarget}:${sshPath}`);
|
||||
console.log("");
|
||||
|
||||
const sshCmd = sshPort ? `ssh -p ${sshPort}` : "ssh";
|
||||
const excludeArgs = excludePatterns
|
||||
.map((pattern) => `--exclude='${pattern}'`)
|
||||
.join(" ");
|
||||
|
||||
try {
|
||||
// 1. rsync로 파일 동기화
|
||||
console.log("📦 Step 1: Syncing files to NAS...");
|
||||
const rsyncCmd = `rsync -avz --delete ${excludeArgs} -e "${sshCmd}" ${projectRoot}/ ${sshTarget}:${sshPath}/`;
|
||||
console.log(`Command: ${rsyncCmd}`);
|
||||
execSync(rsyncCmd, { stdio: "inherit" });
|
||||
console.log("✅ Files synced successfully");
|
||||
console.log("");
|
||||
|
||||
// 2. .env.nas를 .env로 복사
|
||||
console.log("📝 Step 2: Setting up environment variables...");
|
||||
const envCmd = `${sshCmd} ${sshTarget} "cd ${sshPath} && cp -f .env.nas .env"`;
|
||||
execSync(envCmd, { stdio: "inherit" });
|
||||
console.log("✅ Environment variables set");
|
||||
console.log("");
|
||||
|
||||
// 3. Docker 빌드 및 배포
|
||||
console.log("🐳 Step 3: Building and deploying Docker container...");
|
||||
const deployCmd = `${sshCmd} ${sshTarget} "cd /volume1/docker/webpage && docker compose build ${projectName} && docker compose up -d ${projectName}"`;
|
||||
console.log(`Command: ${deployCmd}`);
|
||||
execSync(deployCmd, { stdio: "inherit" });
|
||||
console.log("✅ Docker container deployed");
|
||||
console.log("");
|
||||
|
||||
// 4. 헬스체크
|
||||
console.log("🏥 Step 4: Health check...");
|
||||
setTimeout(() => {
|
||||
try {
|
||||
const healthCmd = `${sshCmd} ${sshTarget} "docker exec ${projectName} wget -q -O- http://localhost:3000/api/health"`;
|
||||
const health = execSync(healthCmd, { encoding: "utf-8" });
|
||||
console.log("Health check response:", health);
|
||||
console.log("✅ Service is healthy");
|
||||
} catch (err) {
|
||||
console.warn("⚠️ Health check failed (service may still be starting)");
|
||||
}
|
||||
console.log("");
|
||||
console.log("🎉 Deployment completed!");
|
||||
console.log("=====================================");
|
||||
console.log(`🌐 Access: http://gahusb.synology.me/saju`);
|
||||
console.log("");
|
||||
console.log("📝 To check logs:");
|
||||
console.log(` ssh ${sshTarget} "docker logs ${projectName} --tail 50 -f"`);
|
||||
console.log("");
|
||||
}, 3000);
|
||||
} catch (err) {
|
||||
console.error("❌ Deployment failed:", err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// SMB/CIFS 마운트를 통한 배포
|
||||
console.log("📂 Deploying via SMB mount...");
|
||||
console.log(`Destination: ${dst}`);
|
||||
console.log("");
|
||||
|
||||
if (!fs.existsSync(dst)) {
|
||||
console.error("❌ NAS path not found. Check mount: " + dst);
|
||||
console.log("");
|
||||
console.log("💡 Tip: Use SSH deployment instead:");
|
||||
console.log(" NAS_DEPLOY_METHOD=ssh npm run release:nas");
|
||||
console.log("");
|
||||
console.log(" Or set environment variables:");
|
||||
console.log(" NAS_SSH_TARGET=user@gahusb.synology.me");
|
||||
console.log(" NAS_SSH_PORT=22 (optional)");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (isWin) {
|
||||
console.log("📦 Step 1: Copying files to NAS (Windows - Robocopy)...");
|
||||
|
||||
// Robocopy 제외 패턴
|
||||
const excludeDirs = excludePatterns
|
||||
.filter((p) => p.endsWith("/"))
|
||||
.map((p) => p.slice(0, -1))
|
||||
.join(" ");
|
||||
const excludeFiles = excludePatterns
|
||||
.filter((p) => !p.endsWith("/"))
|
||||
.join(" ");
|
||||
|
||||
const excludeDirsArg = excludeDirs ? `/XD ${excludeDirs}` : "";
|
||||
const excludeFilesArg = excludeFiles ? `/XF ${excludeFiles}` : "";
|
||||
|
||||
const cmd = `powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference=\\"Stop\\"; $src=\\"${projectRoot}\\"; $dst=\\"${dst}\\"; if(!(Test-Path $dst)){ throw \\"NAS drive not found. Check Z: mapping.\\" }; $log = Join-Path (Get-Location) \\"robocopy.log\\"; robocopy $src $dst /MIR ${excludeDirsArg} ${excludeFilesArg} /R:1 /W:1 /E /NFL /NDL /NP /V /TEE /LOG:$log; $rc = $LASTEXITCODE; if($rc -ge 8){ Write-Host \\"robocopy failed with code $rc. See $log\\"; exit $rc } else { exit 0 }"`;
|
||||
|
||||
try {
|
||||
execSync(cmd, { stdio: "inherit" });
|
||||
console.log("✅ Files copied successfully");
|
||||
} catch (err) {
|
||||
console.error("❌ File copy failed:", err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
} else if (isMac) {
|
||||
console.log("📦 Step 1: Copying files to NAS (macOS - ditto)...");
|
||||
|
||||
// 쓰기 테스트
|
||||
const safety = dst.includes("docker/webpage/saju-web");
|
||||
if (!safety) {
|
||||
console.error("❌ Safety check failed: unexpected dst path: " + dst);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
const testPath = `${dst}.deploy-write-test`;
|
||||
fs.writeFileSync(testPath, "ok");
|
||||
fs.unlinkSync(testPath);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
"❌ NAS write test failed. Files may be locked or permissions are read-only."
|
||||
);
|
||||
console.error(
|
||||
"💡 Try using SSH deployment instead: NAS_DEPLOY_METHOD=ssh npm run release:nas"
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
|
||||
try {
|
||||
execSync(`ditto ${projectRoot} ${dst}`, { stdio: "inherit" });
|
||||
console.log("✅ Files copied successfully");
|
||||
} catch (err) {
|
||||
console.error("❌ File copy failed:", err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
// Linux 등 기타 플랫폼
|
||||
console.log("📦 Step 1: Copying files to NAS (rsync)...");
|
||||
|
||||
const excludeArgs = excludePatterns
|
||||
.map((pattern) => `--exclude='${pattern}'`)
|
||||
.join(" ");
|
||||
const cmd = `rsync -avz --delete ${excludeArgs} ${projectRoot}/ ${dst}`;
|
||||
|
||||
try {
|
||||
execSync(cmd, { stdio: "inherit" });
|
||||
console.log("✅ Files copied successfully");
|
||||
} catch (err) {
|
||||
console.error("❌ File copy failed:", err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("");
|
||||
console.log("⚠️ Files copied to NAS. Manual steps required:");
|
||||
console.log("");
|
||||
console.log("1. SSH into NAS:");
|
||||
console.log(` ssh ${sshTarget}`);
|
||||
console.log("");
|
||||
console.log("2. Setup environment:");
|
||||
console.log(` cd ${sshPath}`);
|
||||
console.log(` cp -f .env.nas .env`);
|
||||
console.log("");
|
||||
console.log("3. Deploy Docker container:");
|
||||
console.log(` cd /volume1/docker/webpage`);
|
||||
console.log(` docker compose build ${projectName}`);
|
||||
console.log(` docker compose up -d ${projectName}`);
|
||||
console.log("");
|
||||
console.log("💡 Tip: Use SSH deployment for automated process:");
|
||||
console.log(" NAS_DEPLOY_METHOD=ssh npm run release:nas");
|
||||
console.log("");
|
||||
76
supabase/schema.sql
Normal file
76
supabase/schema.sql
Normal file
@@ -0,0 +1,76 @@
|
||||
|
||||
-- 1. 유저 프로필 테이블 (auth.users와 연동)
|
||||
create table public.profiles (
|
||||
id uuid references auth.users(id) on delete cascade not null primary key,
|
||||
email text,
|
||||
full_name text,
|
||||
avatar_url text,
|
||||
credits integer default 0, -- 사주 보기용 머니/크레딧
|
||||
created_at timestamp with time zone default timezone('utc'::text, now()) not null
|
||||
);
|
||||
|
||||
-- RLS (Row Level Security) 설정
|
||||
alter table public.profiles enable row level security;
|
||||
create policy "공개 프로필은 누구나 볼 수 있음" on public.profiles for select using ( true );
|
||||
create policy "사용자는 자신의 프로필을 생성 가능" on public.profiles for insert with check ( auth.uid() = id );
|
||||
create policy "사용자는 자신의 프로필만 수정 가능" on public.profiles for update using ( auth.uid() = id );
|
||||
|
||||
-- 새 유저 가입 시 자동으로 profile 생성하는 트리거
|
||||
create function public.handle_new_user()
|
||||
returns trigger as $$
|
||||
begin
|
||||
insert into public.profiles (id, email, full_name, avatar_url)
|
||||
values (new.id, new.email, new.raw_user_meta_data->>'full_name', new.raw_user_meta_data->>'avatar_url');
|
||||
return new;
|
||||
end;
|
||||
$$ language plpgsql security definer;
|
||||
|
||||
create trigger on_auth_user_created
|
||||
after insert on auth.users
|
||||
for each row execute procedure public.handle_new_user();
|
||||
|
||||
|
||||
-- 2. 사주 기록 테이블 (저장된 결과)
|
||||
create table public.saju_records (
|
||||
id bigint generated by default as identity primary key,
|
||||
user_id uuid references public.profiles(id) not null,
|
||||
saju_data jsonb not null, -- 사주 원국 데이터 (JSON)
|
||||
interpretation text, -- AI 해석 결과 (Markdown 텍스트)
|
||||
created_at timestamp with time zone default timezone('utc'::text, now()) not null
|
||||
);
|
||||
|
||||
alter table public.saju_records enable row level security;
|
||||
create policy "사용자는 자신의 기록만 볼 수 있음" on public.saju_records for select using ( auth.uid() = user_id );
|
||||
create policy "사용자는 자신의 기록만 생성 가능" on public.saju_records for insert with check ( auth.uid() = user_id );
|
||||
|
||||
|
||||
-- 3. 결제 내역 테이블 (PortOne 연동)
|
||||
create table public.payments (
|
||||
id uuid default gen_random_uuid() primary key,
|
||||
user_id uuid references public.profiles(id) not null,
|
||||
merchant_uid text not null unique, -- 주문 번호
|
||||
imp_uid text, -- 포트원 고유 번호
|
||||
amount integer not null,
|
||||
token_amount integer default 0, -- 충전된 토큰 수
|
||||
status text not null default 'pending', -- pending, paid, failed, cancelled
|
||||
created_at timestamp with time zone default timezone('utc'::text, now()) not null
|
||||
);
|
||||
|
||||
alter table public.payments enable row level security;
|
||||
create policy "사용자는 자신의 결제 내역만 볼 수 있음" on public.payments for select using ( auth.uid() = user_id );
|
||||
create policy "사용자는 자신의 결제 내역 생성 가능" on public.payments for insert with check ( auth.uid() = user_id );
|
||||
create policy "사용자는 자신의 결제 내역 수정 가능" on public.payments for update using ( auth.uid() = user_id );
|
||||
|
||||
-- 토큰(크레딧) 충전을 위한 RPC 함수 (atomic increment, RLS 우회)
|
||||
create or replace function public.add_credits(user_id_input uuid, amount_input integer)
|
||||
returns integer as $$
|
||||
declare
|
||||
new_credits integer;
|
||||
begin
|
||||
update public.profiles
|
||||
set credits = credits + amount_input
|
||||
where id = user_id_input
|
||||
returning credits into new_credits;
|
||||
return new_credits;
|
||||
end;
|
||||
$$ language plpgsql security definer;
|
||||
48
utils/supabase/middleware.ts
Normal file
48
utils/supabase/middleware.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
|
||||
import { NextResponse, type NextRequest } from 'next/server'
|
||||
import { createServerClient } from '@supabase/ssr'
|
||||
|
||||
export async function updateSession(request: NextRequest) {
|
||||
let supabaseResponse = NextResponse.next({
|
||||
request,
|
||||
})
|
||||
|
||||
const supabase = createServerClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
{
|
||||
cookies: {
|
||||
getAll() {
|
||||
return request.cookies.getAll()
|
||||
},
|
||||
setAll(cookiesToSet) {
|
||||
cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value))
|
||||
supabaseResponse = NextResponse.next({
|
||||
request,
|
||||
})
|
||||
cookiesToSet.forEach(({ name, value, options }) =>
|
||||
supabaseResponse.cookies.set(name, value, options)
|
||||
)
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// Do not run Supabase middleware on static assets
|
||||
// IMPORTANT: You *must* return the supabaseResponse object as it is.
|
||||
// If you're creating a new response object with NextResponse.next() make sure to:
|
||||
// 1. Pass the request in it, like so:
|
||||
// const myNewResponse = NextResponse.next({ request })
|
||||
// 2. Copy over the cookies, like so:
|
||||
// myNewResponse.cookies.setAll(supabaseResponse.cookies.getAll())
|
||||
// 3. Change the myNewResponse object to fit your needs, but avoid changing
|
||||
// the cookies!
|
||||
// 4. Finally:
|
||||
// return myNewResponse
|
||||
// If this is not done, you may be causing the browser and server to go out
|
||||
// of sync and terminate the user's session prematurely!
|
||||
|
||||
await supabase.auth.getUser()
|
||||
|
||||
return supabaseResponse
|
||||
}
|
||||
Reference in New Issue
Block a user