Compare commits
No commits in common. "main" and "feature/negodata" have entirely different histories.
main
...
feature/ne
@ -175,9 +175,9 @@ export function Contact() {
|
||||
<div className="w-12 h-12 bg-primary text-white rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<Check className="w-6 h-6" strokeWidth={2.5} />
|
||||
</div>
|
||||
<h3 className="text-[24px] font-semibold tracking-[-0.02em] text-ink mb-3">메일 앱에서 문의 내용을 전송해 주세요</h3>
|
||||
<h3 className="text-[24px] font-semibold tracking-[-0.02em] text-ink mb-3">도입 문의 신청이 접수되었습니다</h3>
|
||||
<p className="text-ink-soft text-[15px] leading-[1.6] max-w-md mx-auto mb-8">
|
||||
도입 문의 메일 작성 창을 열었습니다. 내용을 확인하고 전송해 주시면, {formData.contactName} 님께 영업일 기준 하루 안에 연락드리겠습니다.
|
||||
{formData.contactName} 님께 영업일 기준 하루 안에 연락드리겠습니다.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@ -238,11 +238,11 @@ const DEMO_TABS: DemoTab[] = [
|
||||
],
|
||||
checks: ["협력사별 1:1 비대면 협상 포털", "같은 기준으로 응대해 관계 부담 없음"],
|
||||
mockup: "phone",
|
||||
/* 현행 공급사 포털(토스풍 UI)에서 실제로 진행한 협상 스크린캐스트. 390x780 모바일 뷰포트로
|
||||
인트로→가격 밀당(협상 카드 4장)→타결(18,480원 합의)까지, 1.5배속. gif 는 무JS 폴백(폭 228). */
|
||||
/* mp4 를 붙이기 전까지 이 탭만 1.7MB GIF(390x780)를 내려받고 있었다. 같은 내용의
|
||||
mp4 가 저장소에 있는데 참조가 빠져 있었다 — 786K, 540x1080 으로 더 가볍고 더 크다.
|
||||
(원본 캡쳐가 좌우로 잘려 문장이 끊기는 문제는 남아 있다. 재촬영이 필요하다.) */
|
||||
gif: "/gifs/negotiation.gif",
|
||||
video: "/gifs/negotiation.mp4",
|
||||
poster: "/gifs/negotiation.jpg",
|
||||
gifAlt: "협력사 포털 모바일 협상 화면",
|
||||
fallback: {
|
||||
icon: Smartphone,
|
||||
|
||||
@ -125,10 +125,12 @@ export function DemoRequestModal({ open, onClose }: { open: boolean; onClose: ()
|
||||
<Check className="w-6 h-6" />
|
||||
</div>
|
||||
<Typography variant="cardTitle" as="h2" id={titleId} className="mb-3">
|
||||
메일 앱에서 요청을 전송해 주세요
|
||||
데모 신청이 접수되었습니다
|
||||
</Typography>
|
||||
{/* 폼에서 "Demo를 보내드립니다"라고 약속했으므로 성공 화면도 같은 약속을 지킨다.
|
||||
여기서 "일정을 잡아 연락드립니다"로 새면 방문자가 무엇을 기다려야 하는지 모른다. */}
|
||||
<Typography variant="small">
|
||||
데모 요청 메일 작성 창을 열었습니다. 전송해 주시면 입력하신 이메일로 사용해보실 수 있는 Demo를 보내드립니다.
|
||||
입력하신 이메일로 사용해보실 수 있는 Demo를 보내드립니다.
|
||||
</Typography>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@ -1,13 +1,16 @@
|
||||
/*
|
||||
* 리드 전송 — 데모 요청 모달과 상담 신청 폼이 함께 쓴다.
|
||||
*
|
||||
* [임시] 백엔드/서버리스 연결 전까지는 mailto 로 방문자의 메일 앱을 열어 문의를 보내게 한다.
|
||||
* 메일 앱이 '열릴' 뿐 실제 전송 여부는 알 수 없으므로, UI 도 "접수 완료"가 아니라
|
||||
* "메일 앱을 열었습니다 — 전송해 주세요"로 정직하게 안내한다(fake success 금지). 나중에 API/시트로 교체.
|
||||
* 목적지는 같은 오리진의 서버리스 함수(api/lead.ts)다. 랜딩은 ssr:false 정적 빌드지만
|
||||
* Vercel 의 /api 디렉터리 함수는 별개로 배포되므로 서버 없이도 POST 를 받을 수 있다.
|
||||
* 같은 오리진이라 엔드포인트 환경변수도, CORS 도 필요 없다.
|
||||
*
|
||||
* 성공을 위조하지 않는다. 예전 상담 신청 폼은 1.2초 뒤 성공 화면만 띄우고 아무 데도
|
||||
* 보내지 않았다 — 화면은 "접수되었습니다"인데 영업이 받을 리드는 없었다.
|
||||
* 여기서는 서버가 리드를 확보했을 때만 ok 를 돌려준다.
|
||||
*/
|
||||
|
||||
// 리드 수신 주소 — 임시. 실제 영업 주소로 바꾸고, 이후 API 붙이면 mailto 자체를 교체할 것.
|
||||
const LEAD_EMAIL = "o2odev@o2o.kr"
|
||||
const ENDPOINT = "/api/lead"
|
||||
|
||||
export type LeadSource = "demo-request" | "contact"
|
||||
|
||||
@ -33,26 +36,15 @@ export function isEmailLike(value: string) {
|
||||
export async function submitLead(lead: Lead): Promise<LeadResult> {
|
||||
if (!lead.name.trim() || !isEmailLike(lead.email)) return { ok: false, reason: "invalid" }
|
||||
|
||||
const label = lead.source === "demo-request" ? "데모 요청" : "도입 문의"
|
||||
const body = [
|
||||
`[${label}]`,
|
||||
`회사명: ${lead.company || "-"}`,
|
||||
`담당자: ${lead.name}`,
|
||||
`이메일: ${lead.email}`,
|
||||
`연락처: ${lead.phone || "-"}`,
|
||||
`내용: ${lead.message || "-"}`,
|
||||
].join("\n")
|
||||
const href = `mailto:${LEAD_EMAIL}?subject=${encodeURIComponent(`[${label}] ${lead.company || lead.name}`)}&body=${encodeURIComponent(body)}`
|
||||
|
||||
// 방문자의 메일 앱을 연다 — 앵커 클릭이 location.href 할당보다 핸들러 발동이 확실하다.
|
||||
// 실제 전송은 방문자가 하므로 성공을 단정하지 않는다(호출부 UI 는 안내 문구).
|
||||
if (typeof document !== "undefined") {
|
||||
const a = document.createElement("a")
|
||||
a.href = href
|
||||
a.style.display = "none"
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
try {
|
||||
const res = await fetch(ENDPOINT, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(lead),
|
||||
})
|
||||
return res.ok ? { ok: true } : { ok: false, reason: "rejected" }
|
||||
} catch {
|
||||
// 로컬 dev(react-router dev)에는 /api 가 없어서 여기로 온다. 확인은 `vercel dev` 로.
|
||||
return { ok: false, reason: "network" }
|
||||
}
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 4.1 MiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 56 KiB |
@ -5,7 +5,7 @@ from sqlalchemy import select, func, and_, or_, update, case
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
from common.database.model.models import users, chats, sessions, quotations
|
||||
from common.database.model.models import users, chats, sessions
|
||||
from common.enums import ErrorType, SessionStatus
|
||||
from common.logger import LOG
|
||||
from common.utils.gtime import GTime
|
||||
@ -38,7 +38,7 @@ class ICardCRUD(ABC):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def card_success_map(self, cdb: AsyncSession, company_id) -> Tuple[ErrorType, dict]:
|
||||
async def card_success_map(self, cdb: AsyncSession) -> Tuple[ErrorType, dict]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
@ -83,10 +83,9 @@ class CardCRUD(ICardCRUD):
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED, [], 0
|
||||
|
||||
async def card_success_map(self, cdb: AsyncSession, company_id) -> Tuple[ErrorType, dict]:
|
||||
async def card_success_map(self, cdb: AsyncSession) -> Tuple[ErrorType, dict]:
|
||||
# 카드별 성공률: 카드 사용(card_used_yn) 채팅이 속한 세션의 타결(DONE) 비율.
|
||||
# {card_id(UUID): (used_sessions, won_sessions)}. 성공=세션 DONE(협상완료).
|
||||
# 집계 범위는 내 회사 견적에서 쓰인 이력만 — quotations 까지 조인해 작성자 회사로 좁힌다.
|
||||
try:
|
||||
stmt = (
|
||||
select(
|
||||
@ -95,15 +94,7 @@ class CardCRUD(ICardCRUD):
|
||||
func.count(func.distinct(case((sessions.status == SessionStatus.DONE.value, chats.session_id)))).label("won"),
|
||||
)
|
||||
.join(sessions, sessions.session_id == chats.session_id)
|
||||
.join(quotations, quotations.qt_id == sessions.quotation_id)
|
||||
.where(
|
||||
chats.card_used_yn == True, # noqa: E712
|
||||
chats.card_id.isnot(None),
|
||||
chats.deleted == False, # noqa: E712
|
||||
sessions.deleted == False, # noqa: E712
|
||||
quotations.deleted == False, # noqa: E712
|
||||
quotations.user_id.in_(select(users.user_id).where(users.company_id == company_id)),
|
||||
)
|
||||
.where(chats.card_used_yn == True, chats.card_id.isnot(None), chats.deleted == False) # noqa: E712
|
||||
.group_by(chats.card_id)
|
||||
)
|
||||
err, rows = await DB_SESSION_MNG.execute(cdb, stmt)
|
||||
|
||||
@ -142,7 +142,7 @@ class CardService:
|
||||
# 카드 성공률(#12) — 카드 사용→타결 집계를 page 카드에 매핑.
|
||||
_e, success_map = await DB_SESSION_MNG.execute_lambda(
|
||||
nego_cards.DBType(), DBWRType.DB_READ.value,
|
||||
lambda s: self.card_crud.card_success_map(s, company_uuid),
|
||||
lambda s: self.card_crud.card_success_map(s),
|
||||
)
|
||||
success_map = success_map or {}
|
||||
for c in page:
|
||||
|
||||