Compare commits

...

2 Commits

Author SHA1 Message Date
69e4240039 [fix] negodata: 협상카드 성공률에 회사 스코프 누락 — 타사 견적 이력이 섞이던 문제
card_success_map 이 quotations 조인 없이 DB 전체 chats 를 카드별로 집계해,
다른 회사 견적에서 쓰인 이력이 내 카드 목록의 used_count/success_rate 로 잡혔다.
통계 화면 '협상카드 성공률 TOP 5' 와 견적 생성 시 카드 추천 순위가 모두 이 값을 쓴다.

- quotations 조인 + 작성자 회사(users.company_id) 스코프 추가
- 소프트 삭제분 제외(sessions.deleted / quotations.deleted 필터 누락분 보강)
2026-08-13 14:06:34 +09:00
8112394aef [feat] landing: 데모 영상 3종 실화면 재촬영(라이브 협상·견적생성·협상결과, 줌인 연출) + 도입문의·데모요청 폼 mailto 임시연결 2026-08-12 17:28:40 +09:00
15 changed files with 47 additions and 32 deletions

View File

@ -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"> <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} /> <Check className="w-6 h-6" strokeWidth={2.5} />
</div> </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"> <p className="text-ink-soft text-[15px] leading-[1.6] max-w-md mx-auto mb-8">
{formData.contactName} 님께 영업일 기준 하루 안에 연락드리겠습니다. 도입 문의 메일 작성 창을 열었습니다. 내용을 확인하고 전송해 주시면, {formData.contactName} 님께 영업일 기준 하루 안에 연락드리겠습니다.
</p> </p>
<Button <Button
type="button" type="button"

View File

@ -238,11 +238,11 @@ const DEMO_TABS: DemoTab[] = [
], ],
checks: ["협력사별 1:1 비대면 협상 포털", "같은 기준으로 응대해 관계 부담 없음"], checks: ["협력사별 1:1 비대면 협상 포털", "같은 기준으로 응대해 관계 부담 없음"],
mockup: "phone", mockup: "phone",
/* mp4 를 붙이기 전까지 이 탭만 1.7MB GIF(390x780)를 내려받고 있었다. 같은 내용의 /* 현행 공급사 포털(토스풍 UI)에서 실제로 진행한 협상 스크린캐스트. 390x780 모바일 뷰포트로
mp4 가 저장소에 있는데 참조가 빠져 있었다 — 786K, 540x1080 으로 더 가볍고 더 크다. 인트로→가격 밀당(협상 카드 4장)→타결(18,480원 합의)까지, 1.5배속. gif 는 무JS 폴백(폭 228). */
(원본 캡쳐가 좌우로 잘려 문장이 끊기는 문제는 남아 있다. 재촬영이 필요하다.) */
gif: "/gifs/negotiation.gif", gif: "/gifs/negotiation.gif",
video: "/gifs/negotiation.mp4", video: "/gifs/negotiation.mp4",
poster: "/gifs/negotiation.jpg",
gifAlt: "협력사 포털 모바일 협상 화면", gifAlt: "협력사 포털 모바일 협상 화면",
fallback: { fallback: {
icon: Smartphone, icon: Smartphone,

View File

@ -125,12 +125,10 @@ export function DemoRequestModal({ open, onClose }: { open: boolean; onClose: ()
<Check className="w-6 h-6" /> <Check className="w-6 h-6" />
</div> </div>
<Typography variant="cardTitle" as="h2" id={titleId} className="mb-3"> <Typography variant="cardTitle" as="h2" id={titleId} className="mb-3">
데모 신청이 접수되었습니다 메일 앱에서 요청을 전송해 주세요
</Typography> </Typography>
{/* 폼에서 "Demo를 보내드립니다"라고 약속했으므로 성공 화면도 같은 약속을 지킨다.
여기서 "일정을 잡아 연락드립니다"로 새면 방문자가 무엇을 기다려야 하는지 모른다. */}
<Typography variant="small"> <Typography variant="small">
입력하신 이메일로 사용해보실 수 있는 Demo를 보내드립니다. 데모 요청 메일 작성 창을 열었습니다. 전송해 주시면 입력하신 이메일로 사용해보실 수 있는 Demo를 보내드립니다.
</Typography> </Typography>
</div> </div>
) : ( ) : (

View File

@ -1,16 +1,13 @@
/* /*
* 리드 전송 — 데모 요청 모달과 상담 신청 폼이 함께 쓴다. * 리드 전송 — 데모 요청 모달과 상담 신청 폼이 함께 쓴다.
* *
* 목적지는 같은 오리진의 서버리스 함수(api/lead.ts)다. 랜딩은 ssr:false 정적 빌드지만 * [임시] 백엔드/서버리스 연결 전까지는 mailto 로 방문자의 메일 앱을 열어 문의를 보내게 한다.
* Vercel 의 /api 디렉터리 함수는 별개로 배포되므로 서버 없이도 POST 를 받을 수 있다. * 메일 앱이 '열릴' 뿐 실제 전송 여부는 알 수 없으므로, UI 도 "접수 완료"가 아니라
* 같은 오리진이라 엔드포인트 환경변수도, CORS 도 필요 없다. * "메일 앱을 열었습니다 — 전송해 주세요"로 정직하게 안내한다(fake success 금지). 나중에 API/시트로 교체.
*
* 성공을 위조하지 않는다. 예전 상담 신청 폼은 1.2초 뒤 성공 화면만 띄우고 아무 데도
* 보내지 않았다 — 화면은 "접수되었습니다"인데 영업이 받을 리드는 없었다.
* 여기서는 서버가 리드를 확보했을 때만 ok 를 돌려준다.
*/ */
const ENDPOINT = "/api/lead" // 리드 수신 주소 — 임시. 실제 영업 주소로 바꾸고, 이후 API 붙이면 mailto 자체를 교체할 것.
const LEAD_EMAIL = "o2odev@o2o.kr"
export type LeadSource = "demo-request" | "contact" export type LeadSource = "demo-request" | "contact"
@ -36,15 +33,26 @@ export function isEmailLike(value: string) {
export async function submitLead(lead: Lead): Promise<LeadResult> { export async function submitLead(lead: Lead): Promise<LeadResult> {
if (!lead.name.trim() || !isEmailLike(lead.email)) return { ok: false, reason: "invalid" } if (!lead.name.trim() || !isEmailLike(lead.email)) return { ok: false, reason: "invalid" }
try { const label = lead.source === "demo-request" ? "데모 요청" : "도입 문의"
const res = await fetch(ENDPOINT, { const body = [
method: "POST", `[${label}]`,
headers: { "Content-Type": "application/json" }, `회사명: ${lead.company || "-"}`,
body: JSON.stringify(lead), `담당자: ${lead.name}`,
}) `이메일: ${lead.email}`,
return res.ok ? { ok: true } : { ok: false, reason: "rejected" } `연락처: ${lead.phone || "-"}`,
} catch { `내용: ${lead.message || "-"}`,
// 로컬 dev(react-router dev)에는 /api 가 없어서 여기로 온다. 확인은 `vercel dev` 로. ].join("\n")
return { ok: false, reason: "network" } 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()
} }
return { ok: true }
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 42 KiB

View File

@ -5,7 +5,7 @@ from sqlalchemy import select, func, and_, or_, update, case
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from common.database.db_session_manager import DB_SESSION_MNG from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import users, chats, sessions from common.database.model.models import users, chats, sessions, quotations
from common.enums import ErrorType, SessionStatus from common.enums import ErrorType, SessionStatus
from common.logger import LOG from common.logger import LOG
from common.utils.gtime import GTime from common.utils.gtime import GTime
@ -38,7 +38,7 @@ class ICardCRUD(ABC):
pass pass
@abstractmethod @abstractmethod
async def card_success_map(self, cdb: AsyncSession) -> Tuple[ErrorType, dict]: async def card_success_map(self, cdb: AsyncSession, company_id) -> Tuple[ErrorType, dict]:
pass pass
@abstractmethod @abstractmethod
@ -83,9 +83,10 @@ class CardCRUD(ICardCRUD):
LOG.e_no_callstack(ex) LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, [], 0 return ErrorType.DB_RUN_FAILED, [], 0
async def card_success_map(self, cdb: AsyncSession) -> Tuple[ErrorType, dict]: async def card_success_map(self, cdb: AsyncSession, company_id) -> Tuple[ErrorType, dict]:
# 카드별 성공률: 카드 사용(card_used_yn) 채팅이 속한 세션의 타결(DONE) 비율. # 카드별 성공률: 카드 사용(card_used_yn) 채팅이 속한 세션의 타결(DONE) 비율.
# {card_id(UUID): (used_sessions, won_sessions)}. 성공=세션 DONE(협상완료). # {card_id(UUID): (used_sessions, won_sessions)}. 성공=세션 DONE(협상완료).
# 집계 범위는 내 회사 견적에서 쓰인 이력만 — quotations 까지 조인해 작성자 회사로 좁힌다.
try: try:
stmt = ( stmt = (
select( select(
@ -94,7 +95,15 @@ class CardCRUD(ICardCRUD):
func.count(func.distinct(case((sessions.status == SessionStatus.DONE.value, chats.session_id)))).label("won"), func.count(func.distinct(case((sessions.status == SessionStatus.DONE.value, chats.session_id)))).label("won"),
) )
.join(sessions, sessions.session_id == chats.session_id) .join(sessions, sessions.session_id == chats.session_id)
.where(chats.card_used_yn == True, chats.card_id.isnot(None), chats.deleted == False) # noqa: E712 .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)),
)
.group_by(chats.card_id) .group_by(chats.card_id)
) )
err, rows = await DB_SESSION_MNG.execute(cdb, stmt) err, rows = await DB_SESSION_MNG.execute(cdb, stmt)

View File

@ -142,7 +142,7 @@ class CardService:
# 카드 성공률(#12) — 카드 사용→타결 집계를 page 카드에 매핑. # 카드 성공률(#12) — 카드 사용→타결 집계를 page 카드에 매핑.
_e, success_map = await DB_SESSION_MNG.execute_lambda( _e, success_map = await DB_SESSION_MNG.execute_lambda(
nego_cards.DBType(), DBWRType.DB_READ.value, nego_cards.DBType(), DBWRType.DB_READ.value,
lambda s: self.card_crud.card_success_map(s), lambda s: self.card_crud.card_success_map(s, company_uuid),
) )
success_map = success_map or {} success_map = success_map or {}
for c in page: for c in page: