├── pudding1.png ├── pudding2.png ├── app ├── favicon.ico ├── globals.css ├── layout.tsx ├── api │ ├── save_image │ │ └── route.ts │ ├── generate_image │ │ └── route.ts │ └── profiles │ │ └── route.ts └── page.tsx ├── postcss.config.mjs ├── public ├── vercel.svg ├── window.svg ├── file.svg ├── globe.svg └── next.svg ├── next.config.ts ├── eslint.config.mjs ├── package.json ├── .gitignore ├── tsconfig.json └── README.md /pudding1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maiinji01/Find-My-Little-Pudding/HEAD/pudding1.png -------------------------------------------------------------------------------- /pudding2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maiinji01/Find-My-Little-Pudding/HEAD/pudding2.png -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maiinji01/Find-My-Little-Pudding/HEAD/app/favicon.ico -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | const config = { 2 | plugins: { 3 | "@tailwindcss/postcss": {}, 4 | }, 5 | }; 6 | 7 | export default config; 8 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from "next"; 2 | 3 | const nextConfig: NextConfig = { 4 | /* config options here */ 5 | }; 6 | 7 | export default nextConfig; 8 | -------------------------------------------------------------------------------- /public/window.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/file.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig, globalIgnores } from "eslint/config"; 2 | import nextVitals from "eslint-config-next/core-web-vitals"; 3 | import nextTs from "eslint-config-next/typescript"; 4 | 5 | const eslintConfig = defineConfig([ 6 | ...nextVitals, 7 | ...nextTs, 8 | // Override default ignores of eslint-config-next. 9 | globalIgnores([ 10 | // Default ignores of eslint-config-next: 11 | ".next/**", 12 | "out/**", 13 | "build/**", 14 | "next-env.d.ts", 15 | ]), 16 | ]); 17 | 18 | export default eslintConfig; 19 | -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | 3 | :root { 4 | --background: #ffffff; 5 | --foreground: #171717; 6 | } 7 | 8 | @theme inline { 9 | --color-background: var(--background); 10 | --color-foreground: var(--foreground); 11 | --font-sans: var(--font-geist-sans); 12 | --font-mono: var(--font-geist-mono); 13 | } 14 | 15 | @media (prefers-color-scheme: dark) { 16 | :root { 17 | --background: #0a0a0a; 18 | --foreground: #ededed; 19 | } 20 | } 21 | 22 | body { 23 | background: var(--background); 24 | color: var(--foreground); 25 | font-family: Arial, Helvetica, sans-serif; 26 | } 27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pudding-test", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "eslint" 10 | }, 11 | "dependencies": { 12 | "@supabase/supabase-js": "^2.86.0", 13 | "next": "16.0.5", 14 | "react": "19.2.0", 15 | "react-dom": "19.2.0" 16 | }, 17 | "devDependencies": { 18 | "@tailwindcss/postcss": "^4", 19 | "@types/node": "^20", 20 | "@types/react": "^19", 21 | "@types/react-dom": "^19", 22 | "eslint": "^9", 23 | "eslint-config-next": "16.0.5", 24 | "tailwindcss": "^4", 25 | "typescript": "^5" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # env files (can opt-in for committing if needed) 34 | .env* 35 | 36 | # vercel 37 | .vercel 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | next-env.d.ts 42 | 43 | .env 44 | .env.local 45 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "react-jsx", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./*"] 23 | } 24 | }, 25 | "include": [ 26 | "next-env.d.ts", 27 | "**/*.ts", 28 | "**/*.tsx", 29 | ".next/types/**/*.ts", 30 | ".next/dev/types/**/*.ts", 31 | "**/*.mts" 32 | ], 33 | "exclude": ["node_modules"] 34 | } 35 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Geist, Geist_Mono } from "next/font/google"; 3 | import "./globals.css"; 4 | 5 | const geistSans = Geist({ 6 | variable: "--font-geist-sans", 7 | subsets: ["latin"], 8 | }); 9 | 10 | const geistMono = Geist_Mono({ 11 | variable: "--font-geist-mono", 12 | subsets: ["latin"], 13 | }); 14 | 15 | export const metadata: Metadata = { 16 | title: "Little Pudding ❤️", 17 | description: "Generated by create next app", 18 | }; 19 | 20 | export default function RootLayout({ 21 | children, 22 | }: Readonly<{ 23 | children: React.ReactNode; 24 | }>) { 25 | return ( 26 | 27 | 30 | {children} 31 | 32 | 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /public/globe.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/api/save_image/route.ts: -------------------------------------------------------------------------------- 1 | // app/api/save_image/route.ts 2 | import { NextResponse } from "next/server"; 3 | import { createClient } from "@supabase/supabase-js"; 4 | 5 | const supabase = createClient( 6 | process.env.SUPABASE_URL!, 7 | process.env.SUPABASE_SERVICE_ROLE_KEY! // 반드시 service role 8 | ); 9 | 10 | export async function POST(req: Request) { 11 | try { 12 | const { profileId, base64 } = await req.json(); 13 | 14 | if (!profileId || !base64) { 15 | return NextResponse.json({ error: "Missing data" }, { status: 400 }); 16 | } 17 | 18 | // base64 → binary 변환 19 | const imageBuffer = Buffer.from(base64, "base64"); 20 | 21 | const fileName = `puddings/${profileId}.png`; 22 | 23 | // Storage 업로드 24 | const { error: uploadError } = await supabase.storage 25 | .from("puddings") 26 | .upload(fileName, imageBuffer, { 27 | contentType: "image/png", 28 | upsert: true, 29 | }); 30 | 31 | if (uploadError) { 32 | console.error(uploadError); 33 | return NextResponse.json({ error: "Upload failed" }, { status: 500 }); 34 | } 35 | 36 | // Public URL 가져오기 37 | const { 38 | data: { publicUrl }, 39 | } = supabase.storage.from("puddings").getPublicUrl(fileName); 40 | 41 | // DB 업데이트 42 | const { error: updateError } = await supabase 43 | .from("profiles") 44 | .update({ pudding_image_url: publicUrl }) 45 | .eq("id", profileId); 46 | 47 | if (updateError) { 48 | console.error(updateError); 49 | return NextResponse.json( 50 | { error: "DB update failed" }, 51 | { status: 500 } 52 | ); 53 | } 54 | 55 | return NextResponse.json({ url: publicUrl }); 56 | } catch (err) { 57 | console.error(err); 58 | return NextResponse.json({ error: "Server error" }, { status: 500 }); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Find Your Pudding 🥰 2 | Discover your perfect anime-style pudding match with AI! 3 | *Created by Hanyang University students as a playful project.* 4 | 5 | 6 | 실행해보기 → https://find-my-little-pudding.vercel.app/ 7 | 8 | 제작과정 short-vlog 9 | 10 | → 1탄 https://youtube.com/shorts/WHcLOGuiQx8?si=nAYUpvYeiWHJiLgc 11 | 12 | → 2탄 https://youtube.com/shorts/eQ8hb9l92uI?si=u1BJEpszqYV07BOB 13 | 14 | 15 | ⭐ 재미있다면 Star 눌러주세요! 16 | ⭐ Please Star if you like it! 17 | ⭐ Twitter/Instagram/Reddit 공유 환영! 18 | ⭐ Share on Twitter/Instagram/Reddit! 19 | 20 | 21 | --- 22 | 23 | ## 소개 | About 24 | **한글:** 25 | Find Your Pudding은 자신만의 귀여운 애니풍 쿠키/푸딩 캐릭터를 AI로 생성하고, 이상형 키워드와 3개 이상 일치하는 캐릭터를 추천해주는 서비스입니다. 26 | 27 | **English:** 28 | Find Your Pudding creates your cute anime-style cookie/pudding character using AI, and recommends a character that matches 3 or more of your ideal-type keywords. 29 | 30 | --- 31 | 32 | ## 핵심 기능 | Key Features 33 | - AI 이미지 생성으로 나만의 푸딩 캐릭터 생성 | AI-generated personalized pudding character 34 | - 사용자 성향·취향 기반 캐릭터 요소 자동 구성 | Automatic character attributes from personality & preferences 35 | - 연애 가치관을 활용한 이상형 Top 3 매칭 | Top 3 ideal matches based on dating values 36 | - 인스타그램 ID는 공유 동의 시에만 공개 | Instagram ID is shown only with user’s consent 37 | 38 | --- 39 | 40 | ## 타겟 사용자 | Target Users 41 | - 한글: 애니/게임 감성 좋아하는 대학생, SNS 프로필 꾸미기 좋아하는 유저 42 | - English: College students who like anime/game style, social media users who enjoy decorating profiles 43 | 44 | --- 45 | 46 | ## 샘플 이미지 | Sample Images 47 | ![푸딩1](./pudding1.png) 48 | ![푸딩2](./pudding2.png) 49 | 50 | 51 | > 실제 이미지는 AI로 생성 | Actual images generated by AI 52 | 53 | --- 54 | 55 | ## 기술 스택 | Tech Stack 56 | - Front: Next.js 57 | - Backend API: Supabase Edge Functions (or Next.js API routes) 58 | - DB: Supabase (profiles table) 59 | - Storage: Supabase Storage 60 | - AI Image: Google Imagen / Gemini API 61 | 62 | --- 63 | 64 | ## 안내사항 65 | 본 프로젝트는 한양대학교 산업융합학부 인간-인공지능 협업 제품 서비스 설계 수업(2025년 가을학기)의 기말 프로젝트 활동으로 진행된 결과물입니다. 본 수업의 지도 교수는 한양대 산업융합학부 정철현 교수(inbass@hanyang.ac.kr) 입니다. 코드와 문서는 오픈소스(MIT 라이센스)이므로 자유롭게 참조/사용하시되 사용으로 인한 모든 리스크는 스스로 감당하셔야 합니다. 66 | -------------------------------------------------------------------------------- /app/api/generate_image/route.ts: -------------------------------------------------------------------------------- 1 | // app/api/generate_image/route.ts 2 | import { NextResponse } from "next/server"; 3 | 4 | const API_KEY = process.env.GEMINI_API_KEY; 5 | const GEMINI_URL = 6 | "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-image:generateContent"; 7 | 8 | export async function POST(req: Request) { 9 | try { 10 | if (!API_KEY) { 11 | return NextResponse.json( 12 | { error: "Server is missing GEMINI_API_KEY." }, 13 | { status: 500 } 14 | ); 15 | } 16 | 17 | const body = await req.json(); 18 | const prompt = body?.prompt as string | undefined; 19 | 20 | if (!prompt) { 21 | return NextResponse.json( 22 | { error: "Prompt is required." }, 23 | { status: 400 } 24 | ); 25 | } 26 | 27 | const payload = { 28 | contents: [ 29 | { 30 | parts: [{ text: prompt }], 31 | }, 32 | ], 33 | generationConfig: { 34 | imageConfig: { 35 | aspectRatio: "1:1", 36 | }, 37 | }, 38 | }; 39 | 40 | const res = await fetch(`${GEMINI_URL}?key=${API_KEY}`, { 41 | method: "POST", 42 | headers: { "Content-Type": "application/json" }, 43 | body: JSON.stringify(payload), 44 | }); 45 | 46 | const data = await res.json(); 47 | 48 | if (!res.ok) { 49 | console.error("Gemini API error:", res.status, data); 50 | return NextResponse.json( 51 | { error: data.error?.message || "Gemini API request failed." }, 52 | { status: res.status } 53 | ); 54 | } 55 | 56 | const partWithImage = 57 | data?.candidates?.[0]?.content?.parts?.find((p: any) => p.inlineData); 58 | const base64Data = partWithImage?.inlineData?.data; 59 | 60 | if (!base64Data) { 61 | return NextResponse.json( 62 | { error: "No image data returned from Gemini." }, 63 | { status: 500 } 64 | ); 65 | } 66 | 67 | return NextResponse.json({ imageBase64: base64Data }, { status: 200 }); 68 | } catch (err) { 69 | console.error("Unexpected error in /api/generate_image:", err); 70 | return NextResponse.json( 71 | { error: "Unexpected server error." }, 72 | { status: 500 } 73 | ); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /app/api/profiles/route.ts: -------------------------------------------------------------------------------- 1 | // app/api/profiles/route.ts 2 | import { NextResponse } from "next/server"; 3 | import { createClient } from "@supabase/supabase-js"; 4 | 5 | const supabase = createClient( 6 | process.env.SUPABASE_URL!, 7 | process.env.SUPABASE_SERVICE_ROLE_KEY! 8 | ); 9 | 10 | type MatchRequestBody = { 11 | nickname: string; 12 | instagramId?: string | null; 13 | gender: "male" | "female"; 14 | lovePriority: string; 15 | dateFrequency: string; 16 | conflictStyle: string; 17 | }; 18 | 19 | export async function POST(req: Request) { 20 | try { 21 | const body = (await req.json()) as MatchRequestBody; 22 | 23 | // 1) 프로필 저장 24 | const { data: inserted, error: insertError } = await supabase 25 | .from("profiles") 26 | .insert({ 27 | nickname: body.nickname, 28 | instagram_id: body.instagramId ?? null, 29 | gender: body.gender, 30 | love_priority: body.lovePriority, 31 | date_frequency: body.dateFrequency, 32 | conflict_style: body.conflictStyle, 33 | }) 34 | .select() 35 | .single(); 36 | 37 | if (insertError || !inserted) { 38 | console.error("Insert error:", insertError); 39 | return NextResponse.json( 40 | { error: "Failed to save profile" }, 41 | { status: 500 } 42 | ); 43 | } 44 | 45 | // 2) 이상형 성별 46 | const targetGender = body.gender === "male" ? "female" : "male"; 47 | 48 | // 3) 후보 불러오기 49 | const { data: candidates, error: selectError } = await supabase 50 | .from("profiles") 51 | .select("*") 52 | .eq("gender", targetGender); 53 | 54 | if (selectError || !candidates) { 55 | console.error("Select error:", selectError); 56 | return NextResponse.json( 57 | { error: "Failed to fetch candidates" }, 58 | { status: 500 } 59 | ); 60 | } 61 | 62 | // 4) 점수 계산 → 상위 3명 63 | const scored = candidates 64 | .filter((p) => p.id !== inserted.id) 65 | .map((p) => { 66 | let score = 0; 67 | if (p.love_priority === body.lovePriority) score += 3; 68 | if (p.date_frequency === body.dateFrequency) score += 2; 69 | if (p.conflict_style === body.conflictStyle) score += 1; 70 | return { ...p, score }; 71 | }) 72 | .sort((a, b) => b.score - a.score) 73 | .slice(0, 3); 74 | 75 | const idealMatches = scored.map((p) => ({ 76 | id: p.id, 77 | nickname: p.nickname, 78 | puddingName: "Mystery Pudding Partner 💞🍮", 79 | gender: p.gender, 80 | lovePriority: p.love_priority, 81 | dateFrequency: p.date_frequency, 82 | conflictStyle: p.conflict_style, 83 | shareInstagram: !!p.instagram_id, 84 | instagramId: p.instagram_id, 85 | puddingImageUrl: p.pudding_image_url ?? null, // ⭐ 여기 추가 (나중에 이미지도 표시 가능) 86 | })); 87 | 88 | // 추가함(이상형 이미지 뜨는거!!) 89 | return NextResponse.json({ 90 | profileId: inserted.id, // ← 프론트에서 이미지 저장할 때 필요! 91 | idealMatches, 92 | }); 93 | } catch (err) { 94 | console.error("profiles API error:", err); 95 | return NextResponse.json( 96 | { error: "Server error in profiles API" }, 97 | { status: 500 } 98 | ); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | // app/page.tsx 2 | "use client"; 3 | 4 | import React, { useState, useCallback } from "react"; 5 | 6 | /** ========================= 7 | * Image API 설정 (서버 라우트만 사용) 8 | * ========================= */ 9 | 10 | // 프론트에서 부를 경로는 이 한 줄만 사용 11 | const IMAGE_API_URL = "/api/generate_image"; 12 | 13 | /** ========================= 14 | * Question Definitions 15 | * ========================= */ 16 | 17 | const QUESTIONS = [ 18 | { 19 | id: "gender", 20 | title: "[Gender]", 21 | question: "What is your gender?", 22 | options: [ 23 | { value: "male", label: "Man" }, 24 | { value: "female", label: "Woman" }, 25 | ], 26 | }, 27 | { 28 | id: "energy", 29 | title: "[Energy]", 30 | question: "What are your tendencies?", 31 | options: [ 32 | { 33 | value: "extrovert", 34 | label: "Extroverted (I gain energy when I'm with people)", 35 | }, 36 | { 37 | value: "introvert", 38 | label: "Introverted (I gain energy when I'm alone)", 39 | }, 40 | ], 41 | }, 42 | { 43 | id: "hobby", 44 | title: "[Hobby]", 45 | question: "What is your hobby?", 46 | options: [ 47 | { value: "exercise", label: "Exercise" }, 48 | { value: "reading", label: "Reading" }, 49 | { value: "relaxing", label: "Sleeping / Resting" }, 50 | { value: "music", label: "Music / Instruments" }, 51 | { value: "dance", label: "Dancing" }, 52 | { value: "cooking", label: "Cooking" }, 53 | ], 54 | }, 55 | { 56 | id: "rhythm", 57 | title: "[Biorhythm]", 58 | question: "What is your lifestyle like?", 59 | options: [ 60 | { value: "morning", label: "Morning type" }, // sun / clouds 61 | { value: "night", label: "Night type" }, // moon / stars 62 | ], 63 | }, 64 | { 65 | id: "season", 66 | title: "[Preferred Season]", 67 | question: "What is your favorite season?", 68 | options: [ 69 | { value: "spring", label: "Spring" }, 70 | { value: "summer", label: "Summer" }, 71 | { value: "autumn", label: "Autumn" }, 72 | { value: "winter", label: "Winter" }, 73 | ], 74 | }, 75 | { 76 | id: "plan", 77 | title: "[Planning Style]", 78 | question: "Are you more of a planner or spontaneous?", 79 | options: [ 80 | { 81 | value: "planned type", 82 | label: "Planned type (I like to organize and schedule things)", 83 | }, 84 | { 85 | value: "impromptu", 86 | label: "Impromptu type (I prefer to go with the flow)", 87 | }, 88 | ], 89 | }, 90 | { 91 | id: "emotionExpression", 92 | title: "[Emotion Expression]", 93 | question: "How do you usually express your emotions?", 94 | options: [ 95 | { value: "direct", label: "Honestly and directly" }, 96 | { 97 | value: "indirect", 98 | label: "Indirectly or in a subtle way", 99 | }, 100 | ], 101 | }, 102 | { 103 | id: "lovePriority", 104 | title: "[Dating Values 1]", 105 | question: "What is your top priority in a relationship?", 106 | options: [ 107 | { value: "thrilled", label: "Feeling thrilled / butterflies" }, 108 | { value: "growth", label: "Growing together" }, 109 | { value: "humor code", label: "Shared sense of humor" }, 110 | ], 111 | }, 112 | { 113 | id: "dateFrequency", 114 | title: "[Dating Values 2]", 115 | question: "What is your preferred dating frequency?", 116 | options: [ 117 | { value: "everyday", label: "I want to be together almost every day." }, 118 | { 119 | value: "biweekly", 120 | label: "Once every two weeks is comfortable for me.", 121 | }, 122 | { 123 | value: "monthly", 124 | label: "Once a month is enough for me.", 125 | }, 126 | ], 127 | }, 128 | { 129 | id: "conflictStyle", 130 | title: "[Dating Values 3]", 131 | question: "How do you resolve conflicts in a relationship?", 132 | options: [ 133 | { 134 | value: "immediate", 135 | label: "I want to talk and resolve it right away.", 136 | }, 137 | { 138 | value: "afterThinking", 139 | label: "I need time to organize my thoughts first.", 140 | }, 141 | ], 142 | }, 143 | ]; 144 | 145 | type AnswerMap = Record; 146 | 147 | type PuddingResult = { 148 | id: string; 149 | name: string; 150 | reason: string; 151 | imagePrompt: string; 152 | }; 153 | 154 | type IdealMatch = { 155 | id: string; 156 | nickname: string; 157 | puddingName: string; 158 | gender: "male" | "female"; 159 | lovePriority: string; 160 | dateFrequency: string; 161 | conflictStyle: string; 162 | shareInstagram: boolean; 163 | instagramId?: string | null; // 아이디만 164 | }; 165 | 166 | /** ========================= 167 | * Image Mapping Helpers 168 | * ========================= */ 169 | 170 | const ENERGY_DESC: Record = { 171 | extrovert: "a cheerful and energetic expression", 172 | introvert: "a calm and relaxed expression", 173 | }; 174 | 175 | const HOBBY_PROPS: Record = { 176 | exercise: "wearing a sporty headband or holding a small dumbbell", 177 | reading: "holding a tiny open book next to the pudding", 178 | relaxing: "lying on a soft cushion or pillow, looking sleepy and relaxed", 179 | music: "surrounded by musical notes or holding a tiny musical instrument", 180 | dance: "in a dancing pose with motion lines around it", 181 | cooking: "wearing a cute apron and holding a spoon or small dish", 182 | }; 183 | 184 | const RHYTHM_BG: Record = { 185 | morning: "a bright morning sky with a warm sun and soft clouds", 186 | night: "a night sky with a gentle moon and twinkling stars", 187 | }; 188 | 189 | const SEASON_BG: Record = { 190 | spring: "a spring scenery with blooming flowers and fresh greenery", 191 | summer: "a summer scenery with blue sky and a refreshing vibe", 192 | autumn: "an autumn scenery with falling leaves and warm orange tones", 193 | winter: "a winter scenery with snow and a cozy, calm atmosphere", 194 | }; 195 | 196 | const PLAN_ITEMS: Record = { 197 | "planned type": 198 | "near the pudding there is a planner notebook, a pencil, glasses, and a small clock", 199 | impromptu: "around the pudding there are doodles and question marks", 200 | }; 201 | 202 | const EMOTION_FACE: Record = { 203 | direct: "a confident and expressive face", 204 | indirect: "a gentle and subtle face", 205 | }; 206 | 207 | /** ========================= 208 | * Logic Functions 209 | * ========================= */ 210 | 211 | function decideBasePuddingId(answers: AnswerMap): string { 212 | const energy = answers["energy"]; 213 | const plan = answers["plan"]; 214 | const hobby = answers["hobby"]; 215 | 216 | if (energy === "extrovert" && plan === "impromptu") { 217 | return "strawberry"; 218 | } 219 | if (energy === "extrovert" && plan === "planned type") { 220 | return "custard"; 221 | } 222 | if (energy === "introvert" && plan === "planned type") { 223 | return "matcha"; 224 | } 225 | 226 | if (hobby === "exercise") return "protein"; 227 | if (hobby === "reading") return "matcha"; 228 | if (hobby === "music" || hobby === "dance") return "choco"; 229 | if (hobby === "cooking") return "caramel"; 230 | 231 | return "vanilla"; 232 | } 233 | 234 | function getPuddingName(id: string): string { 235 | switch (id) { 236 | case "strawberry": 237 | return "Spontaneous Strawberry Pudding 🍓"; 238 | case "custard": 239 | return "Bright Custard Pudding 🍮"; 240 | case "matcha": 241 | return "Calm Matcha Pudding 🍵"; 242 | case "protein": 243 | return "Sporty Protein Pudding 💪"; 244 | case "choco": 245 | return "Funny Choco Pudding 😂"; 246 | case "caramel": 247 | return "Sweet Caramel Pudding 🍯"; 248 | default: 249 | return "Soft Vanilla Pudding 🤎"; 250 | } 251 | } 252 | 253 | // Prompt for Gemini/Imagen 254 | function buildImagePrompt(answers: AnswerMap, puddingId: string): string { 255 | const gender = answers["gender"]; 256 | const energy = answers["energy"]; 257 | const hobby = answers["hobby"]; 258 | const rhythm = answers["rhythm"]; 259 | const season = answers["season"]; 260 | const plan = answers["plan"]; 261 | const emotionExpression = answers["emotionExpression"]; 262 | 263 | const fullName = getPuddingName(puddingId); 264 | const puddingName = fullName.replace(/[\u{1F300}-\u{1FAFF}]/gu, "").trim(); // strip emoji-ish 265 | 266 | const genderDesc = 267 | gender === "male" 268 | ? "a male-vibe pudding character" 269 | : "a female-vibe pudding character"; 270 | 271 | const energyDesc = ENERGY_DESC[energy] ?? ""; 272 | const hobbyDesc = HOBBY_PROPS[hobby] ?? ""; 273 | const rhythmBg = RHYTHM_BG[rhythm] ?? ""; 274 | const seasonBg = SEASON_BG[season] ?? ""; 275 | const planItems = PLAN_ITEMS[plan] ?? ""; 276 | const faceDesc = EMOTION_FACE[emotionExpression] ?? ""; 277 | 278 | return ` 279 | Cute pastel illustration of a sweet ${puddingName} as ${genderDesc}. 280 | The pudding has ${energyDesc} and ${faceDesc}. 281 | ${hobbyDesc}. 282 | Background: ${seasonBg}, and also ${rhythmBg}. 283 | ${planItems}. 284 | High detail, soft lighting, kawaii 3D character illustration, cinematic lighting, no text, no watermark. 285 | `.trim(); 286 | } 287 | 288 | // description for ideal match when IG is private 289 | function buildIdealDescription(match: IdealMatch): string { 290 | const parts: string[] = []; 291 | 292 | if (match.lovePriority === "thrilled") { 293 | parts.push( 294 | "They care a lot about keeping the spark alive and creating exciting, heart-fluttering moments." 295 | ); 296 | } else if (match.lovePriority === "growth") { 297 | parts.push( 298 | "They value growing together and supporting each other's long-term goals." 299 | ); 300 | } else if (match.lovePriority === "humor code") { 301 | parts.push( 302 | "They believe that sharing the same sense of humor is one of the most important parts of a relationship." 303 | ); 304 | } 305 | 306 | if (match.dateFrequency === "everyday") { 307 | parts.push( 308 | "This pudding would love to stay closely connected and spend time together very frequently." 309 | ); 310 | } else if (match.dateFrequency === "biweekly") { 311 | parts.push( 312 | "They prefer a balanced rhythm, meeting regularly without feeling too rushed." 313 | ); 314 | } else if (match.dateFrequency === "monthly") { 315 | parts.push( 316 | "They are comfortable with more personal space and meaningful, less frequent dates." 317 | ); 318 | } 319 | 320 | if (match.conflictStyle === "immediate") { 321 | parts.push( 322 | "When conflicts happen, they prefer to talk honestly and solve things as soon as possible." 323 | ); 324 | } else if (match.conflictStyle === "afterThinking") { 325 | parts.push( 326 | "In conflicts, they need a bit of time to organize their thoughts before having a calm conversation." 327 | ); 328 | } 329 | 330 | return parts.join(" "); 331 | } 332 | 333 | // base pudding result + smooth reason 334 | function pickPudding(answers: AnswerMap): PuddingResult { 335 | const puddingId = decideBasePuddingId(answers); 336 | const name = getPuddingName(puddingId); 337 | const imagePrompt = buildImagePrompt(answers, puddingId); 338 | 339 | const energy = answers["energy"]; 340 | const plan = answers["plan"]; 341 | const hobby = answers["hobby"]; 342 | const rhythm = answers["rhythm"]; 343 | const season = answers["season"]; 344 | 345 | const reasonParts: string[] = []; 346 | 347 | if (energy === "extrovert") { 348 | reasonParts.push( 349 | "You get energized by being around people, so your pudding naturally carries a bright and outgoing vibe." 350 | ); 351 | } else if (energy === "introvert") { 352 | reasonParts.push( 353 | "You recharge by spending time on your own, which gives your pudding a calm and introspective feel." 354 | ); 355 | } 356 | 357 | if (plan === "planned type") { 358 | reasonParts.push( 359 | "Because you like to plan ahead and stay organized, your pudding feels steady, thoughtful, and reliable." 360 | ); 361 | } else if (plan === "impromptu") { 362 | reasonParts.push( 363 | "Since you enjoy being spontaneous and going with the flow, your pudding radiates a playful and free-spirited energy." 364 | ); 365 | } 366 | 367 | if (hobby) { 368 | reasonParts.push( 369 | `Your hobby (“${hobby}”) also shaped the tiny props and overall atmosphere around your pudding character.` 370 | ); 371 | } 372 | 373 | if (rhythm === "morning") { 374 | reasonParts.push( 375 | "As a morning type, you fit naturally into a bright sky with soft sunlight and gentle clouds." 376 | ); 377 | } else if (rhythm === "night") { 378 | reasonParts.push( 379 | "As a night type, a moonlit, starry sky becomes the perfect backdrop for your pudding world." 380 | ); 381 | } 382 | 383 | if (season) { 384 | reasonParts.push( 385 | `Your favorite season (“${season}”) sets the seasonal mood and color palette of your entire pudding scene.` 386 | ); 387 | } 388 | 389 | const reason = `You turned into ${name} based on your answers. ${reasonParts.join( 390 | " " 391 | )}`; 392 | 393 | return { 394 | id: puddingId, 395 | name, 396 | reason, 397 | imagePrompt, 398 | }; 399 | } 400 | 401 | /** ========================= 402 | * Main Component 403 | * ========================= */ 404 | 405 | export default function Home() { 406 | const [started, setStarted] = useState(false); 407 | const [step, setStep] = useState(0); 408 | const [answers, setAnswers] = useState({}); 409 | const [finishedQuestions, setFinishedQuestions] = useState(false); 410 | 411 | const [nickname, setNickname] = useState(""); 412 | const [shareInstagram, setShareInstagram] = useState(null); 413 | const [instagramId, setInstagramId] = useState(""); 414 | 415 | const [result, setResult] = useState(null); 416 | const [idealMatches, setIdealMatches] = useState([]); 417 | 418 | // image states (Gemini / Imagen) 419 | const [imageUrl, setImageUrl] = useState(null); 420 | const [isLoadingImage, setIsLoadingImage] = useState(false); 421 | const [imageError, setImageError] = useState(null); 422 | 423 | const current = QUESTIONS[step]; 424 | const progress = Math.round((step / QUESTIONS.length) * 100); 425 | 426 | const handleSelect = (qid: string, value: string) => { 427 | setAnswers((prev) => ({ ...prev, [qid]: value })); 428 | }; 429 | 430 | const handleNext = () => { 431 | if (!current) return; 432 | if (!answers[current.id]) return; 433 | 434 | if (step < QUESTIONS.length - 1) { 435 | setStep((s) => s + 1); 436 | } else { 437 | // finished Q10 → go to profile step 438 | setFinishedQuestions(true); 439 | } 440 | }; 441 | 442 | // Call server API to generate image 443 | const generatePuddingImage = useCallback( 444 | async (prompt: string | undefined) => { 445 | if (!prompt) return; 446 | 447 | setIsLoadingImage(true); 448 | setImageError(null); 449 | setImageUrl(null); 450 | 451 | try { 452 | const res = await fetch(IMAGE_API_URL, { 453 | method: "POST", 454 | headers: { "Content-Type": "application/json" }, 455 | body: JSON.stringify({ prompt }), 456 | }); 457 | 458 | const data: any = await res.json(); 459 | 460 | if (!res.ok) { 461 | console.error("API route error:", data); 462 | setImageError(data.error || "Gemini API error"); 463 | return; 464 | } 465 | 466 | const base64Data = data.imageBase64; 467 | if (!base64Data) { 468 | setImageError("No image data returned from server."); 469 | return; 470 | } 471 | 472 | const url = `data:image/png;base64,${base64Data}`; 473 | setImageUrl(url); 474 | } catch (err) { 475 | console.error("Fetch /api/generate_image failed:", err); 476 | setImageError( 477 | "Failed to connect to the image generation service. Please try again." 478 | ); 479 | } finally { 480 | setIsLoadingImage(false); 481 | } 482 | }, 483 | [] 484 | ); 485 | 486 | // Supabase 기반: 프로필 저장 + 이상형 매칭 487 | const handleCreateProfileAndResult = async () => { 488 | if (!nickname || shareInstagram === null) return; 489 | 490 | const pudding = pickPudding(answers); 491 | 492 | try { 493 | const res = await fetch("/api/profiles", { 494 | method: "POST", 495 | headers: { "Content-Type": "application/json" }, 496 | body: JSON.stringify({ 497 | nickname, 498 | instagramId: shareInstagram ? instagramId : null, 499 | gender: answers["gender"], 500 | lovePriority: answers["lovePriority"], 501 | dateFrequency: answers["dateFrequency"], 502 | conflictStyle: answers["conflictStyle"], 503 | }), 504 | }); 505 | 506 | const data = await res.json(); 507 | 508 | if (!res.ok) { 509 | console.error("profiles API error", data); 510 | setIdealMatches([]); 511 | } else { 512 | setIdealMatches(data.idealMatches ?? []); 513 | } 514 | } catch (e) { 515 | console.error("profiles API fetch failed", e); 516 | setIdealMatches([]); 517 | } 518 | 519 | setResult(pudding); 520 | generatePuddingImage(pudding.imagePrompt); 521 | }; 522 | 523 | const handleRestart = () => { 524 | setStarted(false); 525 | setStep(0); 526 | setAnswers({}); 527 | setFinishedQuestions(false); 528 | setNickname(""); 529 | setShareInstagram(null); 530 | setInstagramId(""); 531 | setResult(null); 532 | setIdealMatches([]); 533 | setImageUrl(null); 534 | setIsLoadingImage(false); 535 | setImageError(null); 536 | }; 537 | 538 | const loadingIndicator = ( 539 |
540 | 546 | 554 | 560 | 561 | 562 | Generating your pudding... Please wait! 563 | 564 |
565 | ); 566 | 567 | const errorDisplay = 568 | imageError && result ? ( 569 |
570 |

571 | 😭 Image generation error 572 |

573 |

{imageError}

574 | 580 |
581 | ) : null; 582 | 583 | return ( 584 |
585 |
586 | {/* Start Screen */} 587 | {!started && !result && !finishedQuestions && ( 588 |
589 |
🍮✨
590 |

591 | Find my little pudding 592 |

593 |

594 | Answer 10 questions about your personality and dating style, 595 |
596 | and let AI create a one-of-a-kind pudding character just for you. 597 |

598 | 604 |
605 | )} 606 | 607 | {/* Question Screen */} 608 | {started && !finishedQuestions && !result && current && ( 609 |
610 |
611 | 612 | Q{step + 1} / {QUESTIONS.length} 613 | 614 | {progress}% 615 |
616 |
617 |
621 |
622 | 623 |

624 | {current.title} 625 |

626 |

627 | {current.question} 628 |

629 | 630 |
631 | {current.options.map((opt) => ( 632 | 643 | ))} 644 |
645 | 646 |
647 | 654 |
655 |
656 | )} 657 | 658 | {/* Profile Screen (nickname + Instagram share) */} 659 | {finishedQuestions && !result && ( 660 |
661 |

662 | Almost done! ✨ 663 |

664 |

665 | Set up your profile so we can show your pudding and your ideal 666 | matches. 667 |

668 | 669 | {/* Nickname */} 670 |
671 | 674 | setNickname(e.target.value)} 677 | className="w-full px-3 py-2 rounded-xl border border-orange-200 text-sm" 678 | placeholder="How should we call your pudding?" 679 | /> 680 |
681 | 682 | {/* Instagram share yes/no */} 683 |
684 |

685 | Do you want to share your Instagram with your matches? 686 |

687 |
688 | 699 | 710 |
711 |
712 | 713 | {/* Instagram ID (only if Yes) */} 714 | {shareInstagram === true && ( 715 |
716 | 719 | setInstagramId(e.target.value)} 722 | className="w-full px-3 py-2 rounded-xl border border-orange-200 text-sm" 723 | placeholder="your_instagram_id" 724 | /> 725 |
726 | )} 727 | 728 |
729 | 737 |
738 |
739 | )} 740 | 741 | {/* Result Screen */} 742 | {result && ( 743 |
744 |
745 |
🎉
746 |

747 | {nickname ? `${nickname}, you are...` : "You are..."} 748 |
749 | 750 | {result.name} 751 | 752 |

753 |
754 | 755 | {/* Image Section */} 756 |
757 | {isLoadingImage 758 | ? loadingIndicator 759 | : imageError 760 | ? errorDisplay 761 | : imageUrl 762 | ? ( 763 |
764 | {`${result.name} 769 |
770 | ) 771 | : ( 772 |
773 | 774 | Image will appear here once generated. 775 | 776 |
777 | )} 778 |
779 | 780 |

781 | {result.reason} 782 |

783 | 784 | {/* Ideal Matches */} 785 |
786 |

787 | 💖 Your top 3 ideal pudding matches 💖 788 |

789 |
790 | {idealMatches.map((m, index) => { 791 | const showInstagram = m.shareInstagram && m.instagramId; 792 | const desc = buildIdealDescription(m); 793 | 794 | return ( 795 |
799 |
804 | #{index + 1} · {m.nickname} 805 |
806 |
807 | {m.puddingName} 808 |
809 |
810 | Gender vibe: {m.gender === "male" ? "Male" : "Female"} 811 |
812 | 813 | {showInstagram ? ( 814 |

815 | Instagram: {m.instagramId} 816 |

817 | ) : ( 818 |

819 | {desc} 820 |

821 | )} 822 |
823 | ); 824 | })} 825 |
826 |
827 | 828 |
829 | 835 |
836 |
837 | )} 838 |
839 |
840 | ); 841 | } 842 | --------------------------------------------------------------------------------