From 79f16afdc54e9e44b549a46bbcc7105120a408ff Mon Sep 17 00:00:00 2001 From: Hyunkyung Date: Sat, 6 Jun 2026 15:59:16 +0200 Subject: [PATCH 01/10] added changes from main --- apps/web/.env.example | 198 ++++ apps/web/app/(auth)/login/LoginForm.tsx | 147 +++ apps/web/app/(auth)/login/page.tsx | 11 + apps/web/app/(auth)/register/RegisterForm.tsx | 232 ++++ apps/web/app/(auth)/register/page.tsx | 10 + .../mypage/inquiries/NewInquiryForm.tsx | 97 ++ .../app/(portal)/mypage/inquiries/page.tsx | 89 ++ .../mypage/invoices/BankPaymentForm.tsx | 53 + .../[id]/pay/StripeCheckoutButton.tsx | 59 + .../mypage/invoices/[id]/pay/page.tsx | 96 ++ .../web/app/(portal)/mypage/invoices/page.tsx | 183 +++ apps/web/app/(portal)/mypage/layout.tsx | 79 ++ apps/web/app/(portal)/mypage/page.tsx | 173 +++ .../web/app/(portal)/mypage/projects/page.tsx | 196 ++++ apps/web/app/(portal)/mypage/quotes/page.tsx | 108 ++ .../app/admin/inquiries/InquiryReplyForm.tsx | 93 ++ apps/web/app/admin/inquiries/page.tsx | 87 ++ apps/web/app/admin/invoices/new/page.tsx | 185 +++ apps/web/app/admin/invoices/page.tsx | 142 +++ apps/web/app/admin/layout.tsx | 76 ++ apps/web/app/admin/page.tsx | 162 +++ .../projects/[id]/GenerateContractButton.tsx | 67 ++ .../projects/[id]/GenerateInvoicesButton.tsx | 28 + .../admin/projects/[id]/InvoiceIssueForm.tsx | 87 ++ .../admin/projects/[id]/ProjectStatusForm.tsx | 90 ++ apps/web/app/admin/projects/[id]/page.tsx | 200 ++++ apps/web/app/admin/projects/page.tsx | 113 ++ .../quotes/[id]/ConvertToProjectForm.tsx | 147 +++ .../app/admin/quotes/[id]/EstimateBuilder.tsx | 254 ++++ .../app/admin/quotes/[id]/QuoteActions.tsx | 165 +++ apps/web/app/admin/quotes/[id]/page.tsx | 186 +++ apps/web/app/admin/quotes/page.tsx | 127 ++ apps/web/app/admin/settings/SettingsForm.tsx | 192 +++ apps/web/app/admin/settings/page.tsx | 18 + apps/web/app/admin/users/page.tsx | 87 ++ apps/web/app/api/admin/projects-list/route.ts | 21 + apps/web/app/api/auth/[...nextauth]/route.ts | 10 +- apps/web/app/api/contracts/[id]/pdf/route.ts | 67 ++ .../app/api/invoices/[id]/checkout/route.ts | 87 ++ apps/web/app/api/invoices/[id]/pdf/route.ts | 66 ++ apps/web/app/api/leads/route.ts | 43 +- apps/web/app/api/quotes/[id]/pdf/route.ts | 72 ++ apps/web/app/api/webhooks/stripe/route.ts | 115 ++ .../web/app/components/SocialLoginButtons.tsx | 137 +++ apps/web/app/globals.css | 641 ++++++++++ apps/web/app/providers.tsx | 1 + .../app/verify-email/VerifyEmailClient.tsx | 76 ++ apps/web/app/verify-email/page.tsx | 56 + apps/web/lib/actions/auth.ts | 179 +++ apps/web/lib/actions/inquiries.ts | 126 ++ apps/web/lib/actions/invoices.ts | 276 +++++ apps/web/lib/actions/projects.ts | 161 +++ apps/web/lib/actions/quotes.ts | 254 ++++ apps/web/lib/ai.ts | 183 +++ apps/web/lib/auth-providers.ts | 18 + apps/web/lib/auth.ts | 145 +++ apps/web/lib/db.ts | 39 + apps/web/lib/email/index.ts | 412 +++++++ apps/web/lib/pdf/contract-document.tsx | 361 ++++++ apps/web/lib/pdf/invoice-document.tsx | 388 ++++++ apps/web/lib/pdf/quote-document.tsx | 204 ++++ apps/web/lib/pdf/storage.ts | 44 + apps/web/lib/stripe.ts | 66 ++ apps/web/middleware.ts | 54 + apps/web/next.config.js | 33 +- apps/web/package.json | 22 +- .../20260604153106_init/migration.sql | 328 ++++++ .../migration.sql | 22 + .../migration.sql | 8 + .../web/prisma/migrations/migration_lock.toml | 3 + apps/web/prisma/schema.prisma | 429 +++++++ apps/web/prisma/seed.ts | 518 +++++++++ apps/web/public/pdfs/.gitignore | 1 + apps/web/tsconfig.json | 1 + package.json | 3 +- pnpm-lock.yaml | 1036 ++++++++++++++++- 76 files changed, 10923 insertions(+), 20 deletions(-) create mode 100644 apps/web/.env.example create mode 100644 apps/web/app/(auth)/login/LoginForm.tsx create mode 100644 apps/web/app/(auth)/login/page.tsx create mode 100644 apps/web/app/(auth)/register/RegisterForm.tsx create mode 100644 apps/web/app/(auth)/register/page.tsx create mode 100644 apps/web/app/(portal)/mypage/inquiries/NewInquiryForm.tsx create mode 100644 apps/web/app/(portal)/mypage/inquiries/page.tsx create mode 100644 apps/web/app/(portal)/mypage/invoices/BankPaymentForm.tsx create mode 100644 apps/web/app/(portal)/mypage/invoices/[id]/pay/StripeCheckoutButton.tsx create mode 100644 apps/web/app/(portal)/mypage/invoices/[id]/pay/page.tsx create mode 100644 apps/web/app/(portal)/mypage/invoices/page.tsx create mode 100644 apps/web/app/(portal)/mypage/layout.tsx create mode 100644 apps/web/app/(portal)/mypage/page.tsx create mode 100644 apps/web/app/(portal)/mypage/projects/page.tsx create mode 100644 apps/web/app/(portal)/mypage/quotes/page.tsx create mode 100644 apps/web/app/admin/inquiries/InquiryReplyForm.tsx create mode 100644 apps/web/app/admin/inquiries/page.tsx create mode 100644 apps/web/app/admin/invoices/new/page.tsx create mode 100644 apps/web/app/admin/invoices/page.tsx create mode 100644 apps/web/app/admin/layout.tsx create mode 100644 apps/web/app/admin/page.tsx create mode 100644 apps/web/app/admin/projects/[id]/GenerateContractButton.tsx create mode 100644 apps/web/app/admin/projects/[id]/GenerateInvoicesButton.tsx create mode 100644 apps/web/app/admin/projects/[id]/InvoiceIssueForm.tsx create mode 100644 apps/web/app/admin/projects/[id]/ProjectStatusForm.tsx create mode 100644 apps/web/app/admin/projects/[id]/page.tsx create mode 100644 apps/web/app/admin/projects/page.tsx create mode 100644 apps/web/app/admin/quotes/[id]/ConvertToProjectForm.tsx create mode 100644 apps/web/app/admin/quotes/[id]/EstimateBuilder.tsx create mode 100644 apps/web/app/admin/quotes/[id]/QuoteActions.tsx create mode 100644 apps/web/app/admin/quotes/[id]/page.tsx create mode 100644 apps/web/app/admin/quotes/page.tsx create mode 100644 apps/web/app/admin/settings/SettingsForm.tsx create mode 100644 apps/web/app/admin/settings/page.tsx create mode 100644 apps/web/app/admin/users/page.tsx create mode 100644 apps/web/app/api/admin/projects-list/route.ts create mode 100644 apps/web/app/api/contracts/[id]/pdf/route.ts create mode 100644 apps/web/app/api/invoices/[id]/checkout/route.ts create mode 100644 apps/web/app/api/invoices/[id]/pdf/route.ts create mode 100644 apps/web/app/api/quotes/[id]/pdf/route.ts create mode 100644 apps/web/app/api/webhooks/stripe/route.ts create mode 100644 apps/web/app/components/SocialLoginButtons.tsx create mode 100644 apps/web/app/verify-email/VerifyEmailClient.tsx create mode 100644 apps/web/app/verify-email/page.tsx create mode 100644 apps/web/lib/actions/auth.ts create mode 100644 apps/web/lib/actions/inquiries.ts create mode 100644 apps/web/lib/actions/invoices.ts create mode 100644 apps/web/lib/actions/projects.ts create mode 100644 apps/web/lib/actions/quotes.ts create mode 100644 apps/web/lib/ai.ts create mode 100644 apps/web/lib/auth-providers.ts create mode 100644 apps/web/lib/auth.ts create mode 100644 apps/web/lib/db.ts create mode 100644 apps/web/lib/email/index.ts create mode 100644 apps/web/lib/pdf/contract-document.tsx create mode 100644 apps/web/lib/pdf/invoice-document.tsx create mode 100644 apps/web/lib/pdf/quote-document.tsx create mode 100644 apps/web/lib/pdf/storage.ts create mode 100644 apps/web/lib/stripe.ts create mode 100644 apps/web/middleware.ts create mode 100644 apps/web/prisma/migrations/20260604153106_init/migration.sql create mode 100644 apps/web/prisma/migrations/20260604161850_add_email_verification/migration.sql create mode 100644 apps/web/prisma/migrations/20260604170859_add_quote_estimate_fields/migration.sql create mode 100644 apps/web/prisma/migrations/migration_lock.toml create mode 100644 apps/web/prisma/schema.prisma create mode 100644 apps/web/prisma/seed.ts create mode 100644 apps/web/public/pdfs/.gitignore diff --git a/apps/web/.env.example b/apps/web/.env.example new file mode 100644 index 0000000..8da9159 --- /dev/null +++ b/apps/web/.env.example @@ -0,0 +1,198 @@ +# ============================================================ +# ECOWOODS — Environment Variables +# Copy to .env.local and fill in your values. +# NEVER commit .env.local to git. +# ============================================================ + +# ────────────────────────────────────────────────────────────── +# DATABASE (PostgreSQL) +# ────────────────────────────────────────────────────────────── +# Recommended: Supabase (https://supabase.com) +# 1. Create a new Supabase project +# 2. Project Settings → Database → Connection string → URI mode +# 3. Copy the "Connection string" (with your password) +# +# Alternative: Neon (https://neon.tech) — excellent for serverless +# Neon URL format: postgresql://user:pass@ep-xxx.us-east-2.aws.neon.tech/dbname?sslmode=require +# +# Local development: docker-compose up (see docker-compose.yml) +DATABASE_URL="postgresql://ecowoods:ecowoods_secret@localhost:5432/ecowoods_db" + +# For Supabase connection pooling (Prisma Accelerate or pgBouncer) +# Use this for serverless deployments to avoid connection exhaustion: +# DIRECT_URL="postgresql://..." # Direct connection (for migrations) + +# ────────────────────────────────────────────────────────────── +# AUTH.JS v5 (next-auth) +# ────────────────────────────────────────────────────────────── +# Generate a strong secret: openssl rand -base64 32 +NEXTAUTH_SECRET="change-me-generate-with-openssl-rand-base64-32" + +# Your production domain (no trailing slash) +NEXTAUTH_URL="http://localhost:3000" +# Production: NEXTAUTH_URL="https://ecowoods.ca" + +# ────────────────────────────────────────────────────────────── +# SOCIAL LOGIN (OAuth) — 소셜 로그인 +# 설정된 변수만 활성화됩니다. 빈 값이면 해당 버튼은 숨겨집니다. +# Only providers with both ID + SECRET configured will be shown. +# ────────────────────────────────────────────────────────────── + +# ── Google OAuth ─────────────────────────────────────────────── +# 설정 방법: +# 1. console.cloud.google.com → 새 프로젝트 생성 +# 2. 사용자 인증 정보 → OAuth 2.0 클라이언트 ID 생성 +# 3. 애플리케이션 유형: "웹 애플리케이션" +# 4. 승인된 리디렉션 URI에 추가: +# http://localhost:3000/api/auth/callback/google (개발) +# https://ecowoods.ca/api/auth/callback/google (프로덕션) +# 5. 클라이언트 ID / 클라이언트 보안 비밀번호 복사 +AUTH_GOOGLE_ID="" +AUTH_GOOGLE_SECRET="" + +# ── Facebook OAuth ───────────────────────────────────────────── +# 설정 방법: +# 1. developers.facebook.com → 내 앱 → 앱 만들기 +# 2. Facebook 로그인 제품 추가 +# 3. 설정 → 유효한 OAuth 리디렉션 URI 추가: +# https://ecowoods.ca/api/auth/callback/facebook +# 4. 앱 ID / 앱 시크릿 코드 복사 +# 5. 앱 검수 필요 (Facebook 정책 변경 후 공개 앱은 검수 필요) +AUTH_FACEBOOK_ID="" +AUTH_FACEBOOK_SECRET="" + +# ── Apple Sign In ────────────────────────────────────────────── +# 설정 방법 (가장 복잡): +# 1. developer.apple.com → Certificates, IDs & Profiles +# 2. Identifiers → App IDs → Sign In with Apple 체크 +# 3. Identifiers → Services IDs 생성 (예: com.ecowoods.web) +# → Domains: ecowoods.ca +# → Return URLs: https://ecowoods.ca/api/auth/callback/apple +# 4. Keys → Key 생성 → Sign In with Apple 체크 → 다운로드 +# 5. 아래 값 채우기 +AUTH_APPLE_ID="" # Services ID (예: com.ecowoods.web) +AUTH_APPLE_SECRET="" # 개인 키 파일 내용 (-----BEGIN PRIVATE KEY----- ...) +AUTH_APPLE_TEAM_ID="" # Apple Developer Team ID (10자리 영숫자) +AUTH_APPLE_KEY_ID="" # Key ID (10자리 영숫자) + +# ────────────────────────────────────────────────────────────── +# EMAIL — Option A: Resend (권장) +# ────────────────────────────────────────────────────────────── +# resend.com 가입 → API Keys 생성 +# 개발 테스트용: RESEND_FROM_EMAIL=onboarding@resend.dev 그대로 사용 가능 +# (도메인 인증 불필요, Resend가 제공하는 샌드박스 주소) +RESEND_API_KEY="re_xxxxxxxxxx" +RESEND_FROM_EMAIL="onboarding@resend.dev" +ADMIN_EMAIL="your-admin@example.com" + +# ────────────────────────────────────────────────────────────── +# EMAIL — Option B: SMTP (Gmail 등) — Resend 대신 사용 +# ────────────────────────────────────────────────────────────── +# Gmail 사용 시: +# 1. Google 계정 → 보안 → 2단계 인증 활성화 (필수) +# 2. Google 계정 → 보안 → 앱 비밀번호 생성 +# (앱: "메일", 기기: "기타" → "Ecowoods" 입력) +# 3. 생성된 16자리 비밀번호를 SMTP_PASS에 입력 +# pnpm add nodemailer --filter @ecowoods/web 도 실행 필요 +SMTP_HOST="smtp.gmail.com" +SMTP_PORT="587" +SMTP_USER="your.gmail@gmail.com" +SMTP_PASS="xxxx xxxx xxxx xxxx" +SMTP_FROM="your.gmail@gmail.com" + +# ────────────────────────────────────────────────────────────── +# STRIPE (Payments) +# ────────────────────────────────────────────────────────────── +# See full setup guide in lib/stripe.ts +# +# Get keys from: https://dashboard.stripe.com/apikeys +# Use TEST keys for development, LIVE keys for production +STRIPE_SECRET_KEY="sk_test_..." +STRIPE_PUBLISHABLE_KEY="pk_test_..." + +# Webhook signing secret — get from Stripe Dashboard → Webhooks +# For local testing: stripe listen --forward-to localhost:3000/api/webhooks/stripe +STRIPE_WEBHOOK_SECRET="whsec_..." + +# ────────────────────────────────────────────────────────────── +# EMAIL (Resend) +# ────────────────────────────────────────────────────────────── +# Sign up at: https://resend.com (3,000 emails/month free) +# Docs: https://resend.com/docs/introduction +# +# Setup steps: +# 1. Add your domain (ecowoods.ca) in Resend → Domains +# 2. Add SPF, DKIM, DMARC DNS records (shown in Resend dashboard) +# 3. Wait for verification (usually 5-15 minutes) +# 4. Copy your API key below +RESEND_API_KEY="re_..." + +# The "From" address for outgoing emails (must be on a verified domain) +RESEND_FROM_EMAIL="hello@ecowoods.ca" + +# Admin notification email (receives new quotes, inquiries, etc.) +ADMIN_EMAIL="admin@ecowoods.ca" + +# ────────────────────────────────────────────────────────────── +# FILE STORAGE (PDFs — Vercel Blob) +# ────────────────────────────────────────────────────────────── +# Vercel Blob: https://vercel.com/docs/storage/vercel-blob +# In Vercel Dashboard → Storage → Create Blob Store → Connect to project +# The token is auto-injected in Vercel deployments. +# For local development: vercel env pull .env.local +BLOB_READ_WRITE_TOKEN="vercel_blob_rw_..." + +# Alternative: Supabase Storage +# SUPABASE_URL="https://xxx.supabase.co" +# SUPABASE_ANON_KEY="eyJ..." +# SUPABASE_SERVICE_ROLE_KEY="eyJ..." # For server-side uploads + +# ────────────────────────────────────────────────────────────── +# AI FEATURES (Optional — OpenAI) +# ────────────────────────────────────────────────────────────── +# When set AND aiEnabled=true in admin Settings, enables: +# · AI-suggested replies to quote requests +# · AI-assisted contract scope writing +# · AI draft for inquiry replies +# +# Get key from: https://platform.openai.com/api-keys +# Recommended model: gpt-4o-mini (fast + cheap) +OPENAI_API_KEY="" # Leave blank to disable AI features + +# ────────────────────────────────────────────────────────────── +# LEAD CAPTURE (Webhook — Optional) +# ────────────────────────────────────────────────────────────── +# Optional webhook to forward new leads to n8n, Zapier, Make, etc. +# POST body: { leadId, name, email, phone, city, service, ... } +LEADS_WEBHOOK_URL="" + +# ────────────────────────────────────────────────────────────── +# PLAID (Future — Bank Deposit Detection) +# ────────────────────────────────────────────────────────────── +# NOT needed for MVP. Uncomment when ready to auto-detect e-transfers. +# +# Plaid supports Canadian banks: TD, RBC, BMO, Scotiabank, CIBC. +# Requirements: Plaid Business account, business bank account, customer consent. +# Full proposal: see admin/settings for implementation notes. +# +# PLAID_CLIENT_ID="" +# PLAID_SECRET="" # Use sandbox secret for testing +# PLAID_ACCESS_TOKEN="" # Generated after linking your bank account +# PLAID_ENV="sandbox" # "sandbox" | "development" | "production" + +# ────────────────────────────────────────────────────────────── +# DEPLOYMENT (Vercel) +# ────────────────────────────────────────────────────────────── +# These are automatically set by Vercel — don't set them manually: +# VERCEL_URL, VERCEL_ENV, VERCEL_GIT_COMMIT_SHA +# +# Add all variables above to Vercel Dashboard: +# Project → Settings → Environment Variables +# Use different values for Preview and Production environments. +# +# Recommended Vercel setup: +# - Connect GitHub repo for automatic deployments +# - Set DATABASE_URL to your hosted Postgres (Supabase or Neon) +# - Set NEXTAUTH_URL to your production domain +# - Set STRIPE_*_KEY to LIVE keys in production only +# - Set STRIPE_WEBHOOK_SECRET from Stripe Dashboard → Webhooks diff --git a/apps/web/app/(auth)/login/LoginForm.tsx b/apps/web/app/(auth)/login/LoginForm.tsx new file mode 100644 index 0000000..5cd30d1 --- /dev/null +++ b/apps/web/app/(auth)/login/LoginForm.tsx @@ -0,0 +1,147 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import Link from 'next/link'; +import { signIn, getSession } from 'next-auth/react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { toast } from 'sonner'; +import SocialLoginButtons from '@/app/components/SocialLoginButtons'; +import type { OAuthProvider } from '@/lib/auth-providers'; + +const loginSchema = z.object({ + email: z.string().email('Please enter a valid email address'), + password: z.string().min(1, 'Password is required'), +}); +type LoginForm = z.infer; + +export default function LoginForm({ enabledProviders }: { enabledProviders: OAuthProvider[] }) { + const router = useRouter(); + const searchParams = useSearchParams(); + const callbackUrl = searchParams.get('callbackUrl'); + const [isLoading, setIsLoading] = useState(false); + + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ resolver: zodResolver(loginSchema) }); + + const onSubmit = async (data: LoginForm) => { + setIsLoading(true); + try { + const result = await signIn('credentials', { + email: data.email, + password: data.password, + redirect: false, + }); + + if (result?.error) { + toast.error('Invalid email or password. Please try again.'); + return; + } + + toast.success('Welcome back!'); + // If there's an explicit callbackUrl, use it; otherwise route by role + if (callbackUrl) { + router.push(callbackUrl); + } else { + const session = await getSession(); + router.push(session?.user?.role === 'ADMIN' ? '/admin' : '/mypage'); + } + router.refresh(); + } catch (err) { + console.error('[login] error:', err); + toast.error('Something went wrong. Please try again.'); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+ {/* Brand */} +
+ + + + + + +
+
Ecowoods
+
Customer Portal
+
+
+ +

Sign in to your account

+

Access your quotes, projects, and invoices.

+ + {/* Social login */} + {enabledProviders.length > 0 && ( + <> + +
or sign in with email
+ + )} + + {/* Email form */} +
+
+ + + {errors.email &&

{errors.email.message}

} +
+ +
+ + + {errors.password &&

{errors.password.message}

} +
+ + +
+ +

+ Don't have an account?{' '} + Create one — it's free +

+ + {/* Demo credentials (dev only) */} + {process.env.NODE_ENV === 'development' && ( + <> +
Demo credentials
+
+

Customer: sarah.miller@gmail.com / Customer2026!

+

Admin: admin@ecowoods.ca / Admin2026!

+
+ + )} +
+
+ ); +} diff --git a/apps/web/app/(auth)/login/page.tsx b/apps/web/app/(auth)/login/page.tsx new file mode 100644 index 0000000..b2ec436 --- /dev/null +++ b/apps/web/app/(auth)/login/page.tsx @@ -0,0 +1,11 @@ +/** + * 로그인 페이지 — 서버 컴포넌트 (wrapper) + * Provider 목록을 서버에서 읽어 클라이언트 컴포넌트에 전달합니다. + */ +import { getEnabledProviders } from '@/lib/auth-providers'; +import LoginForm from './LoginForm'; + +export default function LoginPage() { + const enabledProviders = getEnabledProviders(); + return ; +} diff --git a/apps/web/app/(auth)/register/RegisterForm.tsx b/apps/web/app/(auth)/register/RegisterForm.tsx new file mode 100644 index 0000000..68480fa --- /dev/null +++ b/apps/web/app/(auth)/register/RegisterForm.tsx @@ -0,0 +1,232 @@ +'use client'; + +import { useState } from 'react'; +import Link from 'next/link'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { toast } from 'sonner'; +import SocialLoginButtons from '@/app/components/SocialLoginButtons'; +import { registerUser } from '@/lib/actions/auth'; +import type { OAuthProvider } from '@/lib/auth-providers'; + +const registerSchema = z.object({ + name: z.string().min(2, 'Name must be at least 2 characters'), + email: z.string().email('Please enter a valid email address'), + phone: z.string().optional(), + password: z + .string() + .min(8, 'Password must be at least 8 characters') + .regex(/[A-Z]/, 'Must contain at least one uppercase letter') + .regex(/[0-9]/, 'Must contain at least one number'), + confirmPassword: z.string(), +}).refine((d) => d.password === d.confirmPassword, { + message: 'Passwords do not match', + path: ['confirmPassword'], +}); +type RegisterData = z.infer; + +type Step = 'form' | 'check-email'; + +export default function RegisterForm({ enabledProviders }: { enabledProviders: OAuthProvider[] }) { + const [step, setStep] = useState('form'); + const [submittedEmail, setSubmittedEmail] = useState(''); + const [devVerifyUrl, setDevVerifyUrl] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + const { register, handleSubmit, formState: { errors } } = useForm({ + resolver: zodResolver(registerSchema), + }); + + const onSubmit = async (data: RegisterData) => { + setIsLoading(true); + try { + const result = await registerUser({ + name: data.name, + email: data.email, + phone: data.phone, + password: data.password, + }); + + if (!result.success) { + toast.error(result.error ?? 'Registration failed. Please try again.'); + return; + } + + setSubmittedEmail(data.email); + if (result.devVerifyUrl) setDevVerifyUrl(result.devVerifyUrl); + setStep('check-email'); + } catch (err) { + console.error('[register] error:', err); + toast.error('Something went wrong. Please try again.'); + } finally { + setIsLoading(false); + } + }; + + // ── Step 2: "Check your email" screen ──────────────────────────────────── + if (step === 'check-email') { + return ( +
+
+
📧
+

Check your email

+

+ We sent a verification link to {submittedEmail}. + Click the link in the email to activate your account. +

+

+ The link expires in 30 minutes. Check your spam folder if you don't see it. +

+ + {/* Dev mode shortcut — shown when RESEND is not configured */} + {devVerifyUrl && ( +
+

+ 🛠 Dev mode — no email service configured +

+

+ Click the link below to verify directly (also visible in your terminal): +

+ + Verify my account → + +
+ )} + +

+ Already verified? Sign in +

+
+
+ ); + } + + // ── Step 1: Registration form ───────────────────────────────────────────── + return ( +
+
+ {/* Brand */} +
+ + + + + + +
+
Ecowoods
+
Create your account
+
+
+ +

Create a free account

+

+ Track quotes, download contracts, and pay invoices online. + Previous quote requests with this email will be linked automatically. +

+ + {enabledProviders.length > 0 && ( + <> + +
or sign up with email
+ + )} + +
+
+
+ + + {errors.name &&

{errors.name.message}

} +
+
+ + +
+
+ +
+ + + {errors.email &&

{errors.email.message}

} +
+ +
+
+ + + {errors.password &&

{errors.password.message}

} +
+
+ + + {errors.confirmPassword &&

{errors.confirmPassword.message}

} +
+
+ + + +

+ By creating an account you agree to our{' '} + Privacy Policy{' '}and{' '} + Terms of Service. +

+
+ +

+ Already have an account? Sign in +

+
+
+ ); +} diff --git a/apps/web/app/(auth)/register/page.tsx b/apps/web/app/(auth)/register/page.tsx new file mode 100644 index 0000000..9705320 --- /dev/null +++ b/apps/web/app/(auth)/register/page.tsx @@ -0,0 +1,10 @@ +/** + * 회원가입 페이지 — 서버 컴포넌트 (wrapper) + */ +import { getEnabledProviders } from '@/lib/auth-providers'; +import RegisterForm from './RegisterForm'; + +export default function RegisterPage() { + const enabledProviders = getEnabledProviders(); + return ; +} diff --git a/apps/web/app/(portal)/mypage/inquiries/NewInquiryForm.tsx b/apps/web/app/(portal)/mypage/inquiries/NewInquiryForm.tsx new file mode 100644 index 0000000..b7b33be --- /dev/null +++ b/apps/web/app/(portal)/mypage/inquiries/NewInquiryForm.tsx @@ -0,0 +1,97 @@ +'use client'; + +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { toast } from 'sonner'; +import { useRouter } from 'next/navigation'; +import { submitInquiry } from '@/lib/actions/inquiries'; + +const schema = z.object({ + subject: z.string().min(3, 'Subject is required').max(200), + message: z.string().min(10, 'Message must be at least 10 characters').max(3000), + phone: z.string().optional(), +}); +type FormData = z.infer; + +export default function NewInquiryForm() { + const router = useRouter(); + const [submitting, setSubmitting] = useState(false); + + const { register, handleSubmit, formState: { errors }, reset } = useForm({ + resolver: zodResolver(schema), + }); + + const onSubmit = async (data: FormData) => { + setSubmitting(true); + try { + const result = await submitInquiry({ + name: '', // filled from session on server + email: '', // filled from session on server + subject: data.subject, + message: data.message, + phone: data.phone, + }); + + if (!result.success) { + toast.error(result.error ?? 'Failed to send. Please try again.'); + return; + } + + toast.success('Message sent! Our team will respond within 1 business day.'); + reset(); + router.refresh(); + } catch { + toast.error('Something went wrong. Please try again.'); + } finally { + setSubmitting(false); + } + }; + + return ( +
+
+ + + {errors.subject &&

{errors.subject.message}

} +
+ +
+ +