feat(contact): 푸터 연락처와 문의 폼 추가, 구글시트 적재·메일 알림 연동
연락처 통일 - 사이트 안에 이메일이 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) <noreply@anthropic.com>
This commit is contained in:
parent
c0804dd2bd
commit
9c47df561b
146
scripts/apps-script/lead-intake.gs
Normal file
146
scripts/apps-script/lead-intake.gs
Normal file
@ -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);
|
||||
}
|
||||
234
src/components/ContactModal.tsx
Normal file
234
src/components/ContactModal.tsx
Normal file
@ -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<SubmitState>('idle');
|
||||
const [fieldError, setFieldError] = useState<string | null>(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 (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="on-dark fixed inset-0 z-50 flex items-center justify-center px-6 py-10 overflow-y-auto bg-[#0A1128]/60 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
data-no-print
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 24, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 24, scale: 0.98 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
className="on-light relative w-full max-w-md bg-white rounded-2xl shadow-2xl p-8 my-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="도입 문의"
|
||||
>
|
||||
{/* 닫기 (커스텀 filled SVG — 라인 아이콘 금지 규칙) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="닫기"
|
||||
className="absolute top-4 right-4 w-8 h-8 flex items-center justify-center rounded-full bg-slate-100 hover:bg-slate-200 transition-colors"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" aria-hidden="true">
|
||||
<path
|
||||
d="M2.3 0.9 6 4.6 9.7 0.9a1 1 0 0 1 1.4 1.4L7.4 6l3.7 3.7a1 1 0 0 1-1.4 1.4L6 7.4 2.3 11.1a1 1 0 0 1-1.4-1.4L4.6 6 0.9 2.3A1 1 0 0 1 2.3 0.9Z"
|
||||
fill="#0A1128"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{state === 'done' ? (
|
||||
<div className="flex flex-col items-center text-center py-4">
|
||||
<div className="w-14 h-14 rounded-full bg-[#F3F0FF] flex items-center justify-center mb-5">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path
|
||||
d="M9.6 16.2 4.8 11.4a1.2 1.2 0 0 1 1.7-1.7l3.1 3.1 7.9-7.9a1.2 1.2 0 0 1 1.7 1.7l-8.8 8.8a1.2 1.2 0 0 1-1.7 0Z"
|
||||
fill="#6C5CE7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-primary-900 mb-2">문의가 접수되었습니다</h3>
|
||||
<p className="ui-body mb-6">
|
||||
남겨주신 연락처로 담당자가 회신드립니다.
|
||||
<br />
|
||||
문의: {CONTACT_EMAIL}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="inline-flex items-center h-11 px-8 bg-gradient-to-r from-[#4F1DA1] to-[#021341] rounded-full text-sm font-bold text-white hover:opacity-90 transition-opacity"
|
||||
>
|
||||
확인
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<h3 className="text-xl font-bold text-primary-900 mb-1.5">도입 문의</h3>
|
||||
<p className="ui-body mb-6">
|
||||
간단히 남겨주시면 담당자가 회신드립니다.
|
||||
</p>
|
||||
|
||||
<label className="block text-xs font-semibold text-slate-500 mb-1.5">
|
||||
성함 <span className="text-[#7C3A4B]">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="예: 홍길동 팀장"
|
||||
autoFocus
|
||||
className={`${INPUT_CLASS} mb-4`}
|
||||
/>
|
||||
|
||||
<label className="block text-xs font-semibold text-slate-500 mb-1.5">
|
||||
회사 <span className="text-slate-400 font-normal">(선택)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={company}
|
||||
onChange={(e) => setCompany(e.target.value)}
|
||||
placeholder="예: 뷰성형외과"
|
||||
className={`${INPUT_CLASS} mb-4`}
|
||||
/>
|
||||
|
||||
<label className="block text-xs font-semibold text-slate-500 mb-1.5">
|
||||
연락처 <span className="text-slate-400 font-normal">(전화·이메일 중 하나 이상)</span>
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
placeholder="010-0000-0000"
|
||||
className={`${INPUT_CLASS} mb-3`}
|
||||
/>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="example@company.com"
|
||||
className={`${INPUT_CLASS} mb-4`}
|
||||
/>
|
||||
|
||||
<label className="block text-xs font-semibold text-slate-500 mb-1.5">
|
||||
문의 내용 <span className="text-slate-400 font-normal">(선택)</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
rows={4}
|
||||
placeholder="검토 중인 내용이나 현재 겪는 어려움을 적어주세요."
|
||||
className="on-light w-full px-4 py-3 mb-4 rounded-xl border border-slate-200 bg-white text-[15px] leading-relaxed text-primary-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-[#6C5CE7]/40 resize-none"
|
||||
/>
|
||||
|
||||
{fieldError && (
|
||||
<p className="text-[13px] text-[#7C3A4B] bg-[#FFF0F0] border border-[#F5D5DC] rounded-lg px-3.5 py-2.5 mb-4">
|
||||
{fieldError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{state === 'error' && (
|
||||
<p className="text-[13px] leading-relaxed text-[#7C5C3A] bg-[#FFF6ED] border border-[#F5E0C5] rounded-lg px-3.5 py-2.5 mb-4">
|
||||
전송에 실패했습니다. 아래 이메일로 직접 보내주세요.
|
||||
<br />
|
||||
<a href={fallbackMailto()} className="font-semibold underline">
|
||||
{CONTACT_EMAIL}로 문의 메일 보내기
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={state === 'submitting'}
|
||||
className="w-full inline-flex items-center justify-center gap-2 h-[52px] bg-gradient-to-r from-[#4F1DA1] to-[#021341] rounded-full text-base font-bold text-white hover:opacity-90 transition-opacity disabled:opacity-60"
|
||||
>
|
||||
{state === 'submitting' ? '보내는 중…' : '문의 보내기'}
|
||||
{state !== 'submitting' && <ArrowRight className="w-5 h-5" />}
|
||||
</button>
|
||||
|
||||
<p className="text-xs text-slate-400 text-center mt-4">
|
||||
입력 정보는 문의 회신 용도로만 사용합니다.
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@ -1,21 +1,62 @@
|
||||
/**
|
||||
* Footer — 전역 하단.
|
||||
*
|
||||
* 2026-10-01: 연락처(전화·이메일)와 문의하기 버튼을 넣었다. 그 전에는 푸터에 연락 수단이
|
||||
* 하나도 없었다. 사업자명·주소는 확인된 값이 없어 넣지 않는다 (지어내지 않는다).
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import ContactModal from './ContactModal';
|
||||
import { CONTACT_EMAIL, CONTACT_PHONE, CONTACT_PHONE_HREF } from '../lib/contact';
|
||||
|
||||
export default function Footer() {
|
||||
const [contactOpen, setContactOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<footer className="bg-white border-t border-slate-100 py-12 px-6">
|
||||
<div className="max-w-7xl mx-auto flex flex-col md:flex-row items-center justify-between gap-6">
|
||||
<Link to="/" className="flex items-center gap-2">
|
||||
<span className="font-serif text-3xl font-black tracking-[0.05em] bg-gradient-to-r from-[#4F1DA1] to-[#021341] bg-clip-text text-transparent">INFINITH</span>
|
||||
</Link>
|
||||
<div className="text-sm text-slate-500 text-center md:text-left">
|
||||
© {new Date().getFullYear()} INFINITH. All rights reserved. <br className="md:hidden" />
|
||||
Infinite Marketing for Premium Medical Business & Marketing Agency
|
||||
<footer className="bg-white border-t border-slate-100 py-12 px-6" data-no-print>
|
||||
<div className="max-w-7xl mx-auto flex flex-col gap-8">
|
||||
<div className="flex flex-col md:flex-row items-center justify-between gap-6">
|
||||
<Link to="/" className="flex items-center gap-2">
|
||||
<span className="font-serif text-3xl font-black tracking-[0.05em] bg-gradient-to-r from-[#4F1DA1] to-[#021341] bg-clip-text text-transparent">INFINITH</span>
|
||||
</Link>
|
||||
<div className="text-sm text-slate-500 text-center md:text-left">
|
||||
© {new Date().getFullYear()} INFINITH. All rights reserved. <br className="md:hidden" />
|
||||
Infinite Marketing for Premium Medical Business & Marketing Agency
|
||||
</div>
|
||||
<div className="flex items-center gap-6 text-sm font-medium text-slate-600">
|
||||
<a href="#" className="hover:text-primary-900 transition-colors">Privacy Policy</a>
|
||||
<a href="#" className="hover:text-primary-900 transition-colors">Terms of Service</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-6 text-sm font-medium text-slate-600">
|
||||
<a href="#" className="hover:text-primary-900 transition-colors">Privacy Policy</a>
|
||||
<a href="#" className="hover:text-primary-900 transition-colors">Terms of Service</a>
|
||||
|
||||
{/* 연락처 */}
|
||||
<div className="border-t border-slate-100 pt-7 flex flex-col md:flex-row md:items-center justify-between gap-5">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-x-6 gap-y-2 text-sm text-slate-600 text-center sm:text-left">
|
||||
<span>
|
||||
<span className="text-slate-400 mr-2">전화</span>
|
||||
<a href={CONTACT_PHONE_HREF} className="font-medium hover:text-primary-900 transition-colors">
|
||||
{CONTACT_PHONE}
|
||||
</a>
|
||||
</span>
|
||||
<span className="hidden sm:inline text-slate-200">·</span>
|
||||
<span>
|
||||
<span className="text-slate-400 mr-2">이메일</span>
|
||||
<a href={`mailto:${CONTACT_EMAIL}`} className="font-medium hover:text-primary-900 transition-colors">
|
||||
{CONTACT_EMAIL}
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setContactOpen(true)}
|
||||
className="self-center md:self-auto inline-flex items-center justify-center px-6 py-2.5 text-sm font-semibold text-white rounded-full transition-all shadow-sm hover:shadow-md bg-gradient-to-r from-[#4F1DA1] to-[#021341] hover:opacity-90"
|
||||
>
|
||||
문의하기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ContactModal open={contactOpen} onClose={() => setContactOpen(false)} source="footer" />
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
@ -4,27 +4,31 @@
|
||||
* PART III 피봇:
|
||||
* - "Free Report" CTA 완전 제거 (계약 기반 영업 모델로 전환).
|
||||
* - Login 버튼 복원 — 계약 완료된 병원에 별도 발급된 자격 정보로 로그인 (스캐폴딩 단계).
|
||||
* - 주 CTA는 `문의하기` (mailto:contact@o2o.kr) — 모든 리드 단일 채널화.
|
||||
* - 주 CTA는 `문의하기` — 2026-10-01 부터 mailto 대신 입력 폼(ContactModal)을 연다.
|
||||
* 메일 앱이 없는 사용자가 mailto 에서 이탈하던 것을 막는다. 제출은 구글시트로 쌓인다.
|
||||
*
|
||||
* 라우팅 구성:
|
||||
* - 로고 "INFINITH" → `/` (홈)
|
||||
* - Product / Use Cases → `/#solution`, `/#use-cases` 앵커
|
||||
* - Pricing → `/pricing?from=header` (유입 추적용 쿼리)
|
||||
* - Login → `/login` (Secondary pill — 계약 병원 전용)
|
||||
* - 문의하기 → mailto (Primary pill — 신규 리드)
|
||||
* - 문의하기 → ContactModal (Primary pill — 신규 리드)
|
||||
*
|
||||
* ⚠️ 앵커 링크를 `/#solution` 형태로 쓰는 이유:
|
||||
* 단순 `#solution`은 현재 페이지 내부 해시로 해석됩니다.
|
||||
* 예컨대 `/pricing`에서 `#solution`을 누르면 /pricing 페이지 내의 #solution을 찾다가 실패합니다.
|
||||
* `/#solution`은 React Router가 홈으로 이동시킨 뒤 브라우저가 해시 스크롤을 처리합니다.
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { Link, useLocation } from 'react-router';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import { buildContactMailto } from '../lib/contact';
|
||||
import ContactModal from './ContactModal';
|
||||
import ClinicNav, { clinicIdFromPath } from './ClinicNav';
|
||||
|
||||
export default function Navbar() {
|
||||
const { pathname } = useLocation();
|
||||
// 훅은 조기 return 위에 둔다. 아래 ClinicNav 분기가 먼저 반환되면 훅 호출 수가 달라진다.
|
||||
const [contactOpen, setContactOpen] = useState(false);
|
||||
// 병원 작업 공간(진단·빌드·사진 확인·확인 항목)에서는 절차 메뉴로 바꾼다.
|
||||
const clinicId = clinicIdFromPath(pathname);
|
||||
if (clinicId) return <ClinicNav clinicId={clinicId} />;
|
||||
@ -63,15 +67,18 @@ export default function Navbar() {
|
||||
>
|
||||
Login
|
||||
</Link>
|
||||
<a
|
||||
href={buildContactMailto('도입 문의')}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setContactOpen(true)}
|
||||
className="inline-flex items-center gap-2 px-6 py-3 text-sm font-semibold text-white rounded-full transition-all shadow-sm hover:shadow-md bg-gradient-to-r from-[#4F1DA1] to-[#021341] hover:opacity-90"
|
||||
>
|
||||
문의하기
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</a>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ContactModal open={contactOpen} onClose={() => setContactOpen(false)} source="navbar" />
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
/**
|
||||
* 최종 CTA (다크). 연락처는 o2oteam@o2o.kr 단일 채널 (허위 연락처 금지).
|
||||
* 최종 CTA (다크). 연락처는 lib/contact.ts 의 sales@o2o.kr 단일 채널 (허위 연락처 금지).
|
||||
*/
|
||||
import { motion } from 'motion/react';
|
||||
import { buildDiscoveryMailto, DISCOVERY_CONTACT_EMAIL } from './discoveryContact';
|
||||
|
||||
@ -1,8 +1,13 @@
|
||||
/**
|
||||
* AI Discovery 랜딩 전용 연락처.
|
||||
* 랜딩 CTA는 o2oteam@o2o.kr 단일 채널 (전역 Navbar의 contact@o2o.kr과 별도 트랙).
|
||||
* AI Discovery 랜딩 연락처.
|
||||
*
|
||||
* 2026-10-01 이전에는 랜딩만 o2oteam@o2o.kr 을 따로 썼다. 사이트 안에 이메일이 세 갈래로
|
||||
* 갈라져 있어 sales@o2o.kr 하나로 통일했고, 이 파일은 기존 import 경로를 깨지 않도록
|
||||
* lib/contact.ts 를 다시 내보내기만 한다. 값은 거기 한 곳에서만 고친다.
|
||||
*/
|
||||
export const DISCOVERY_CONTACT_EMAIL = 'o2oteam@o2o.kr';
|
||||
import { CONTACT_EMAIL } from '../../lib/contact';
|
||||
|
||||
export const DISCOVERY_CONTACT_EMAIL = CONTACT_EMAIL;
|
||||
|
||||
export const buildDiscoveryMailto = (subject: string) =>
|
||||
`mailto:${DISCOVERY_CONTACT_EMAIL}?subject=${encodeURIComponent(`[INFINITH AI Discovery] ${subject}`)}`;
|
||||
|
||||
@ -1,21 +1,29 @@
|
||||
/**
|
||||
* contact — 회사 연락처 정보 중앙 관리.
|
||||
*
|
||||
* PricingPage / Navbar / LoginPage / CTA 등 여러 파일에서
|
||||
* `contact@o2o.kr`과 mailto 쿼리를 중복 하드코딩하던 것을 일원화했습니다.
|
||||
* PricingPage / Navbar / LoginPage / Footer / CTA 등 여러 파일에서
|
||||
* 연락처와 mailto 쿼리를 중복 하드코딩하던 것을 일원화했습니다.
|
||||
*
|
||||
* 변경 이력 (PART III 피봇):
|
||||
* - "Free Report" 진입점 제거 → 모든 리드를 문의하기 단일 채널로 통일
|
||||
* - 계약 기반 B2B 영업 모델 반영 (온라인 결제/무료 체험 없음)
|
||||
* 변경 이력:
|
||||
* - PART III 피봇: "Free Report" 진입점 제거 → 모든 리드를 문의하기 단일 채널로 통일
|
||||
* - 2026-10-01: 사이트 안에 이메일이 세 갈래(contact@o2o.kr · o2oteam@o2o.kr)로
|
||||
* 갈라져 있던 것을 sales@o2o.kr 하나로 통일하고, 전화번호를 처음으로 명시했다.
|
||||
* Discovery 랜딩 전용 상수(discoveryContact.ts)도 여기를 다시 내보낸다.
|
||||
*/
|
||||
|
||||
export const CONTACT_EMAIL = 'contact@o2o.kr';
|
||||
export const CONTACT_EMAIL = 'sales@o2o.kr';
|
||||
|
||||
/** 화면에 보여주는 표기. 사용자가 지정한 점 구분 표기를 그대로 쓴다. */
|
||||
export const CONTACT_PHONE = '010.2755.6463';
|
||||
|
||||
/** tel: 링크용. 다이얼러는 점을 못 읽는 기기가 있어 하이픈으로 바꾼다. */
|
||||
export const CONTACT_PHONE_HREF = `tel:${CONTACT_PHONE.replace(/\./g, '-')}`;
|
||||
|
||||
/**
|
||||
* INFINITH 태그를 자동으로 prefix한 mailto 링크 생성.
|
||||
* @param subject 이메일 제목에 들어갈 문구 (자동으로 "[INFINITH] " prefix 추가됨).
|
||||
*
|
||||
* 예: buildContactMailto("도입 문의") → mailto:contact@o2o.kr?subject=%5BINFINITH%5D%20%EB%8F%84%EC%9E%85%20%EB%AC%B8%EC%9D%98
|
||||
* 예: buildContactMailto("도입 문의") → mailto:sales@o2o.kr?subject=%5BINFINITH%5D%20%EB%8F%84%EC%9E%85%20%EB%AC%B8%EC%9D%98
|
||||
*/
|
||||
export function buildContactMailto(subject: string): string {
|
||||
const normalizedSubject = subject.trim() || '문의';
|
||||
|
||||
52
src/lib/submitLead.ts
Normal file
52
src/lib/submitLead.ts
Normal file
@ -0,0 +1,52 @@
|
||||
/**
|
||||
* 문의 폼 전송.
|
||||
*
|
||||
* 받는 쪽은 Google Apps Script 웹앱이다(scripts/apps-script/lead-intake.gs).
|
||||
* 그 스크립트 하나가 구글시트에 행을 추가하고 알림 메일까지 보낸다.
|
||||
* 이 사이트는 서버가 없는 SPA 라 중간 서버를 두지 않았다.
|
||||
*
|
||||
* Apps Script 웹앱은 CORS 프리플라이트에 응답하지 않는다. 그래서 단순 요청(simple request)
|
||||
* 조건을 지켜 Content-Type 을 text/plain 으로 보낸다. 스크립트가 본문을 JSON 으로 파싱한다.
|
||||
* 헤더를 application/json 으로 바꾸면 프리플라이트가 발생해 브라우저에서 차단된다.
|
||||
*/
|
||||
|
||||
/** Vercel 환경변수 VITE_LEAD_ENDPOINT (Apps Script 배포 URL). 없으면 전송하지 않는다. */
|
||||
const ENDPOINT = import.meta.env.VITE_LEAD_ENDPOINT as string | undefined;
|
||||
|
||||
/** 선택. 스크립트 쪽 SHARED_TOKEN 과 같은 값을 넣으면 외부의 무작위 호출을 걸러낸다. */
|
||||
const TOKEN = import.meta.env.VITE_LEAD_TOKEN as string | undefined;
|
||||
|
||||
export interface LeadInput {
|
||||
name: string;
|
||||
company: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
message: string;
|
||||
/** 어느 화면에서 열었는지 (footer · navbar) */
|
||||
source: string;
|
||||
}
|
||||
|
||||
export type LeadResult = { ok: true } | { ok: false; reason: 'unconfigured' | 'failed' };
|
||||
|
||||
export async function submitLead(input: LeadInput): Promise<LeadResult> {
|
||||
if (!ENDPOINT) {
|
||||
// 엔드포인트가 없는데 성공했다고 말하지 않는다. 화면은 메일 폴백을 보여준다.
|
||||
console.warn('[submitLead] VITE_LEAD_ENDPOINT 가 설정되지 않았습니다.');
|
||||
return { ok: false, reason: 'unconfigured' };
|
||||
}
|
||||
try {
|
||||
const res = await fetch(ENDPOINT, {
|
||||
method: 'POST',
|
||||
// 프리플라이트를 피하기 위한 simple request. 위 주석 참고.
|
||||
headers: { 'Content-Type': 'text/plain;charset=utf-8' },
|
||||
body: JSON.stringify({ ...input, token: TOKEN ?? '', submittedAt: new Date().toISOString() }),
|
||||
});
|
||||
if (!res.ok) return { ok: false, reason: 'failed' };
|
||||
const data = await res.json().catch(() => null);
|
||||
// 스크립트는 { ok: true } 를 돌려준다. 형식이 다르면 실패로 본다.
|
||||
return data && data.ok ? { ok: true } : { ok: false, reason: 'failed' };
|
||||
} catch (e) {
|
||||
console.error('[submitLead] 전송 실패', e);
|
||||
return { ok: false, reason: 'failed' };
|
||||
}
|
||||
}
|
||||
@ -13,7 +13,7 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import { buildContactMailto } from '../lib/contact';
|
||||
import { buildContactMailto, CONTACT_EMAIL } from '../lib/contact';
|
||||
|
||||
export default function LoginPage() {
|
||||
const [email, setEmail] = useState('');
|
||||
@ -23,7 +23,7 @@ export default function LoginPage() {
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setNotice(
|
||||
'로그인 기능은 준비 중입니다. 계약을 희망하시는 경우 contact@o2o.kr 로 문의 주세요.',
|
||||
`로그인 기능은 준비 중입니다. 계약을 희망하시는 경우 ${CONTACT_EMAIL} 로 문의 주세요.`,
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user