From 9c47df561b8dd3214303e4190714860b53e653d6 Mon Sep 17 00:00:00 2001 From: Haewon Kam Date: Thu, 1 Oct 2026 10:21:09 +0900 Subject: [PATCH] =?UTF-8?q?feat(contact):=20=ED=91=B8=ED=84=B0=20=EC=97=B0?= =?UTF-8?q?=EB=9D=BD=EC=B2=98=EC=99=80=20=EB=AC=B8=EC=9D=98=20=ED=8F=BC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80,=20=EA=B5=AC=EA=B8=80=EC=8B=9C=ED=8A=B8=20?= =?UTF-8?q?=EC=A0=81=EC=9E=AC=C2=B7=EB=A9=94=EC=9D=BC=20=EC=95=8C=EB=A6=BC?= =?UTF-8?q?=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 연락처 통일 - 사이트 안에 이메일이 contact@o2o.kr · o2oteam@o2o.kr 두 갈래로 갈라져 있던 것을 sales@o2o.kr 하나로 통일 (lib/contact.ts 단일 출처) - 전화번호 010.2755.6463 을 처음으로 명시. discoveryContact.ts 는 기존 import 경로를 깨지 않도록 재내보내기만 한다 푸터·문의 폼 - 푸터에 전화·이메일과 문의하기 버튼 추가 (사업자명·주소는 확인된 값이 없어 넣지 않는다) - ContactModal 신규: 성함·회사·전화·이메일·문의내용. 화면 구조는 기존 DiscoveryLeadModal 을 그대로 따라 모달이 두 종류로 보이지 않게 한다 - Navbar 문의하기를 mailto 에서 모달로 교체. 메일 앱이 없는 사용자가 mailto 에서 이탈하던 것을 막는다 수신 경로 - submitLead: Apps Script 웹앱으로 POST. 이 사이트는 서버 없는 SPA 라 중간 서버를 두지 않았다. Apps Script 는 CORS 프리플라이트에 응답하지 않아 Content-Type 을 text/plain 으로 보내 simple request 로 맞춘다 - scripts/apps-script/lead-intake.gs: 구글시트 행 추가와 알림 메일을 한 실행에서 처리. 시트 트리거는 스크립트가 쓴 변경에 늦거나 누락되는 경우가 있어 doPost 안에서 바로 보낸다 - 엔드포인트 미설정·전송 실패 시 접수된 것처럼 보이지 않게 하고 메일 폴백을 띄운다 (기존 DiscoveryLeadModal 과 같은 규칙) 토큰 실값은 저장소에 넣지 않는다. 배포본 Apps Script 와 Vercel 환경변수(VITE_LEAD_ENDPOINT · VITE_LEAD_TOKEN)에만 둔다. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/apps-script/lead-intake.gs | 146 ++++++++++++ src/components/ContactModal.tsx | 234 +++++++++++++++++++ src/components/Footer.tsx | 63 ++++- src/components/Navbar.tsx | 19 +- src/components/discovery/DiscoveryCta.tsx | 2 +- src/components/discovery/discoveryContact.ts | 11 +- src/lib/contact.ts | 22 +- src/lib/submitLead.ts | 52 +++++ src/pages/LoginPage.tsx | 4 +- 9 files changed, 523 insertions(+), 30 deletions(-) create mode 100644 scripts/apps-script/lead-intake.gs create mode 100644 src/components/ContactModal.tsx create mode 100644 src/lib/submitLead.ts diff --git a/scripts/apps-script/lead-intake.gs b/scripts/apps-script/lead-intake.gs new file mode 100644 index 0000000..0a1d30a --- /dev/null +++ b/scripts/apps-script/lead-intake.gs @@ -0,0 +1,146 @@ +/** + * webforai.kr 문의 폼 수신기 (Google Apps Script 웹앱) + * + * 하는 일 + * 1. 폼 POST 를 받아 구글시트에 행 추가 + * 2. 같은 실행 안에서 알림 메일 발송 (sales@o2o.kr, haewonkam@o2o.kr) + * + * 왜 트리거가 아니라 여기서 메일을 보내는가 + * 시트 onChange 트리거는 스크립트가 쓴 변경에는 늦거나 누락되는 경우가 있다. + * 행을 추가한 직후 같은 실행에서 보내면 "시트에 들어오면 알림" 을 가장 확실하게 지킨다. + * + * ───────────────────────────────────────────────────────────── + * 설치 순서 (haewon 이 직접) + * + * 1. 구글시트를 새로 만든다. 이름 예: "webforai 문의 접수" + * 2. 시트에서 확장프로그램 → Apps Script 를 연다. + * 3. 기본 Code.gs 내용을 지우고 이 파일 전체를 붙여넣는다. + * 4. 아래 SHARED_TOKEN 을 임의의 문자열로 바꾼다 (예: 영문+숫자 24자). + * 빈 문자열로 두면 토큰 검사를 하지 않는다. + * 5. 배포 → 새 배포 → 유형 "웹 앱" + * - 실행 계정: 나 + * - 액세스 권한: 모든 사용자 ← 이 값이어야 사이트에서 호출된다 + * 배포하면 https://script.google.com/macros/s/AKfyc.../exec 주소가 나온다. + * 6. 첫 배포 때 권한 승인을 한 번 묻는다. 승인한다 + * (시트 쓰기 + 메일 발송 권한). + * 7. 그 주소를 Vercel 환경변수에 넣는다 (infinith-discovery 프로젝트): + * VITE_LEAD_ENDPOINT = https://script.google.com/macros/s/AKfyc.../exec + * VITE_LEAD_TOKEN = 4번에서 정한 문자열 (토큰을 쓸 때만) + * 넣은 뒤 재배포해야 반영된다 (Vite 는 빌드 시점에 값을 넣는다). + * + * 코드를 고친 뒤에는 반드시 "배포 → 배포 관리 → 편집 → 버전: 새 버전" 으로 + * 다시 배포해야 반영된다. 저장만 하면 기존 배포는 옛 코드 그대로다. + * ───────────────────────────────────────────────────────────── + */ + +/** 4번에서 정한 값. 사이트의 VITE_LEAD_TOKEN 과 같아야 한다. 빈 문자열이면 검사 안 함. */ +var SHARED_TOKEN = ''; + +/** 알림 받을 주소. */ +var NOTIFY_TO = 'sales@o2o.kr,haewonkam@o2o.kr'; + +/** 행이 쌓일 시트 탭 이름. 없으면 만든다. */ +var SHEET_NAME = '문의'; + +var HEADERS = ['접수시각', '성함', '회사', '전화', '이메일', '문의내용', '유입', 'User-Agent']; + +function doPost(e) { + try { + var body = {}; + if (e && e.postData && e.postData.contents) { + body = JSON.parse(e.postData.contents); + } + + if (SHARED_TOKEN && body.token !== SHARED_TOKEN) { + return json_({ ok: false, error: 'unauthorized' }); + } + + // 이름이 비어 있으면 받지 않는다. 사이트 폼도 같은 규칙으로 막는다. + var name = trim_(body.name); + if (!name) { + return json_({ ok: false, error: 'name required' }); + } + + var row = [ + new Date(), + name, + trim_(body.company), + trim_(body.phone), + trim_(body.email), + trim_(body.message), + trim_(body.source), + e && e.parameter ? trim_(e.parameter.ua) : '', + ]; + + appendRow_(row); + notify_(row); + + return json_({ ok: true }); + } catch (err) { + // 실패를 삼키지 않는다. 실행 로그에 남기고 사이트에도 실패를 알린다. + console.error('doPost 실패: ' + err); + return json_({ ok: false, error: String(err) }); + } +} + +/** 브라우저로 주소를 열었을 때 살아 있는지 확인용. */ +function doGet() { + return json_({ ok: true, service: 'webforai lead intake' }); +} + +function appendRow_(row) { + var ss = SpreadsheetApp.getActiveSpreadsheet(); + var sh = ss.getSheetByName(SHEET_NAME); + if (!sh) { + sh = ss.insertSheet(SHEET_NAME); + } + if (sh.getLastRow() === 0) { + sh.appendRow(HEADERS); + sh.getRange(1, 1, 1, HEADERS.length).setFontWeight('bold'); + sh.setFrozenRows(1); + } + sh.appendRow(row); +} + +function notify_(row) { + var subject = '[webforai 문의] ' + row[1] + (row[2] ? ' · ' + row[2] : ''); + var lines = [ + '성함: ' + (row[1] || '-'), + '회사: ' + (row[2] || '-'), + '전화: ' + (row[3] || '-'), + '이메일: ' + (row[4] || '-'), + '유입: ' + (row[6] || '-'), + '', + '문의 내용:', + row[5] || '-', + '', + '시트: ' + SpreadsheetApp.getActiveSpreadsheet().getUrl(), + ]; + MailApp.sendEmail({ + to: NOTIFY_TO, + subject: subject, + body: lines.join('\n'), + // 회신하면 문의한 사람에게 바로 가게 한다. 이메일을 안 남겼으면 설정하지 않는다. + replyTo: row[4] || undefined, + }); +} + +function json_(obj) { + return ContentService.createTextOutput(JSON.stringify(obj)).setMimeType( + ContentService.MimeType.JSON + ); +} + +function trim_(v) { + return v === null || v === undefined ? '' : String(v).trim(); +} + +/** + * 설치 확인용. Apps Script 편집기에서 이 함수를 한 번 실행하면 + * 시트에 테스트 행이 들어가고 알림 메일이 오는지 바로 볼 수 있다. + */ +function testRun() { + var row = [new Date(), '테스트', '설치 확인', '010-0000-0000', 'test@example.com', '설치 확인용 테스트입니다.', 'test', '']; + appendRow_(row); + notify_(row); +} diff --git a/src/components/ContactModal.tsx b/src/components/ContactModal.tsx new file mode 100644 index 0000000..e59aeda --- /dev/null +++ b/src/components/ContactModal.tsx @@ -0,0 +1,234 @@ +/** + * 문의하기 모달. + * + * - 이름·회사·전화·이메일·문의내용 → Apps Script 웹앱 → 구글시트 적재 + 알림 메일 + * - 전송 실패 시 성공한 척하지 않고 mailto 폴백을 보여준다 (정직성 규약). + * DiscoveryLeadModal 과 같은 규칙이다. + * - 화면 구조·색·버튼은 DiscoveryLeadModal 을 그대로 따른다. 같은 사이트에서 모달이 + * 두 종류로 보이지 않게 하기 위해서다. + */ +import { useEffect, useState } from 'react'; +import { AnimatePresence, motion } from 'motion/react'; +import { ArrowRight } from 'lucide-react'; +import { submitLead } from '../lib/submitLead'; +import { CONTACT_EMAIL } from '../lib/contact'; + +interface ContactModalProps { + open: boolean; + onClose: () => void; + /** 어느 버튼에서 열렸는지. 구글시트에 그대로 쌓인다. */ + source: string; +} + +type SubmitState = 'idle' | 'submitting' | 'done' | 'error'; + +const INPUT_CLASS = + 'on-light w-full h-12 px-4 rounded-xl border border-slate-200 bg-white text-[15px] text-primary-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-[#6C5CE7]/40'; + +export default function ContactModal({ open, onClose, source }: ContactModalProps) { + const [name, setName] = useState(''); + const [company, setCompany] = useState(''); + const [phone, setPhone] = useState(''); + const [email, setEmail] = useState(''); + const [message, setMessage] = useState(''); + const [state, setState] = useState('idle'); + const [fieldError, setFieldError] = useState(null); + + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [open, onClose]); + + // @types/react 미설치 프로젝트 방침이라 React.FormEvent 대신 구조적 타입 사용 + const handleSubmit = async (e: { preventDefault: () => void }) => { + e.preventDefault(); + if (!name.trim()) { + setFieldError('성함을 입력해 주세요.'); + return; + } + if (!email.trim() && !phone.trim()) { + setFieldError('회신받으실 이메일 또는 전화번호 중 하나는 입력해 주세요.'); + return; + } + if (email.trim() && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim())) { + setFieldError('이메일 형식을 확인해 주세요.'); + return; + } + setFieldError(null); + setState('submitting'); + const result = await submitLead({ + name: name.trim(), + company: company.trim(), + phone: phone.trim(), + email: email.trim(), + message: message.trim(), + source, + }); + setState(result.ok ? 'done' : 'error'); + }; + + const fallbackMailto = () => { + const body = `성함: ${name || '-'}\n회사: ${company || '-'}\n전화: ${phone || '-'}\n이메일: ${email || '-'}\n\n문의 내용:\n${message || '-'}`; + return `mailto:${CONTACT_EMAIL}?subject=${encodeURIComponent('[INFINITH] 도입 문의')}&body=${encodeURIComponent(body)}`; + }; + + return ( + + {open && ( + + e.stopPropagation()} + role="dialog" + aria-modal="true" + aria-label="도입 문의" + > + {/* 닫기 (커스텀 filled SVG — 라인 아이콘 금지 규칙) */} + + + {state === 'done' ? ( +
+
+ +
+

문의가 접수되었습니다

+

+ 남겨주신 연락처로 담당자가 회신드립니다. +
+ 문의: {CONTACT_EMAIL} +

+ +
+ ) : ( +
+

도입 문의

+

+ 간단히 남겨주시면 담당자가 회신드립니다. +

+ + + setName(e.target.value)} + placeholder="예: 홍길동 팀장" + autoFocus + className={`${INPUT_CLASS} mb-4`} + /> + + + setCompany(e.target.value)} + placeholder="예: 뷰성형외과" + className={`${INPUT_CLASS} mb-4`} + /> + + + setPhone(e.target.value)} + placeholder="010-0000-0000" + className={`${INPUT_CLASS} mb-3`} + /> + setEmail(e.target.value)} + placeholder="example@company.com" + className={`${INPUT_CLASS} mb-4`} + /> + + +