├── 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 | 
48 | 
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 |
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 |
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 |

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 |
--------------------------------------------------------------------------------