Merge pull request 'Diy estimate presentation module' (#3) from diy-estimate-presentation-module into revamp

Reviewed-on: AITeam/LynkedUpPro_CRM_Demo_Old#3
This commit is contained in:
2026-06-19 06:54:35 +00:00
114 changed files with 21496 additions and 4846 deletions
+7
View File
@@ -3,3 +3,10 @@ VITE_GROQ_API_KEY=Your-API-Key
# Weather Widget (OpenWeatherMap)
VITE_OPENWEATHER_API_KEY=Your-API-Key
# --- Payments Module (Stripe Hosted Checkout) ---
# Frontend (safe to expose). Until set, the Payments page shows a "configure" notice.
VITE_STRIPE_PUBLISHABLE_KEY=pk_test_REPLACE_WITH_YOUR_PUBLISHABLE_KEY
# Backend only (Vercel env / .env for `vercel dev`) — NEVER expose these to the client.
STRIPE_SECRET_KEY=sk_test_REPLACE_WITH_YOUR_SECRET_KEY
STRIPE_WEBHOOK_SECRET=whsec_REPLACE_WITH_YOUR_WEBHOOK_SECRET
+1 -1
View File
@@ -33,7 +33,7 @@ dist-ssr
# Reference images / design workflows (local only, not for repo)
Estimate_Section_Screens_and_Workflows/
LynkedUpPro_Customer_Estimate_Presentation_DIY_Wizard_Build_Spec_v1_0.pdf
# Claude Code local config (session memory, not for repo)
.claude/
.vercel
-1
View File
@@ -1 +0,0 @@
shamefully-hoist=true
@@ -0,0 +1,284 @@
%PDF-1.4
%“Œ‹ž ReportLab Generated PDF document (opensource)
1 0 obj
<<
/F1 2 0 R /F2 3 0 R
>>
endobj
2 0 obj
<<
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
>>
endobj
3 0 obj
<<
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
>>
endobj
4 0 obj
<<
/Contents 19 0 R /MediaBox [ 0 0 612 792 ] /Parent 18 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
5 0 obj
<<
/Contents 20 0 R /MediaBox [ 0 0 612 792 ] /Parent 18 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
6 0 obj
<<
/Contents 21 0 R /MediaBox [ 0 0 612 792 ] /Parent 18 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
7 0 obj
<<
/Contents 22 0 R /MediaBox [ 0 0 612 792 ] /Parent 18 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
8 0 obj
<<
/Contents 23 0 R /MediaBox [ 0 0 612 792 ] /Parent 18 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
9 0 obj
<<
/Contents 24 0 R /MediaBox [ 0 0 612 792 ] /Parent 18 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
10 0 obj
<<
/Contents 25 0 R /MediaBox [ 0 0 612 792 ] /Parent 18 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
11 0 obj
<<
/Contents 26 0 R /MediaBox [ 0 0 612 792 ] /Parent 18 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
12 0 obj
<<
/Contents 27 0 R /MediaBox [ 0 0 612 792 ] /Parent 18 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
13 0 obj
<<
/Contents 28 0 R /MediaBox [ 0 0 612 792 ] /Parent 18 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
14 0 obj
<<
/Contents 29 0 R /MediaBox [ 0 0 612 792 ] /Parent 18 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
15 0 obj
<<
/Contents 30 0 R /MediaBox [ 0 0 612 792 ] /Parent 18 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
16 0 obj
<<
/PageMode /UseNone /Pages 18 0 R /Type /Catalog
>>
endobj
17 0 obj
<<
/Author (\(anonymous\)) /CreationDate (D:20260609045909+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260609045909+00'00') /Producer (ReportLab PDF Library - \(opensource\))
/Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False
>>
endobj
18 0 obj
<<
/Count 12 /Kids [ 4 0 R 5 0 R 6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R
14 0 R 15 0 R ] /Type /Pages
>>
endobj
19 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1271
>>
stream
Gatm:gN)%,&:N/3m%^"A0E?^6]IBEt@uoHe2:GV99gTI*MP(8RWrB^?OOPS^3pAJrTh&Q-HTB_A!!A*Ck5M[r^kW&14u$FbOsMeJ"=+NI0c@Zb=Kgsf&dSal,@Q*q7Sai++:<GJl\Kd/pI,KYYW/9f^;EKd%-l@K?*T$+O$o(G3o>m<hL4^ZZ\Wjr"fRXG0RhZ@hdm:)/""rb2b:YKnd_P[8f7!F>@EB`(`)e>L^P>aKn0O"*DnKP(Ld,F:`QtQM2*u7_RufXk?2FI/`9Lg.YZbgQ>^?j-mL[VCuAg6;Pf$WXX\@9Y%2^3QQAk+$^UYh)++Pc$Ad9,&:T:23uC2j7AH_[OZ#j<D]m:J9/fkN<ojGkl%W3eeoCh+e(!ZZD3RLe>3;bFD=k2OCF]XtW="u,C,Ne7ML[%IgS>usC7TAu,dGApgj\%n1[,WFE6!ca;0%VNKSp,4]U)gT%\NV1@;_PH0V?@@>E&S]^n75hV-CV6cOYkn;\TR`IbUpl+70!q@V/J2JhS1%q(Gki$QLB*VJtF%Ys]8`PQqG(c<"7<`hU:&7dOq48"u&a?HXh#Z/oq7hnu_t&t,jp?0IQ:#&`@*Y=jMIHqH>tfL&QGVE#uCB(B+<oG7Sr7t_pTj%C\6/CAKlj^">L+TI`F(A1_F95Cb#NIb>PAYCX+[qV6GHdb[H2YkddrTk#iCM$]kOudZ(,ps:&ia%eM&5L6aZ,>UnL11`n%PCU_U;A8YpmdP0_pf."l^%!/e62(`E62%F3klg'&-`ZH"-i=XTH;O4A\JO;0r>&IN_Pg&YR`E_ed?EF=mJ>in+67Fl<</UbR*'u36,sF[)K:B.l5#W9r1LX?4(E*e5`?-6qZ"&MNr(A207F7qYRSF4.OIB-G>hlmiYi4jU.7WjMt%P[+X)CG&R"nC"=2B3Sd$NfP!qpMLeYa\Fh@_l87Cu9G4kcD/!M]\?n\J[f5@uP2#d@[sdtjoQ`WIS3U)TD49j_UP&Gp32`)20ZD6kBVc*%mEs3pX(hlsJJYl(EE0ElgWVuP"rerOEf\PUIpjhd,FKQe@5Cl8)d1H)R&lpXB9Vsen"=Z7jQXqSojXQ3=3c%D3h`(9X"AKGXUtG?BuSp$@f0Ap[QuLeW'UtFo<lF[@aUaNMcjp5h0HSNkNTjTVkhJ%(QafQ5MGXb&dJj&!Cc^$=F(J#Y5^VEh@YVfZn:Q4(5fi$65s9R\GlCG+a&N"Ze92E\>pagpQl%WjndL-I@V5InSej;mq?a5XsD;.g[&R@_rr1o>]5N~>endstream
endobj
20 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 806
>>
stream
Gatn$9lldX&;KZOMERi-=@#7Zkfllh9P2_k[7R8t"j9:k?PUHRq"7p$:XZ--ci_1EA\d0sdH]On$9he's3+)c=UJdL$C!if"j..,oGZkkpVWcH?\U`V*$Q/[5i?#=%Iq=qAfM)QOl-hSYob5"9^Z=KqPk)l>%2RX2!h!X0(qjdmgB<l(=)6gWlhal+T2f78#Q1eY<S8"rh`@la:f7u#tJMm\O6.Y0SQ=SA\B]B0Rlu'BD#TI\gpEfSd2&[/4Pb6"I>qAFI@id]%(p,ZJ*)g>to39?<ceP16_tdi_`eZA1=>1eu$A!fIJ1fe`0onC#h`eLGs))Y&)nF5qH9\^u>K2N.QbH[_qboVX$I0'!P6_6NY`]`7C-AcLg[+P<ET[13YOm5f`Dm5_^f!CDEA_<jhmo>^"P3N7>gC,/Rk1Va^eAUm3S2Wot%lb:?[R.N8GF`j'/Mi9hPSiGk*nJot[i)Y@Dc`pmGg*gC4\F^DmVA\'mn_Ya_4%\#36j-Yu*kQG[\^@,;1Sg$g+)lp%;[)]_r_CY@Q?`?C`Co#pEc#[$sFu&B*YsG%r<f`k\J^/AfZI8..OK+"`)U=O2N`R[<e_BK"I;jVp6L@cM2^JqJR)s/GeG0(KO2l5k`K%N-Ku0R,Sb:@af6(^]Ke;0\,ooAnk@`l)6r7-B+2o4#QQ?IkdAqe=7u5EL6](Ms*,'h'^tLl1m)Up"Y616sd:!P*A,7p_(c%cQp2C;]-kU>I-eH\p:2SplEUsJ`Ln/6_@.CWYYB!nt3#q_*#4P:$O@;Qni%=$<bnK7%4(Z1o*5%ok@Jo3~>endstream
endobj
21 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 4402
>>
stream
Gatm>gQL;L&q0LUi,LZ;-sh#?<,L01il"UFNakjC<Vlu_O9?(_-j2Q9o(@@@_:so=PdOQ&&n)%YQbJtRh*<*1r!W<"nQ(I4G@L2;+G*GFX[Fl:`o?EgG]ic?mh\:_k/@"k@ke?90:Ll6oQ^@G4qG]N$\I&R4N$XCNu"G1f.Sg_d**&X].HJ_753]Jk;;m4i/(NB<*U486&AD!Ft<1%^Vnr@EVBQ0r!33aSU<I+]Lb83&5L4QbS`%Q=LAs(Bbb"$4`uqn@d_Kdp:q&]rk"/u@ThtQ&U(']Of4PjPSI)D19m.EGc)0+`K0"*Gu/d[XkKeV&?iAs?5"iQMmnK.?7"UWSbS9c;/Bbd@Zt@Q0!*\sSHtW0_X'(k'5o-W;C`BNUH`U=3qPQW6Cc?>h-O@NXIt+e%nNJj<1&%`hNrLbk,Om&'6iXubKh#Z"G]-NV@]@m>6oVGQ^NqlIHE,'`jq7d_?T&M=Hs:n@s12YJ!\mS6CU[$K.YUHVg<2DY:5`J(2lTU@[ZM2BJ4Cah5rIp5]at]9,/coLg=mP).[+uWI`,b=]BgDg/kH/RmQI>Gutu+57fa0W2*+lW85a,pEX2VKP*$*=teqd`qIVWjj]"GmR44$BTmhQ8m5DM2.[i0@:=o'41t*VZ;PtG9If\Jg#Ym7L7r[e!Z#9KU)R%J^im7H3Fr$uXXhqBR6%kaSZS/'fjchBQ_MRoZ?1TJ@MVcH"BMI9__Ro[AsHA4+ZKniM4`J*>)Cd:ZdLp.=Xp]28>olH&#>"j77MIliJip0;Q"Q7;dCAfVB'J,;`X(HMNt.Gce,8[,:guQKl1,U#2;C1V5p:iq*V2BN,-Nk11?jT*/=8*I`M__WNd;&lE)\C,aJa!X'%T65Bu1$eOF?]PK=2'+=h"adTc^ojNZK1nqWK+@Xu/6moQLA>Huc`7Y)lJW_ch"$&V`NVF$TQ1FFjK7GG[#\"E2Xlt8'a><H.i34KeYI%VK_:.>FTE&n:mD\e3RrrEG-IX'iC8C"jbI!rH#n13^nM=W8TLqKK@qYO%LfH<'\UWd2\9@Zf>^60SZ!a,Os'gp<#KMZM'PFX#Zp>\5\%1#M1X5'hbdR!9o"9N0=c\<J((snH]$jL6=RJP?8kRrX-+Ftos)`N&E?!ZC;Y+=cmBtfD:e@^%C?`//tMDY@'U41"0T-6%O;o0JLr5BZ0_>jJ=q"X._D^+nWL:4LMpodYEi\%uomQZ$]GMd2mpj/#dT#I8BA9sq/)LCHZ!6de-7l0@jW8F(\pg1's_-gbKT]"IpHK",=d+3hhjMCVhF"mel#V/jI(=W(9,h&Z0!rQMdB[V9](%We]!-A(8:Cug3kC+)in>9Dnj=RBMi)e2=:O1V1IM:WqQ9t>HNalM`Pn%EU>f")lkhiD8;@YD:G7=J5JmQb#j2huEKKRrm/E8:knA7a+(K57p`\kY;C$OF_<ESEr?YoQtH-*&O+>E`&K*M8)@1UTmA"Z%8N,FL/T`*Opkm%nfX8OGJLf-6`Jfb^_?Rp_i*b:%cN1keqD<*9GVZm]6#&L/<L^Q3&=d(MOi995T_/Kd:9>Y7l:4`]PCW:PU`D;#0!:55iUJp/QWJoNa"sj!@&_[e"(*Db2X[uJaE50@j,0GT3d[Ek,%CU1$_nO>C6njnI%H7kB"Ya/`M$W`ET0hi)XN5oTBj-@gB"aA"&SPNDo!aYN$aV@<)?H"rfTR]i^K-%Ra<PG(B!tsu(_fdU.cWt3@#<4Hjp,`OgmM-*1.\]%k$gk1N*bbY;*dq1&B<Z^.`7#rOZnJRlL:<-78/+ta@duAUTust?5*(HH./gK8a)^8Gl#p/o*-Hd'1jfXYie!2f'4!'B0#&V,puj%2.qaZ.<mE=@RYVY3t$$F:Um)HhpNB@+XY"Ogq&_1[%LU^$Za?7C*5c8nIF=VfX*)9s*+`dF?"ra1.o9B2AJ6N9s7nlF)Q4VFkj7XnD82Lp8TA.^%5(6:E?X+C)2kB[gb=6<[GkcP3]DI84]6l:qFcZb_'Xc&j3ePbW]$O9e;FW$s]U$bS*f3B_Z-DR5ePoMQX7*jM2W*Uc/G*3dtF$r&8Jm`tWfYo^I,$K1"rnVamoM.(jfr1Sjg/h)gcQ@--6.@%?0k(GSskPc>eO=.S#b0&XMK8JGfcY2GEbH/GTsf8qYoO4WGh80P;B9C+UsbK4Y6eE[b4iZJL7dp04-.7=pk0ZiX3jZ,gOIL!u*;#rTG:BhGqX`U<$%9UK9nn/)3iR/VOO['2!s"#3jC!F$EkqbP+9YlEq;h$]LGth9"23+bVR1m2U2hRg%:NMum>H\":YCgd(@8oDFDL17:eT8+_Vi(h>`]`HrrL?HnHd9n4q07Gji1>"rIa/)R4m-E5CXasaC/u#CDZ04^Y5e<F/=QInOo]tu4r,:`Wrr#Hi^XJiZJscGS%I[sNCEWq;=db%(.AeMH-n;c;pKm*7+Lja'[F$aA<GdfKUAUE)eAq@<U?!n*10bq^.q,>=f<R$LiHIu>=`TB@aqAhEBUVD@)\C)PnP#ePhahM5<_JG(.uRc/kFY4Us[`*in0K7/sY_0Xt!CBaTGMm*VbU@5GLY-&MI-89A;%"&_sSEARJ?9*F@>C=-b5Ok2i@IfVocui(%VnYs3RF3=.f,H\QB<.<dV$2gj@M87<:sI`-.grT9%A3i$NUs-Mcs5'']S1BN0sR3:+'_uO\:;H<"%;=WkOTg/@eBtI]dP<J/O1mu3^^LO-F=[!'=c^+nQo/#&f+/eu>kp!A`nKU<E()$L]oQW5+n).%@C(1qkB%o-:=b""7pRiiN*.6\m>8T0Z+)90(9[d1O_Ju.3#bt<@^k[:Ke/"8tB5<PmNl-H)RU@2-=[W7/090n-%Z4lWGV\AJ)%ron9B^be=>=i*a&[XtTQ;)T!IiQ$M'>S6+6i.BE^T,,:*6XX4.5!tXr2LdSME6.M92`0eEgH,>nOhDOKP'sD]A>Xof,Y4<Bdc43u)Hl/i`"rQPJ<+c#-Toe!*[23omV"f'&M"ZF,M*gf)_O.hs/cPYuCaDma^X3>@Hhqedurm"i7I)B<j;gFU#q)p[%31Q05?'q[[=qPu@:oQVt93SYl@ZdR7eEj,.^1N\n2.L,#)B&F.-lbq)[`2o(-],WSG<BTf^p36T()6(P&1Ki807R$'[j2m]hMdG^hdUY:HIj5I;IoQtHp3li.?*i_t"GQF$CXMtEWC,3R1ho/=qRHa0gngkm1$<RJS!RhrE`ID3AS"\VpKejC!(A82gue@#ZIk*KG]ubk5/D,)HnfKSZcH^*c`#Qf%tD:WNZaHPp::Zge<u)$I2,p[>i8>"_J+VDR>$`/Vn$XoW-)ftd*tWKoa4bR*Or-Wgr^A&H3*Mn:,#noR)l9S=*soC%5Z\(iCb?;6rTCs3a@BdRH:8/8l/113R+`m<>V*;=o@L4+cH/@pU\C"`.O@O65(V4o\uYOpp2a_:9OnME+d9;4t#O+@DL;sBpC"cAQ?\q_E-O])aCor_sH>-%jim;$>M02#4P4Iln6IqhLhm%?!nM-`L.:o>FeJ$ICqPbp-8/pXNU?qoY$9IMn1<&"XFmA2J0#Rb"N$mi?i,:=Z03h;,^_6nNu!?A2u`b\-jS[VnH/&Zt!t-LeRK9[.ajI\\r&<lpTVgKW7ZG8].H2cC(]`-2oE=)J3(.`'Ch@+$^mPQ\[EIOB%P07b?gdohN<50Oq_rTPc9>*tUsu^:]\D.L9N1f+=+mXiF:e,s;efC^irL[KN$'aXfM6`td3e=k`T'O^gd5.;C[&''+qP<<m&>g?\>qm/D#hpt%tN(2UD9$chip6[S8>PuGH!A@F<C^hHp=-pc]Fmb0#sUq)@OARE%J;m6%^1QD?S"*S&@XCi8$CI[1qh+$B^2.],:ggY/BR,dI3WdCe(DO-N7mUI?$OA9Mjb*Ypb_IYZO=08_fmhjQ0M&g\S("hXkFEqb@gA@+9H'p.rTEcU8rT%_\2"$TQJfp"@35+7tdsLba85.3_T==D"!m9g2iin=tR6mu\RN1$TkWC;nb)*5gO&ZSQCBUEp[#fu*>k2Ge*kapJJ\!uq]jb7G\L0Fd`_oH01d$.bB-1,uWJ[Vng%r)91=t?pUQ,DHo'LRTiH-a-X[)BBT_853I>?,XH-/u:W83c'!O<2X4h'sLmZkRk";RuO(5_#9ZSp+jY%89-NPqCe?9_;,g]T'Fe%\OU'[K[bDa1:BV;^]YKA3<LC:\>+cY=MN*PuQ'FUtsN;\Q;a=2uZCDL0<rf::l?]BS:[En/(p\n=(A<a5kglfVMEG0d_`T$PIYBYiKfXuQ'+Wlqf]IF0>4gcf#EmAj$*:.s<sgPeICj,]KeFaqdm<Jc(blDjM'GV>LG1EBt!Iu[kE)*P+H\q.c7h7^IIHtd_*h^U"GXR;1bjNH_YFt2ns:1q@6#^K.>G/t1.\0C"Y8Cej8RX"?ePZb\_~>endstream
endobj
22 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 4330
>>
stream
Gau`W>BAOW(4OT5^n\B$.?^DnZ$CBVAB-cLlc)\,G9@N]=ApJ<J6'%lI_Q!S-H@=4$)a=f,$mN>%_hP*)r`OjIiSE*\7=mc,Fa.*R,bKm201n'@ukj#e)S'ThDnTZ\SWo\k.<Eb[_O88A(gJ8de0!h3<$edn[l/@J]iqq4*9XB,L"g#%_]KuRHr^reG"Z0Z0SJ&m'GWM`:#?^<$js2M`q']*e0ohJ&e1Jj*RqE/VhABnVJ4/X?#kf96Q-qiNZeVTAE_e@_$I*31t10r1]8*]_TVVXG-Jk%4qNr+$"n3rm1OV[RAS#5?dp.5`W7"gGduj=],&*[MjY>:?dbIp"$Y1D7djDV/+>7`4KBaqs.?Z]u*$>Y1_7O*F5$q4h7DLm@`DKoNu)uL08tOR$h`"h*83(5OKC"6]imjL4Y+JX@*Zl(B`C&-hM^1]YtMpADla_F"[Mch8"Z9/]T*k^+p.ADW]o^Ksf8Wj'.)+aE<fG,#faq=(.J]WB..6Q#%6pFPr\J"3W\br,5Z5g.oOP6m>Vc:Y:]*Hm<gERui/67SJ[B;iJ&)_K&N?@OZ<AUr#mK8Nkl;Ps'tgD$<_bG/Er^[EZ"T\eWC%Y1?a\@j6agnL@:CF^Sp\KX?JWH1"jooAo:k7o$2];5>1I$@0c\,Pu0hbNcB>ZM`'#974omLkk6V8K\0:'oXN75V%Wp&!Y!f6$uQ$;-#.a7qe4Om,m?m'`q*u1Ia_FhrQuWV!MShMS+bMd(=O&nDZ-T.ZeU'SriCKYq"d%Yp.BBEOqQ)^/>]B7M00<c*@mhS.#/!(>k.kWQ].N6)m%B)M(dNGQnk9'fZlCT,=_hNC^t)KOG^r1<,bj?[$`9&tE$Ce09"T+V<B,:s]2X;%u-\(?.=iq`tpV/"iqV(T.Ij:_P;1Wnsrd0NC6U+=.Mr[?Fs_d73&2"Sb9TXrjho/6cTn>.4tl-lY!$D(u=_("kBR?rX?HK_Cg,l[e!E/\<>MM4Xs`!`NM!<"5MHJR*K?'CNV(.LOVPW8MG[PS>E`8lC:pG>JL@`&A=*O<FOm!/GceSc1*m2"rT*D0bu"H,rV1UdBUkbf:8P9CNH'a)Y!':mW9V]#]'=8=he.F%t-4;k!(eJScA.gW8s$Q@P(f''^nE.`#_[2;hSn27[OK\Op>A`F<$56]3O`RVYoin9?>63oQQfLd&1^*c?8>@D7W]X!HQ@+@M@OI41gRfK"CWg[>`)GDC$IT4q1F5s:>YfTc>dFgc?QKg+BSe"HG<eGDipQc*J*3XuV'f!.aiTI_Y1F^:N"'p;t3$<BFU1Ck#`-=f[f*n]'tG8&OO;/?4)QX<Fq0\Uf4XW)rD@WnX7cjqYdi5dG[1QX;Q20]"#*HH+8=d*oh-)/p<*_!jeWT,m0d7f(XP=?+IeiqmGFS(Y//eVr+_Q;7Gf8D9KpQ%dZdA=u<Ik;$Aft7VmSo^cCn&#lpA25[/;q"o6;]\Y)qU=/-?isS:!,K\9#^DPJK@quUV'=>;i5`afj?i(,l+Ma2_[f?J]8<Hp\Mi(I3o2+I$=0DZpK+UYnDYbd"k*a&OrPtG95<gOB2Y5%aU>GL+>5@L[L!FtnaH4W!k0o'!D(0Nm]Lkej8)m'BYZ'l=O8$;<Z2%!1Eu?RF?Kq4.B+fr!BGc"'i_D2AHi>^2VK%p_Y?J@U21)/]]a%%p<kS&0a'&ec$#?en6I5T<Y;DJE9L$g:k/a."?O2Y,qP&&&OXT,@-Bm93Ph*n=!*s$?%>#/_/l6k6Q/R_V8/=UBp:Ms=fPlSRLnh;E75@TksT/Ei80-=`]i8P=Kk3O3>RoEC*/PNBD7oY?&%97JQdgq7\.^>99M6llGI]s`"RTCR(sI]"H/@'UeQ>o^Xn;7[]4LRF'c`2-/(@gF_E^[LkMd6s"KZ%&t<>Y8SoX>/MK5AQr04'#nnUQ%Q=X^+<sB)d*Z35B!%87]W"@#!#*R:/ZTC,raJ8Sk\CO'"'F.Bcu$A:^($aqluY_#Sg=\FGD-kpde%D;"V#b^PA!6Q[Q7BBCr5+uWZhXY^_neHGWJ)^)m3%S2^)6[6X3$,dUX#idpr85"HH>\q.jSY;aHu_<`Gr.,30Z[\?H_C2Vb`QWA7Y7bGV>Z:*1Ce`L1U:hb:55jGC%rZes>LMM'P/17ZVU*l*F2&/fU_%n%.&Ri6/J,o%Y,Mf#o#hqB&)it1X8(B2NY*CITb'/2mdTR];)'ZM3p]\oi(<tnUCWeW(RdE3SaN4R;1Ai^OTQ4ks`Au(9U?pJleI=P+6SIGG3et?/>h3b0.-j6Y5ill_Nc-.1_R<59uS#]G6J2^`MhLbrdnjqW*Y#@Q0V+L<@O)]StGJj[5TS679&"A\X'#e)l"GF&oDnoiNiY"OLr2k-\nF`.*Xr^b!C7XXob$IbsK8_9;2!15KoPDSlYEgF>`+86/=n)VU%?!p>Vfd=1r!h')Npf$Np:V?9oB]3O-:&AVm:F`TY,]E,1VISjeY(MUicOJ@;k;l,%^8$U)]c"HCeASe*#!sgWR=b1V3FZL4>AaJ":WE[T;4AQ*3Q-4JA*#Il\r<sYA'22C7Gb..VYdF<6b_L`:E0>Z)1Fa1mMbYM7f:Hq%*K(q$s0f3h1+M_8=%W_Y0<;qss3"E)lV&@ba`1U!.Yr*qUD`H>4NbZ&`(VpTK#4lE!hT7UJ)mF@9KLdFZV^*@t0.oQC,P'_9HeF5=LVKp:k^^2DfkS:(!!?T2oiH5*rjTB[&\H)r[bkDM?J%eEq4f$AL7c90^Fm4*XKH4dYDcNEHq7&;QJZC3c]b=L#c[I8Qo[F.)D(2p1Z^7it:\(G[ejJiIt(W(@SjtuIMH5:RP\Xl5dNS`/HB>-HGlC9Y&K?da>24%V6)cQ$<l>f7gc8cr;m9=qFdZM1bc#@Zs\8lVdJF15Jgjkr`8$Gbt>pg@1FZ.dQIA>DYnN>(!F3g;#f^nXOE+rPMdduTbHe_[of@tBLQGhR'hP#7b)LOIgf*s:c*D\HB'tM(l\%76l>/pF`A<pp'jYSRDd(dA?`e4Xl_P<-*:64ZnXdSX&,GIOe.dVTLe/P"uI`#l:SBiaUp2Qm2HpKmgZ&$B11Q@D&H.ph/RYmKoc0\mF$GaEb0f@h#Z8Gh[G;U-!]<5kIT"hWkST[_cmlTd?Ffq-_moAXHh`!KbmO?bhoo'eG2k:.*S4!g5C=(aX)[&fd7,=NMkqne'1BSQ?p[q7YJ`eW0IVQ2mh_Yd;Qj!95AGlBfa.J!NhfLQmJPc8c&*>i-'6B^eo];])0%6ehS9rQB6&GN8"pR<Cog((4dj_.XL4fN3r/kDO,7rBK"]#a<Xl".eB&(g-@s:Wg-(::o.,o>"8qJ(k)GudcFfA[ge`s2'<aD7\Wnl;"H*Z4hDLC#)VSaRSffuG*R5E`$(V!bWX8CXHd?cUjce)pFU70H+Wh4L5Bkj)=<HQT!Q0KoU#s3>n/NY,J'WTAOLd\6AhHJ+U=oQ5M0J<dOJ#@AI"HG9r/:Gn?K-tZ7;cS@H5.`_$&r3GH%CP(#3m1Q:U*f#m&1dOIjnt3N*03u'(4H+a'mEq6k">/_90Z,lk4YZ*M.NnHio4hud*"g*\90.8`MBjjkU^>:LK2=:HM-/r&ZOU$S.5htD>C-LJHrlH<<>rtm5n>ERM&SYOB[:J-?WI6R[6GQ#!"lF;h?eXA\"gU:hK<S2L3O.O@Scc?!)1=keR`)KHE`..i@0Gi_>i+\h48jO$c@H<S.@Fe/l0s(TQDm2!0TA;o8nQ&iI?*+&K*,Dl\K>_#UE*[@-gAJ2->nT498P`)`EAXQkWaW['90)tf>KWBTi+Xj04+%:W"&iV$kslUS'fggti$jZ8EDF];#7.C,EW=F1U:bME=;Lc*L@?.+4]9_CnQ_8mkX#YN*8IAAkun]E!jVN29Tj\s;KXsi`"<S`g>2(pX0VrOmH)`_Z@iPe8qX/NT]ki>bVY7I0hn=i*ne,iY8@bT3.N`o\d'1W?^ds_H@2ncA=U%EUZ;]!m=f?\RG8tR/ANkH"c!=od5JRE%!Y;:a#&NS7P<0tq9N8#gc_fJ2W53d/RenJ/,s2$%XBT>W+X0CLWS"2%;[cb=VRQ[C#eu;0^^:If.Ar/iU*8=C5(#?SP(Y1e<c\r#!=u"IT+hImZ.PhP$pNXlk+X=k^>S7CEio5/No<8h21e#?GNdGNum0E7]-H<6phthXIhqr;$c1`<0h3Wo+RtS;^r^ZSLAm)t?\n3b%cGAhIp0F5.)4#-)(O,O'k6mp]4-mUUDQ<>^Ddr;65B0hj-RV$%kgNkt(L><m`q?3=XC4D^]!sht!&j_CZhkqVQg>mEBB66?aZcO?Z@)M-)LKOo=#2oYc<]=2/7racpJb&]&Ekmi4[Ks3pOCm7\]=~>endstream
endobj
23 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 3274
>>
stream
Gatm>9lo&K'#"0DFNqoh,uGjTIB?;*-V7mmaa+rQDUmSd&s<fi"@J9Z28=TI"!f1>@`k.D>`fSL!7BW+]3dMYdhmSKrJXXKGBYn?i0,80h4"8g$gI)JhVXlP5P+2&VdjW\dQEnAP8TZo`Z%\iM]a:kpZJl%Npjr3Z4*\aS7+LoIDU[*;X>Hd5IJ*VGjk6l8!fk3%jU=2(hlNO0n$,k&Jmb[jr\^>NUD8W,Q*S:QcB@JJJT:j7YW5*,kQHkB+#VE/mO^Lrq4K>c5*_d2_TH9O)OA>R5U!$je>ll/A:iu(POY`G3Yg@*+s;-HuJP`2;t?Zc@-4]LHh\`CW`n^G46;8L(jcA[rfbS>d_&.&)h;nc$?grKkE>*LWM$5/:tuM23C+0GX(k_pJ3!XPLgm1$%njs5`;rbNu6WZf]S[2`q<7m5F@0ui%%oad&Z>bN49rl2OM)=q?0&cbg19,Ide'T'5E`YW_d_1)k"f7Pe&iOb;%1\as!(Bs6:a[j&BS8ZVhQnIPH(p8h)X-N(Tp&fX=!(=r4Q"P(m1@df2MeQsZqY!Wpa)\nlfe>d(m3g'9V\;8l.>Oeb[;dL>@)mLg=GMKHQOn;\s"PtYX\nQo?uPSN\WMfS,(ZZiTF\<Yr%<H-`ug(8O9kQd5TONs"0@sfq9.5R*O6[+HA/;[3&$Wq_gL([iXH&-.S%c/"N0(JR#$J%'tLdL'*YD5`**Ikfq-@Mk13;1A.4IdRj5JiC"HmML$.n,u,9TE.7hRTC9R#D3r#8,(Cf(T:CW:_J<9WhB=0B5j]J8Sp<X)cPm0t&%-C;7if1=fQ5l:b*W=OW51%W+:tpi@OL-Y[sg*:fQ@^M$eB80LL=]IZT;M9UdoL3OMbGmffcH]Wp_\mq8]7(Rte/gsL`aflkmIpk6MmuOoO&.rj&ioW:!d>js'N:+:Y24]*Ch>h[-]/d@u?:eZ*?g"7XeXuD7;"DN#2s\^1^K/^].1HHu&8)T3>45G!BociH<7."b+oM\rNLWoWaN3@,+l60%Jn_T-JTMB7c"0c?i8??f*!$:!XssPZ-W>o.3Y%rl"oM)99LQ1p'&hW6;:^e<;2Pa=mIWT<!_^FRTj1,@\'c1o',Qi]3(In<VMX?Qf`e95#)?Rr0%)NqO;RI!<Cg":*$l=LZ\HN:$<[_g]ECb0<+SNH1%@P."Jf(XcYg%VDC?Tgq'_GAZX)g)X#9(78>k*);BheM2lXk$*1[^Rb+hf'QemQMiCM1,nM(_h=f>>S0\a`jd0aP:A0mn<;8Q`eJu>i#CR&jbDsr<40b]OP-\TL;_7^4An#@4;`L1ftOAKFi3dPcK7@&/l"elNafGM32\L*N@H_d3cUR_iHY\sq;K8$B^TE9:tWuM_=ZPf[J!UWJ3\^=Cha^M0:")'qRAJN6@if&'i)U%ZMq7d:T+CBi0B['ugmf63&(%OZ4`sZo2naPHgAJ#XsaJLL<W^J%faU!\XLWLt!FNB'VDg!=akiBQ\Jo^M<B*,U%Ak-5`>F_)/]3oWD#WN5*[6[De+]$/(s,jKT.=;HB0q;no/N,F.LF\j0Efgj)Ik[!O,l:OCP/;[HMkb9d=#oQ)_R:K)14b43*$Su_b+u7\,kLR1LTO_!"VmKd8Lp51aeEX++OPmnC"O[#@!W&eL$7CC!!XX[,t-B8].Y1R(--2:o2;MUY@1e&5D>"1:l@_NYge9.gtPF)/qu4mLM_1H(6Lpo@9b[J&+[k8`G[5IH55lq&NaU]f'>Vm^a][+9<R4p[rQt?%aP=D+J_%5F^\YjoLL`TANjk09rP/7R.2J+kJulHYZIieVPmSqKROuOME0fbObNQJ="$+5%`1.J4)ad:6:7K,["W=saAldXk7DA&BKb+J&=49?/8SrI`BiHbZk<Ro*s)E,p+ok"A+gLmj+^/FjtKI_T-33_.a9QumUcd';P!5DJ@&Mo3_j0TbDtFofpWtX$9A1R;"b;0'bT>g\_kf36?NL<.@FF]4ib!kOY@tmd^D2urt/Zoh"-1i^t6aMP6[t\/go:O:$#f1#=mHXT,N0FBN14\="=j8m0N&4`n<tL5iEj1?jAnWcG/l;gDRG?Xk!7"?d=%Hcc5QF8@K\e5R&8?qQ$U_8%N\,?gZ(I^,g^dAc1mI2ufZ'JYglhN#ai5XuYf[gpio[q6cg!j*&FseU#5qgDPLl50&YP);D]A'6`#5T2"'qfO8jW[Un9;45<I17rJOHcs?Cdggt-Oh<7]3a^e8p\^>lopE-l^BB-J._k1@l"*r@^%q&+L(/O8A]MH#bjG3nQPt9ZA6QcO(<R+`X[;3Am8)!SK+4b^AS)0d]s$iL7[KKCmT%j7dM/2IK5LFJ+)un`#p!#3k07Ra64798ZX\=C2'm$$o94QcOqeL"+/],o5b`lR8Li.LY@?5!Z[>r&bks:_p^Q%6>%o+2K>2'778<T6omRJVXar"+#A&`1$BCrKZ5'YI-"FUjLqA0"F2QpAPO8B:]7!jCYTIiOT<&@d2$W2M5X?nL*DBo%KcGY*VV:f8ga,%?dYR(*k/"_=N^.q7u;55_KYt(,eP<rm/>UV9kToU$hUSMa01-9su&NA1#Gdio/)?80JGin:DQHppD(Y<kR%u=\W*n6_CLW2D`m(AdT!LS5#g/"m93r?08MM`K)*l7i_3ioYS..l5u5Y,]1d4FuF/jO5.O$74ghnSg%beS@0%Jto4H4Hjg,BZu[s5=EP8n*"X.C?4'%pDnK;m/j=-eCfCPm>'HkYa2,mV.AMQ]h@nP"H59W\Cl>i*HP*43ACEOrHe(14k]?^[:OSh_l=\@:I"a.&mpJi!no@oB">`4)X*FK+#FB!Q?n+.+j[C',q?02dk,gK1r'K&6!IhD0L9'dSI#C\s33OXXMG*rd&Rnm.l36RU=/RX!&G8\"MdK.taNJ'Yad14o6a_[T?L%mY[7W]t6U&i*&O-?F*YtZL*q0H.t]K:P:3ll?YD>1TPHNNcd[u2uJ-(]B'>k)hq%K\<L,3>\p]X&7hii2&Opb1)d9hOH.l67Yq#T)F.71l?]Xo$TYs/;\@'CR.!<B@&WKdX'DIbm]_boWAc:pcIq3T$5gfGlN4k^Ntj@(G9%GQcWC#;pBgMO*Odhj[am6Xgs"Dp[;NhkS*QBH]'FOl>ds0LgiY^F/if4mJE<I-9gNc(;dj>gI:.u*l"nY["1%seD/'D?GLWqoooM,#47%[RF1f:I[[b"+Vf$"7F2mu:^YWr;Ud&ldDPA;&eM\@R`eN>_)[3hLN`-:573"HEr5"-:@.)9OPPpe+9V\JXF>1WhXO,Z#!MZf3]D~>endstream
endobj
24 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 3067
>>
stream
GauHOgN)%.&q0LUi%[<);$??6b[`l9CaCs!m<N;0,U>,(/\K`:R@8W_7KXbZ?USX\9t=_!!.f%ITGu^8*(k&BU%g\u#l4.E/:XFJ3oj^:W+UD:iA8#i\GCYCeW[^a"Wdsjf5^t\![F#u8!2gDMpa<K6MQLUGNFouHNhU?6%V`N$<Z+-eAp)aZ1bfV,6)6q,@@I8kCf*LH%5^Jgt+[a(O>dF*e"^\^V\;)'l83$$B>i28iW,pCA:C1.eP3C*YY82c^?#h'G>h=4G_&[2A8,3g&,l%Xb%s?i6dgW6/`JXE;a2PljQX/W"mRH6:1e(V3>0=\l_iE_J<C9a)eB@`>gjJn07tN,A/5>cAMef&I5a+P.Q-)CP7rSGR@K&,HMP]\BqjWZHKhsRrD7b6dCaoJ(lo)eabX&31g.[!e/=;T!ltr[rETCEjA>5A?F@LUi>[$E"nk.#<QZt:E7Y]UcQ4(91`LpBU'aB+lopcAk>nnAbTI)Mqp0=pHqrH4R%)_lC<!dW[WbT_j]:jLL-drb+`gSdIEBb?(@bCK-&Wts0q#_a`>'N3*`b@?^%p.?/JtPY1iJ&DYW?t/,QJDWg^Ee\$dci8^?$BD@^:kZgR5T!AJA1%LdrWYbGa_[[]b7W8LIJgbGPBKCL<hP>5RY="F(EF3rFJ*Pkm\^VM);*_0`G37iSijW4%R*M<ZGPpq0j]Qq^6*d<C.US,N9WqN[^3M:*I_`_NCk_:meq%V1f"b0?H$#<c%[<RbM\Nc0GhR$Ec:'2PlE.aP[qukF[nf;@WSs=iO\k3Lb,irmkA9F4Fdk#a4Yo*-Mdg!Tj(2nK?)[9(%R]sA@W^f$+g>_EJc]WqWH5>b\[<uI'T#7VIiiOPI2CeDVLaD8M;ngArXIFpGmp!X.(L,Z%m",$t#CnaXD^qr[oPIYEnb8WaW3GV0&5JmVK7E+#-,>[<p;d52"`3)WYiq+4qtO"\C;Cd$HPBp`;qm=N',PW_Z>r$`\5CY_qAMgDi.<0qm_5VQ8teiS>uueG#4N^05/Q,VI02YLo`:Rb"3u2ef!)I!-_"G4M2$NKZ\BRT2AgJ#13"Z1OgBSdQ.bY!]Z2:^7YQ:@f+>Bd"ABYd:Z=ineeO.2MQmiT<dbnP@sdTF+r@<F\n\et4OF2E2s_urfcdQKAF66;mbi%KV%[k#.P_k>oN`'HFgI],fr38QL*_2Tc8k^\mdh^B/)+I!FTS-k9R3c6`J?(P!'YA2@%U[DJlF_k;Au6J[U\7."(\8.`LpfU]J)#W9(co<95lI7h\LT#%5KF2NqY,7W3quq15H?n:IiF)dMo_%.^\#B3C#N.Y3P_F]gqSb&1[3N7"9MVVE^G0S\rkL$q;e@Sr,gDr\t!MUV^qG+qa]>emOj(qIF)N4,KsR(^up@GJQV`=JF$E=JA&]?c:E49D2UY;,%.*nM!;"o!GU\4bliPF'@sI`Ks/3FWH*3b2@R_>UP:Z9A2@:=[#j]0ri,-E)B=6&]LA)L&4?p9+W(/9X6hu=m-3T_$C>k)D8\g.r_,lkSd@NCQ]Fd:'%p/K&/Y<@XrE^"MlsbPoVnPR(02gi8Z7:nRdJ"e=VemCkGTD:2j\SSFNN!&B^g8pF?&=q/'!l)2"]EDYhRcQPa(Q4e03!obLrRKA422\8U?XEQeLl._o"9ZcUnoH1E0ng,H/,5Oe-r(!)_1ma.i3JAPr,LQP*p5$l(-pG>j\$S:n,<_i(ff2d=n8WS;lN#[7+drAGs'<3//_[%:Zl.?B$_'[?SfcEVe3j(rV4cK.G3*8%im9p-Uj(\_O_hc"SrQs!+=;R=[f[TkN5D[=Vm`1d+m)S8`DW=C=60_&n.5hJOj"<>5<<KP'8%u]4><'&g`l(0C#(-^QL:7Uh8p?f@o&c&]1_M+r$Y^7m%!2\r@JWY-`4^DQm9`V>"=o9X/eitQr5J+\Ctq#5!ie\RiPL(:[E*FY[Qe(q<Ts=8'#*jQ=/BQB/-SE;Jb<@tF'^.s6riW[$iA_eZ@&H@Q7b#AYtQDLZM[@&gj`+:W_mnVJ%pK#g1@#\mmSh!r;OG!G,/k@[[2m@;t(!3-Qk;ZAH9T%;!*/mc",Ac3B2M"l/0Z)B6gtU6H,lE+)`Jl0e2XPGC<C^7LNZ4_.#^Oa4Ir'g-\7/\U0&sj#%$.,.#1sInjk1]e$.N6:V9gM`'QEF`[7/;TK^)?j84fV(#AY@3V2B'U_Ej6";F87b]f61mW]2lZaRHk$.of!-'!2'e!WI1DiW=Ki-fMI$[]]aF%uj]jNnML2AuYN=$OM(t80'Ok[m2cDOFbKb_0lTTRO$mala8:3n^kR`rUiX?cTEU&lPh;imLks,@'7/Hoq`n>knY^/-mXOr[?ld->/Ia1JZlm`I=f!n:%\3d8]O"eM"dR#ELL"/RD"=EsbBLfU6WRlmt\;XCP4S#.Zk:0,KNXIaI(DGRfh0X`..SJ*E)Nknnk!)-?d()*c#U1@6o%$Z^nHg^Iq=%HbAI"Q]=&qqCsUC0_cZ`jG']Y,GL2QF_)>`?h;P8r2am4);WXWWK94,8CM)(^Om#M)n5Z>Uk!i<qrV:)Sd`'kRGR<1YiRID%6:#YRaBM0hbm8^JGu^ljmY-kqlPd!XEMQBf>o5`@0J!t_6u<4"1n`^15m".r%:R3Q[AnjYE;b9P#!F'Ro.TZ;#;3Drib8Q@?268b2-)q8=tk&4)Qf:M_AE_n>7pk.lNpL79B2WBe+jC=QeSi%Lo<hnn)iQ<;+'tT6Zs-1q/`9bntl\sDH[#je3MV0-Vmi'g[Z9B[;J&o-.=/;&/Y3j7X"BkJ!`H>umd0Rcg*>8aWZUN8+4<adBE?mLno*6XSIP2O\_=gD/\-"JDNkH,.>MYA-OYH8p@KP9dC#BK^!&b)15ZEXVBcrkU+9.?rp1&.d'Bnq3fGqtcE$Q+6j*,Rb=*'-UUQK.+AF"IN*B++H82O^i*7Im)1qmqZ#"\WKGP6YcE"Vk>/>GhdTrc<"s)qk^TfgjHXD(2o#tqkUCV"Op7i2>um!#'4jW7\*+#UTG.^C2k<?b%ln'dTXP[!*<IdOI(IK"^iNY_7q[t;C9Qb6m6:'+=MWqjaC-Wg<M]`^Uo~>endstream
endobj
25 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1719
>>
stream
Gatm<?$G!^&:N_Cb[\):(u+ff*bi(=AiFR@+Qr*lYQ^uXQ7L]$rdCsq$qZo)k,](@73+rUd[`HA_BTj=chs9n7.9:qpumk'dsO_nhukmOUCZtQO.LPt)7iQW;!\^`@>$5"ESPLincNnp091W$h]m>)p.;B4;!/iN$u);#]2)aH[#_T^X-#a[s3cEEBeBa]-&V;gqLdqfC&Uk3reA`llX:VkrakDLh@b*o7mSu+CKq*R:<RiEB,lmT+,e0cO,%Z;(l$(*_r5%Icl)km/`YK/o:BX^KQ#Z[GY\1f4_Eg!4`E/?ejmaAY&T@!Mo,Yd@#Sq,mAIQkT%+AoHlMD1P"(I/FCkFH>BbC"kX>G@s&=iFWBDO_04htXqB^8hUBCpr?0FgbcOYX_39-&%>i"oOc(\;+qk60'8!+H&qSkM#5/Cm/2S9gEMEWm*qBU-2l*_TNs(?X^odO<pai(3PWb-AU6,m&2d:,n:CfJ:K1qT7(6?/<m3MmMr>WFH8Se=\2+U\SCdd((C-^h&$OesOOk5=p.5gf>bN39=(,@N7)EMQ0haQ?Q"rdnXXk,0)tQVuoF;l(C2H^mAOWl)&:[G:6ba<aouW%+Ha_OX[a<Vn!)duH*4;JrGq&1s?^QGluX?-VTYY\XX9^(FMjeKlJH(Wktp`lF,6>N9',hgq*GJp-5V<3V:]X[kG%q\&H7QRg%totCrMdM3"q'r[NY)(+HcaV>cq\lp[d"*EoH)UY(?-,Qc[pf;FYZM&HC4/k-]9^^1,4c3(b3lCc47[hk%.c^c#4#K13V,X]GOP;N:(rA_^q[)b^Vh.bm((0<2Dt7CmAT5I@@k2M@bE_&S>#_oZ9.ZT\:c=5*<9nTJ`]a`2=#[ZWZD*C@oKM:@(WkC1m:4X!"j]lp2aAUc*t?*<k!d7=1dW?;*H4A(W).+<fT-$I<+=%64[%TXp$(W<ZW2,,BYA#sk'A["\sZ@>5oPcRm`NBleTq'cUZr'&rZ5Kj.@G?orcWQ4ikUkF6!L&f<nM#:2=bH#fE9+9!HLHB2"H8d6I:F/_:NKRUcO9<&>1]Al2hKMJ0.]Fe@[@Ko'ld/SQc'1GIE+t.;]X(!//g]W<-0eL<-P1nLNJICeAut6ro]eQD1=!c-NgFa^)^t0bgWfTdLs33,6)s2TMV?%bRK@N4L5\dNmF9BZ=-L/)AoP>WXf,@p9tn,--k&:4[5M%:Ij3s)D8h%jM!T8lZCD6%<5#Rkf[n;OnenfQ/rPH;WGOG(F!0f;"h^L]!?9l\.a9EiHWoH%9%S[LnpFHD^cH'W!/.M0?[S/U*uj%i1O%dZ,("ntOMpJ`=4RElu@rOUAf)XS6'LZa24LYg5F4_AaKi@_M$OneBI8FOkUn`s)bB?XtqFJ=T9O7/iq'[&(?KnKQeR!aD5Nd=F42Ad*qIWa>kuDu>%PiAC8%*4t&e_BUdqgisO[rEh^0J%I)3jkMs6m5Ul/R;ia8pR/sJ[`Zn1[bD0H#nJh(I%8#Oq]NrC..tTXF-pr/A)>m^_@0ZEBSbfnb0@/URl@d@RpdfX[;W2&$sU8f?X(&Y[W4T%-F.YJ4IuLoZ/P4ZT'&D;`kma2BRDmdjg$gA;^)e12tmZS=T@5erhn9d*pp4\$b*n!35aCS1k8>FKQ!e/^3i^\`P+MPR!Nna0/W8A8t%5$f//\J?l;].K)AHH7)A7@Je7e#'6665(]ogJ)P0o';4VYaWII]g`^f=m"><9*(B~>endstream
endobj
26 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 3471
>>
stream
Gb!SoD/\Gm')nJ01$GN?c]KMV[d75V(>1P!k--LQP/RLsNL"6^)bJQE-/P<YJ%qDFhOMNie^_&m@3fGUOW;Y/*6p?>/26U(nS^&^G]O,M[h`((go\A4$jZ4W_<'Lpf+Y34EbZLK#@)Ub9P2o\>7>R6TA'hmQK?C3(Vf.qA77j:5/H3ZHA2a]76%jfes&[se(CQ+""m!+LgV7@NV%fjAftP)0#(h3.'nE=h&!TuObEKE5L0HeXknZ^L0KRF)\X!!=peasdO`:&->=%S+HM`*=h)K2YUhMO=j0aPc>3u'W+E&rYkmkmHd?=_5.mOP(4P9\PA/iel53IUagR0(Oe"&P=Ho?l\F?7S9s&`9V@UNF5?aH`&sG1c5D+cE[WQReXGD&'`WR&,\hnt2`>-T+*pE5jV=hOE?@:Z*1<[MgLcC^ZIY1f3#8R.We%Cm]LQtIVpfcU7JVs1t2R1#4WqR]/U^qI-k!^blb[U,C37s7:>hm"=:+^hX/.eEH_Eh*=Q:$1N@<R!tVmg&Gb]j47fQmJNS<b&2=X$"4.)I9'E4o%9fsTaSMAr4L#e2hfMXXYkEiIRepaXR0Ytd_EJtJ<3LN6?#EV_tMbC-586T1RJM#CYkEH/>RPO.*!;'"<q1=hUAEXBPhmUNYa7j(W:&%/!(6RhG1O'n_,M9Rdrk;!fm5(Y&F/"*9_Y93F:S7?KYK1Hp:a;T4f\&o(H#`S4W_0M1lgoQW+2gP+8?nBeo?8\*`5PIGgQ(H#DMH%N#UOu4Sn-!r$25N'm&#b'X.mtXC'4Wp;Qu?LfJe1hVB_E]>@$/[(:nCA.7h,^a'"792UjGIS:r.$?-";k.&PU5S*Q77u.L7&o%n2'_qQ8RC\."!,T3_\W@1OGL(QhAG$m!o0]FHI"[c^@pau9/]BsD"F+#^PNHR#:.p1;qa6p?ij!odc:e$Y.J8$22aU^?=j'8P-Aqu3!Cr4EVeL2&c&_a+GfB:fL,YKXpW6F'JPMM`ZY_FLM-]%gK567['Z90W5A(bUMdNsB:(m'8$lEDY%%S,O+4M9*A))iVNGObdeHAVPeO/8g)f'>tT'+UF!2AmHfifGGV;-1HVG:h4bE@m#ED,1HIp((nMSIuqN&n2kW(RY1!bG<pU0rN!FlhJm3)I^%]oCBVqR._$DV#Xkd?,"=\kMBo.m!r6>XIqE$qd=br+/#@4>Zk**d^_f=]*esn_;P";Vr60/(j7<4]jYqbJL?).,FD$`GQh>?N*TC(\7^]/,&;aKn@=UDKbq['YQe@'if,QLBr"NcnoRVV\Ln^pa4[/;>r.)7e/i=IkNX7s.;FaPlR-6gdSuZ[H!6R*E^ps2mJllnJ:J'[",/=c]'bm'hMjsIKCngD$oNDJB3N->^;(jGpIMQWS^7_J1S[Ei`lNfkSn1e@W"h[FU%[iH.RtCp*%tWMrFeFaVBb`gL2cB'&-XtGGq?@?k1&*;/ChdL6ERFRTrU'C$;g_tiQ"n22d3m*`j<N'".B=%5m`rGl2R*Jq&d^(^A8cX^W^pQc$J-KZODiTkD8WKArSn4mjOc2]:g<1.@BH0:lOrnlMG2!KT>4s(!c-Kk'&glqP!*k&nqIgU7TLNY550F>_T!"q"#q5pZthtd?@g<>>q:.=l.qs8GQ9\%MHW>eIX$I'?n9U20cjm'p]1d5aANq_1%g@_!Wgm3cd0,o>oKdnppqo8]9bqa&,pr:_7=Y;dP\/Gbeih:l-:<Zq=t/SiGC(e)0*qtQUA:E(:1)"GA>?mVf_f/]e,8WaqcV]/URPb/R5G_G]/p#>cq9jQW^b9\S@O:oe]pTl8b!+la^-J1KDos>h&ES]su&LlE#\Rkk41<]5<.GV&&oFl@44B4mYq)8Ilo6Wj\rJ3DpcdZMrG`CopAnM>\U/Q>U/+NVS^Ylff+Ycb@M:CslUF;'"1u@H!)jVRo^4=l4L@Q]AO&IJRKdSXLhtc/$)CA@UMPQ:7kMNo<W]*0h2/%*-d[0#,JubiZX)%6,^_,`u?T<.!"I\bR4N3CgQLGoLPMP>$,@EF&jmLR(-fUj&=VL.nHTJVW;!_Z9K#eTLRXn,>+oOc#Ju?ajQd9apD(#$pTu1+SN@&gsWk=0UZtO\upc834.kg3pMSQhGgr<:U<D.TRRcSlLTo_Jgml%l_q37,Na)Kc#S_K@iZs_/I(o+KbV@pXaAePJXGK%St%$/$Ik>i<'_S-2M(X7%>NN^ri<Di-5S0OiBrd1"pK`*9acYE@]3@8s:%n:N_[fnG2d2,a@"$5BN9&DY@UL?.O7Ih#3KrLMQW0e=BI5La]U8;3XB/W3MV#W=jU?oa0uq2At*eb!]7q*X+rqgoV#`Wjb@-e+nM6k^r6Dd-*JZcj+YSke,VPS=p-bomj%b%`g+HS?bORb16`k3(YhS7441q0Yjkf2t%U$"Oo5-jQpC*K$i8n[%3e)X],4YM!rY2<C&2+JDpd(pkR1[?1MMk>/U/7-u";7(E92+8_^WVXE)(]45j^'Sj\+s(TE*qKZ&2YoUX`^@LdXZ:gOj2]6U1hnUTm/S\!k4n=Q0%5kehuk7KQ:RA945JZ`AqV!*+8hI;fVq2gg+N'b6Y3'4,j$WTr>0"V@1K]%P.%9AMmSB<:%p7_#.A'.>0O?jJ"Z?`s=7&P#n-o:Fr*@W6CWSs_=k_(e!@A4eI7,D-<V>Ne/5(6@](SSo*E5nK,'#jZjftM'anJ]*3Y?`)"".!XD=0Rq!@4@Wh-Q%:[_.Ltld9S4D,hT5sFpRe:Dp)il@])qWM/\aQG+1fo)EG<4[4p@`%:$""B8`3FF0VUnAC6QD;mE?HXI?hDZ)6;o6b2$AEYI8I(,2`L[k%p-ACA`!B%8PuCO7crL;"^'XsYlalT0%C,jRH;m(-oXdTNM/k=BW1/c*4"ehH4G=mo_7Pg0"GIEUk'VYnJin%k68o8Xumnt;11jiS:(4:RU=5d,i2Q[/ZnpG$Vt9io#6B?Hn6j%(L[/E:H1>j<IO4];ejnaVQ.0\oH#UVNW1X+0qiNk^oL&GkKul[X$KD3%PT`Z"6WbFd&UhGFbNXaBZ!:29_n'&e0%2)22SOMtqjbK,9'Gk\/pf+%]/Qf[>*O?T3qrl,_'kAIj;")9ku*oHc_66u@j\22VUPUc<7c@aETn)]4k1d?7k6,*R*BFfhLC8pl,!]7*#[GG)cY):>CSbW/qYa:p4@'2$Y]e:C;l^`ksQ),n?FZ_/a"l4gV>JBq'AFkd1,.rO@T3o:,='mCYpR6:oZK`GBhU*,]YA,-WGGQfK25f(0lV31"H1l>)^9m'DeF#fR@8^uUm/ls=)KkK<`BDk+RW-2J;1cbAGCQ`mG,WMXDp+Y/GCNj]6oA5Y(5.Tq&_3g];,N=j>!s0%GA&nX5Fo@W5:QLH%9(9cY)YHAJ#P.LY/Z`ZA"%K:`ZZ^*@obas9l_Dc:>\2I"bBGU\KNqb19Y`dB5e`l&N'!3]K"[jqe^nY2;f0+17M",+g:.TM`DdXQG%nq3;FZ!&\dB~>endstream
endobj
27 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 3106
>>
stream
Gb!;fgN)%.&q0LUFQFr5,?"Y^Ou?)W-eXrfUuL%`:"?tR+;0hSP6Ad](%'WA!C0Pi@uGL7PM.Ti+p]I@c-q[?5#!c#iK*7'UQ"EGCRVtK`*Bl['D!6:Y2@Wrl]\M"XM$EW3MB5i2fu\D95qk<"cJ*#PJc$8o]Q!Sbe0cFjlQ^#Z!N%Jbiq1+r\!ng4=q^QqnHB&cC8`7.oJYF5'XBTs%\n)Ng#oLH.n)'F])6tV1Qap&c(>Jq?pU/U,;b'^\c`=Y34$L[EGgc3Hmg\_ZcUjBe_,OrQC&($l>UqH^AIR_^Dn^Jo4;mFFn[qk:E76`b0PmB<KX+hHpBFi6Pf39f:Bb]&+CJVaUkr2$h)g#D4^cFU"87iL1OBhWO"T7&9@J+>Xc5Zle2=2>+Z]B6kkI)*Ypm"k:PPbROIrbi^U,4KZMKA^C[X\q(I:.!3<kadiMr-25=BMhpZZp:fVb:M*Vj]@LO^am2@W,YP.1^0W0!eWQZ/Li1Y:Wi$G)UF,([Y/d7JH(<q7_YlTSA<YUjdU\QCOu004cD175'J,d$*an<A-qD(Y.e%:("MM=0E_T,3(!9^1TI<6K5jF&TnAV:WY8)T#\fnej77j#X4@uLRK%UOc1eB6Y!Tj2<OLFeqQMHnH=d=17BG.i%K]8d18e]?YI_+>O_Ef:r;P)sVdg0g0!BH%uXXG^ZH&See&6u[g/ZN;7Gr')Acb(q@I;MBKbZ]u+Al\Dk%)+HiJ,asPTh:QY8H`:/*f2YEcrOaFmVkLj74AUAepGPac8VJf&ADaJ<@Kk[\#j51arN@Y1!!H&TsbYbc\k-."I&7T;Vm@?Al\Ib\N@dXX/ZAl8&S,4!a+<D[@5J\q%Z1[RH/K;&>ROSce?:8P;WJFMJ-38%9T2ZIF!A_WTRht_UN)0APMB(M5+.i?Rkhna^]mXKE/F=3<FRRZ`M7\Z)NiSq0_dkD?ODdO"6)abEV6tNF-*%WhPXm3p)dY+So/:.P"V-2K^Hn[!`4t5Oe-b#rFCPc@!Wt+f^?D31DRK])9STWiY$MbfOr]Tnb(&l1nID3P.(')r>*X5Ho=iT`?e<HJ$L.o#T&@@5f82]afA@c0Ng!bj/[rn'2LFbe,!?o=>boo6P*4).V;t.;D>cWZN<6&Hc9f6A]s-&%oW(eLnWQg&p!OTsM<_P?"C'`rpO#+\QD6M2nqbME8kBs6.?V;RZkhi_WW>d5O3NB'YooJ>DgHVTq5n3A]:Y"/rn#MF&X$q'O2eAIWZGWO:4^+qr<eW\ESXPA>;k<GJ?CM&=Pn7AR$MZ8(%B(-0]VLK%]7>`p<(&P"r[a$A^kfk_[,_I?hg<ta\YWi^(IjlB+9,JN,-#c_E67cLe@VqTru5a9KW1((SuMBXsF:mRjt67@.BOr+<bGiBB]M0>WtHZ%fg!r%8'Hu+oa3:+26i18_GfDND1&dCc@5K4]"CJT@@\@[W^r/(AZ$i#)>^e&s9S1oUNHg15r(&_X'Al[h.Ni!F]X1]u:l8?_jBudud4J##%GD\%J@pU6/VO?o,,ttd'D).f;_"ks]0iT\r^Z7K4AZ-q(<-a?F)`bF!LmoS_:^)\i<Ubr4k_'94&[K$K'^mV6MO;#+lWNj#&Z[=[`/^O&=0d_9a91a+B"\kG4q3=)JFg^jE7XCq>tcKVkc/4u>-"=>CED"rU!`tqRaVf4eI'eSDGo\nCcR7)?_p4Lcm);'_-?Sh6j]EkY'1SNg!$^&c<b?RDk7#b<N12X5g`i4U:q#T!MKPO3/YnQ4_970U3]=<,b>(^?Qp=NU)\?,a8r09,%@s^Ffroq-bs.jl6k)R?f\4*![2lG^mp-]h!Ll)Z5#(EW\j`i/[S/j/+U\jZHEbk=!MB]Q//1bP&]?!'GHR&iI**.lh''l)LblUm%P1sdU\RghaXiT.otTuOa8p;jI17_?gX#&X87fZV1M2r\A:.**1u5f[WhB8>%V^L^'i"$PYn#8Js041kGWWCWp+@EG1@:s0hi?'VC+I!VV%i=`ru2YV^q$mmuE_\:>9*QGA-p'g1=p3Ri8BbO-0XX;KX"DjN9Rug&_Ae?DD%9V)#VZonB=OOD'3ED3OK0pQq^jDe#g1\LPZJfa)!Znh,M-ljC)%"mhKeC=c>GZ7HtkIh6P@CumqO/l7V.l?n/5Qig0nTi@9Z3dUb8QC-\3D&3MRZ%goiSmaj"\<;PShlefqrebS4Zrcnpd)nCkZ3IYHA'iNeEIUFOG1nHmgg/;:K>'q?rNA-D>SfHb-Cl3#pc[E-o0R'Z,r;"kaE*cM=!0PqhPuG_6cFS[2:>NG?08@=[["K*ApIuuButgsh;^Z5h#lKIDtcAlFlWJZ/)TsApBL--_i(ERR6N7\U8+=-I1%RB^6.O;$,I#!@JsWVY<bhh\e4Z?4PV^??>-ZFEis^FiX"d-\6>4\!pA!]/jGld?p,j@D_1fYi`e"WS>VuZ[eL8c/GhBU0AC)jRf0Yo+c63LhkJP7<T=^o)J&!Nr=MG8As(.D..1*UAOgQK<IQ<[VY%-/;E_jT<csLXHKCf,L`$j8fGZ)sQicO%*f1l(ik-M`,Gq[p?h[Y,Af0_f@L]AQg]_%*Qk#)$r7J8>!Fq1bG5[E$n^LrHmT$U9OrDF-L9=,apW`r!Y71ib<-MXE5!g'@7dOYceZQZ,r.K;&Mr_CSb#oF868]1KoS6\"3qE9uR)l9?(F\g_]M]>72k/f:ndY"fjaS)KFtu:210i]\n\3q.FYkc+-Me#=G"&m]s%E$E7Nf/i36?)M&dacPWA"StpNtCIp3=6DUaA!Y-\^a62bSoHXf"@O[Y=;G[N"^:Lj6n6)a]e?-''Kg?hG]V<Vnm&RAYC(epW8$f1NP%;_R?5\U1u[ckaAS;(OS^&"Dr(oN_!dN^3jOWr"25+5D!oUu,>"g]OrJ&8P"U>5HGIoQ`Xhl*DH)o[u=QB5^&-rFJcsorsq;`66Bj@8ENKJNgE34p1`N[k)h^Hiai#`B9V-*NjN1H5p5JfC[eIcPD1T`No.q[8"gGkD&#=56,o?\T&$"Hd3E%`0T8.Sn'a&RJBS"bBNhg28B,ncp]DJPceP]^hqEWMVa_mUAWRfBh[gTN!!]1C`'['-Od]+Q5?Q2]2Uptk2,V]3r(F#Hm<>~>endstream
endobj
28 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2796
>>
stream
Gatm=9lo&K'#"0Do[,a3d3L$#dcBJ6`k1)".N)Hi4`3P2,U"?<K`J5gJ,R(c^LS^XiYn+5&HiA)U\f3.CEO`AIt@J]abek-kkZp)+Jcu6Jr)F[`<Z6\EUiE?BRVu7]kB`P3;1?(VI7@6VmBMc+YO,\BG0?Hk)\[KQ0Sf/(N_`tBououOFjH9IE$&0#(]::[BZM$Q-*!NOAAL:J#>B%ZDs/]TZkuK-rRWThkg,R/\]Zm2\tX2r4Ne[1(M"]]N>?7KX7p"dj1C0#j4UbUt'B"hE75q+auQ:3<;=K`d$33)UH*"4XgfdqP?d"4Mpr5SM\;WhE8oP^NE.*KoOtLa8PSj*.S6C[ET_FHtVO0#67IHN'hMe)sMTJO-IEf>-,Zt>g^c:$XGM-?H']2Ji%senJ62V:?AOGK[CW$ru8Rt<*$,T<P!F[L[QR"TkCq<Yi>r75rbFZJ`C]5JR6#t_T;^bkIJY4Prg3Y?K?Jcp?$8]:J\:OPr_u9'l2N7_QJ!K[rP`2b3Jn;U:o(HO@"qSc]A&#1oA6`/f.FHA7UuD>#e]pM9'm-XUE9?+kAE8Kr+`;_FcbZmn"d]1gNrIh$V;$VK-6Z9"Lgh-uC,#&j)W\8S!M5SLd[/h`GK28Vj^ejci&iT_"W?n"d1HY#Qh"L6dTirA`h4/6/S6iHKlY'#@B\g#3:0[F8^YV))#D$Wtfl80`1(Ot0-tZ*L,NLJM&Z3OOiieM:'8j3a3?at$uDD"R^?UM_r!+dI=Y'gQ.d_[51q"tt@($QfrF.?bs(4d=P0$\Q8lru\f,BYC5Wr7`nRXPV*OjS,2pV0V7B+Xl>XTRNY^kJ(h-F;?>.9)C7j1:s5j,Eo6<%+t,9h^Vc#HqPXJ.$N3`dV[mU3M$Cn3m"MT)9.\3lMR)=-;06t\5UB'O>FbHR4MR"A:"po4pSBeE,_ic_nKC>,163=;kcfh&%W5`_0c_m&#JEXPaq>V349tu#*mWsa2_t`"!s0giZW"!Q&?liTfU\0()fqZFXbd\i##M_gLl\7nll8/VmM[J%8rN@\'f;9mpe?XY*5uhceAOiI^ai$c4FV989BQ>ZVfrm(k/$uQ)HHb1,]9AWAbk,'CXn7c544rRR][/'fZW4[nB'(golpg#JD4]8sMAs@BCD>8q_*h:$tNOY\X:U`#LR%R"bhhX&m_rQA]&O6l^i$%0tr`@B0Lb*DsYWh!jj'qc8nk&tf?cUOoBiI#q<2l-=Ll8*H.0Rr2;*VAp1-;/u$6:,6Z3+c=@)JA;2`K)l#YfP(V=E1n5mo*ch?;_K%aYP30O"VHlk*%cl,WUk?[CqVoL:k6Sc3t@phf,uh><H9dg,SMXliB@\f"r7!CMl"o_1)Rj5nc)c8YTmY])FB,6D5$]l9\5H!o8HCt*7=AT-G[",iY2U:j#?P+PKCGd2,udjOlSRfEX2lZT)lkUGU$!IS[.fkQAL2K>a-^L,1Vl6[*cG6HY/Zu-Kh%tTW70,5H(.pg.Ymr<@7:*_q)5Xjc`:r/N@U\j,dYA"mJ"aX[%#ZP]nD$>STEdWKH4-)fl@1g62^Ojo[BW)[ct=Q&E?FriA.fNs*:<q=Vqj,@!XCM^/Qk>Ish:S.U^,?RjF*i])bf<4qAtS=Io/IR&2Io=VA<R7UNN^Z%e.J>Y"u`u-$TD<1fcPq%d,c34-iOkeL.l71KZ?A6OgBqp(8%#;J"[rWBGaTCW(JGePN>8<j?""!1LXf2^92ff`CK1^3k?DBnBE-#K/,)ULHkIG>Z/"r'-%-LW!7Jint3%5#Xh3I#]FWPm-3ch+`*(5P3Bt\cnUN/Q^@esDtgD)+G/7*#Tj_`24&N$)XPtLo*d&b1)$0MoE'NM.E,P:gX7cJ)HU:?;=!'Zt)&Y-9`a_=F:ZQTY,)H$)kY]]2fa]]N+-*omS2mVi:mo=*N?ZR@'Apn[O4bnPRAU_![B3SN(Bi'%C&+=FSYF$C!O?-XSa>G>IDMB!iiD`C&\9C=KIa-Zsaac5;dn\KE/(5&HD'fY+n$L%]d=A=`K;88IW)"oR[,?<[!r+ATj:JS\/\V6g@ciZ=9:)Rn7%[:1P\+Rq4fH<.W:PpHPjZ[Ai<l62j=ou,!n0O0Lec;HX.7^34Fpiu=$p%pl'VBN(@203cPLOh^TuZ<6uI+dN(C15:'"iE%`l^T.YI;(?1:kO%]WV1qj#@JaagJY[T-#-5@OTo-c\`^CFV^d"IA9rQm'?79"AKgC%eT6SgB8H9)M1bej!(M,,s6H[4#HM;/G(UA6i(g5*"TNPhe5MDj3p1.\o>6``uM(ZJ]q.;UWI\Rn(aS21K`B$^O2XMphj*59Q5m+-Wk0o1N@CF"bj'+M9:#SIE(7=?bn9JTt"bFNh9`E&'.!Ls`J/G,$R3SV&?BBfB%H$Z<[3B(@';O:1XfZsc5Yq/37p3>E>e7q+F[+3@XB,/46R1XM!H'R,JilgC'c?)^f5El6G&.%\$`[+WFSZ%-Tfm#_7e^TSH8Z$t/[rEpZMpl,,q2PGnU?@ZAKNun"8pHV`f1O=-fIadUIlg^VGWZM2nlnFkQV_o^%h2SpG_.LWn]9/cP&,P/Z6*Td\gYGEE:ThruCNh#k4g5fDm;?J$GIuJoDT&0VrFiUPZaecF2;<TS&m4n?B%oNfG8n/+J%#@'6+?0=hb7.'Y&UPZE6?e*4B/fdZ+h2-T==6!_QUVn+Y;k7MYR_8IH+n-LW?oRChmC\]l@<S_?=ZN1ONZRn]M0LH=<<g@XkocE%(LujNEQIQ^UD9W*S)+",Hi]=K=bdrEB.*IXD:LUBRP=:)=06P+UE[!h[q,&3^1U?2Pf/XpH-~>endstream
endobj
29 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 4331
>>
stream
Gb!#_>Beg[&q9SYQq)'rVK`b#SN]&oSb4$OF^o(]%$?XiRNbiH[%mTm3s1QejU5c[kin\/<j4gj"5q)@2]$bti]g6;5McEg\;0=6#G)J#C[aY<K_l^u]Kc5P`+ts7\P<jUg^fnt_H3t^EG(2`++iNuo-g-hkJ@[o5*VNM*6p>r"E;,nd=(LrC@h[hIHpV<r3BX1>>]G9a<,[SMB1"hHslJ3-8tmSqqpB3-PGE?r:aG-J*B&_\0*fG')J:85Su*2$s/0jF'/Hai[ginjQ"-NHH3p!*$^iY0*M_"2aP.<0:ho#?h,#YZ?aQ5(@QUl?d)^DNd*#F:oGUGns0[(48PdVkff'9aUO9d].e2u(XjKNnXg#M_PaFGjM*r-*gIQ"iY3I1Dfmc+Ye'qqS=44q3,5,8b9Dr'K\JWcOeM6>./.Y?mEHEVe/oP,6_.i2S&5KY%rKL8*:lM&4Q[3AWT7X5Sk\=H;/jJQ5m0gknZ4C@kKh!khFE&5ih8A=82p0p#YTq0_bQbAj?uU5bjcl++WS#:Qs>Pif1pd@-0dU$1)9W3W<LHk0'!j04NDqU%M(0[T(?(T"E8!M"Bg^(XPKHBjh-o6BjC33[:IFcC'%Bd$BC`V4Ra=?`#@BL]K0inP>'OB'_kB[0jC!?pl8O41C0IX&YSt^C@gSdL>S0mK&tA'$VWn,=X?ApgrNL#4B[O0;ou:sl0\FPYKK&7Anb;@?GJE4);A6.10tHl"'6([:A"Pd7D!U8/JLV[0e:m+bu/8_bit/01AsCkc"MrKh:Cs9S'CO-,[4h"n1<PQ25"Z59(NK+c),WhVAt0/99;8Kq%s`W!LaR884:"OFM6h5\jQf_ZY_*?K'uCofm[,q6Bk2k?"]RNW2Nd#>&=L1(G'QUXmN1l(Mm0WFH_@*p:MA5Rbc(KplO%=-r]2<=h6*9W%eY]%puaKoo#!n)'Nsf*KYY=BL;J:TTm=J9i0_=e-m#K4'lfL*q)15(Q0;_)5FX9a&jnA7s.u!YK)kliB\\&8sYHEd-@@NP?j(2OAo\D3Y5>(Mt'$ZakeX2.G9#Lc$=d\hJ>rHHB$33IH5>plQd3T=S(=6gXnKOCVA11fWi)$)=SoJ5Oa<!d?6';:-)\SaH-lT"df9?MGcRB:.K!@4+\l]U4:@o&4fOTFpAUd\WG,P(Z;](ET#_G^Z"2]Dc3#7iLaAWa<Ha-&n%"Q&)`2K"On$d7V:6po+qdm,jm/D=b<<'CK+V%pnL<BKDLD<_>f_r/,=BW*U88I(@U5:f,LN0>F%LDm6tkeoA]b>?s47X["a-D=8[E>B,U(Z:0sX<AVi5hgE115Mm&`b<OC+==O"`n.H\Ff?9%dlYE]hE7$QMQ4nt#?[R**L8VoIa]MUp_d?2XgkX7W)*$)@&.NT6^DIn?r<,R](3i`T/Ve^Ygoi.se.^?@@q]oiWKd>\=![k%cJ#nPZNQK$^k]r#28;$7)UXip<G".4LE(0_XW1*NP;Jf(*$H"*&l6,Qci<u4r*nGgJ*`g.A]&7'HZRSa$%+k4lEjd4-Ze49B9_f1_f9@RQafC<KP)Qk5#*u%GPgr_IA9-s?Z2YDt=dAqT4HXYi,K;0sOgLLV!7bq1>>I+$,aDX+6%fEE7>h&kJb.qrMR+rnmS<TOYnG(CcKrAK;+pK,au\Qm_$o5q/TTdqX>&@Bg+qK`qQ&W;@u:L;"[0+tTkYmb?6I'[lM/0H5*[ETSg.XK.ea`ILf.)@nk*UITTR_p7f!p(rnYip+I:gC7'hUnS+HK,e6*t9hJhHTHrCL[6lJ0j']C6G<L'`]csUJfFlNcp.5Lgb+qfAJVbcEGJnF,["]q(24hH^X)RBeH*B^3!]#sfoO<#aG:0VJ6#Ei'hYR\L=0.jK@9109$>=q'Xe&ZQ2UJHFZI3c8_/`mfTqP=F@I&$6tn"]&hGN(*scm1$6d2FW>mHLop&4Mk_9*1GG=[liDho,q&'@QXr*F0J.fPlC#U"JcB[iYgFh3HbRk"J3A&V%_!EA=2LB;kGa?:?fJl:uYeZ$Wgf7Ip1_W3\"3&!tE`SlYaDUk2@n-.8;2;*KqI0Uc/:.'t7=EVH(pD':"ciXW'G16N?C[N%k'SY'rc7f[.Dc[#g"=\;Qgm"p>bBc^nE;UfS#7B&UqW0%hJBW_4ee5SKUes9W&'JW+e84e0[46'DI(!J5WcRZ1`2h+4Y@(&Oqm23@7m_l:cirkSSTd$Dr?<bHDCe.8@aJkr9/bDM']A6ci,!)ITQ]#NW057fRH7A@!#96eZ:?@`?-90\LgS_'./4Fb"I<j<N9b_7L7:'Ai]p"F,#q!&Tpn/L@F,l^"2`Vtd;3f^5h<DUOCUls0Ijk3c)kIiPWje!f.%8(:gB1[S>sXc"5N@U7"_r0@;q4%X`-d=o\+GaBO@.(faapWF-T-"q39DDIP)KjiQB7%?4AVj=Ni)5ib%$TJcrh+G[a$Xc?>E]:C'AC0b;;\*nPDs%4!W2)7D<-=R0aghEm:@(RFiYG5-bk7Ai?b_Oqsdj.@$/<:pG[RgQc.m.:cUPSKe;mTY!$#03"Y\ZeQ@q+1'?GC']r?Oe99&MQ1R8)AIV0;s]tngc,sIVanssNM+\!4T?2\gf#%0/SH&(',;4sO'MS=.P$158Fg;k^3`-1Vro==Q9Jb]_rt2,0Eao5j(Q3)EG4'qd7]ufBr!*H@U_+i6@71ph3WcAZG8[_HTqMDJ!&^G+Y@(bVtnlMUt&U23,eFPO>jC5j"MC"4jrDP-Ge58XB!"30N3(BNF*A/MefWP;W1@2BeM!iR]OohX$t,pA0QJqUh#tadn*,EP*&3lFA^N2,Z6(kL54!.J`XF4&fN:9Z9,+j4CHj<A&kn2p=rs@@@CEX,)VQ><M`#&kLUVK4mGb5B?(&"?PlFUrriQ>HD@hq:pB]qKfsB3R8UuCeA!?h^[](r:N-YH1H]lpA`Vf_jN\PbI/S/=[f<=.34r0iWOeY:Sa(3aDr$%agk2MHs(cP..f%uoW-6pjX,_=`DO[5cM^kjn;/OdWMN4/IeM]SYgruPJD3(h[eigT]HD1DaACI=-VFQmk2a+0XHG$):5e#Jh?#crmFPHF9Nn*d#[cpYD]'3(/e"bE=e"bWoa#.A;h+7.Q9VInes2h$R[\'#E3q/TGbJ:"SIdX7H`/jmHP%=E:FfXC96lHU.fmWndZTHUbYt*2glIaum:`g6'UXDb0Fc%Y]fT7HT,32"@7n\FMdoj>eU)q'<Aq+>[]fL`iMo+"!r'4,hE?:1CID4OCC*5:lU0@TgK<)9o?+g'C"q?Eqs61sb^S?BCVJ=kgY=?-Z:S$b:H-?U>`<X%q1i/kofW%nVrH;+I>eu`49Pu_7#@.<jBi<`GJ0!H4E[''T6Y7h"f'n*Y5uh7EB.hm0P7X4B:N+Z$l-FL46^H8!pg5&M?*Ei>UaaktQXU2i`EPT/&Y77d`+NCeSsZL_G\cDRY2d+-(X1k;$+E3p"15(.O[=oMBpiYocmD/#="Eq_!jd3>hKst'=nU)t!e!J0.4OVYcOeNE5-Jp=<AmcR>A<ff$J8<"ZKJrV.7e8jpu3ld+"688H+BS+"@n==iSlPF3P2HU]o#5AdT0Yab7'^rFL)A!:slc_YI,n0$dJAZk]uM/<u3B?o@n%+IWDXogStdZOVA@iH<`GuLM^/%QYAQY5bTlBf-bq]Wn1sZ8MA5^j(-])2N.^.D>J8qJ2.<EX"3sY,$jh&JP$1>8@Vu&\aR2>d?LsqO5GUs#(mGP;%OoYIOWXOI]Y/ZePba'.N]?9lq-lD:,Z?;6HW32*!O.$PMgSAFd`+m\km*_=c+crq;g"VeNGT76T,,OMlrW-1hDkN+Y5=SVCTATHTS81S7e_==W:Sn)qkmOb85TAVl'i<j$BX;R&APcWpf0m^AV.0,tj\_VH&WT]Krd;6W+t]C3=s.q]HsVD!td2#DYq.=6kS8\-?F#1S#B6/k>mNQQoZp7qQssW'q>Q6tfIo1L=N:@2+!Y@O8Kp\mo:ji)F..8t/PHkh,\h<@TA6o@4u7l]ciH!h-a;Gu?N\M:,L%9o\V=:(q(m'/8501FjPhAt)ce<)MKSY&d2Q_VUbhGs\I+$+`&\(%Q+A69Weh\^A`G"+9(bAKl(.M>l0b0D/mi"t[;kFt_'eFal-Z]"$,,=9Z6]_l<RbQ>1B$;U@IBp9Z[O<krQ$TID7@bBuZ[`t^'j8j@J`L=;_&ltW>c.h3tLMA/+&Ub7!&s"l#C5T3lZo&Y9?^SbL2TF#u\,+1l@qB``LMkJlcK6-D`[D-'mK"8m$Lh""(r59b2kFRnZfV]hU+hCDZf7/NMZiUFP9="rYK=aMKoTmlDE97DN*8OaRj=M+e+ZKWcWWu1PPQ([W_jO[~>endstream
endobj
30 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1034
>>
stream
Gatm9>u03/'RfGR\;tGEDOmXr]02Es0Vj:]g'MJ3DK6[Y&j+_UXT*b$%Ln,?<(JE8U=f2]hfJ4@3=P-1iLNsK-S+DQEo7P22S`f<_eKZg<kZ*dJ$^1)=;!t>7;S3M$gfVhNT(8kB+gCQj"]_2!iG/n)Xe0oS.TKNn5GYJ""#W!d4q/*?'GU6D;[5,A#X6#p*7dYjD_+n"kU8udJ=VNF;@8nO2aaa2IKDD+B>9hW=%?+LKle>)l'GSCfoM<(h'NOE#Jji>&@ft(sHE3FTI.oCT6)N=T*p'HT6>5mY8s"?d1m`)7R+e]00'Q)a/\L1.W:4V(-<Z:Jp7Ph-ti9i'Nf8FIqB3E=NAO$R;Y_<"N`R<>jjYCFW:Y32mX4,D@6R<@qaTq?C_PFoXFF##-XoqGj_GJ3$%4oX9OR-KBmkhlm*`0[HSJBl;l[Q?>^<L*]WB7g:H:7"KgY6PhSJQ8][G/pauKU6[&,,o)?hi3+1ulV_?L%pkSTq_P?F6;&Zapk*l<$(JS5l7_[obOp<CJ3g4TPG*[MD._iI]0>^5oJrb*FsK^Onu!joSUu97+r(k]7W=Xrh%KQi,7XJqh%0^TZB/j)8p@N)P7`<;/GKbKlPngL['&A`+_!XL74DL*hWHca).+MoqFT@JD(*SS)RU%4Yc>e_W@/hA!FeE^@D0R]kTpQhTGPr<no3\$PIGB!e(H9?FLmpW/pM`<S!&@k,'5L.!Q4P]eSHrD?+XV&\3>U^6OV,8QcI:goV*Y_3nQW9G-]OgM@srDZVk"rFC:OM%l:"VIEUIW8]nY3I\+MroRteD2X0q!p7`R3Z<aFl#t@C),>#,0W]>.^(=l\.m?D%T*E@4[SN#[cl\b$W#eCkAqG1g-1)c4K(g&iWPD%CD)5Q:0Y0M?0#S0'%EU6+57`;cf;3H7m_I!VmhW'<YH:>j0FioGQ.r.hGK/%c\El_V/g&ae"lWM'OI-[s<pg3h:!01Ke1S7t1h-U4Ll53)B/8NK'B^>b[U*30.rWKd)60^+M9p^;#N&pT["&jJ7,Q~>endstream
endobj
xref
0 31
0000000000 65535 f
0000000061 00000 n
0000000102 00000 n
0000000209 00000 n
0000000321 00000 n
0000000516 00000 n
0000000711 00000 n
0000000906 00000 n
0000001101 00000 n
0000001296 00000 n
0000001491 00000 n
0000001687 00000 n
0000001883 00000 n
0000002079 00000 n
0000002275 00000 n
0000002471 00000 n
0000002667 00000 n
0000002737 00000 n
0000003018 00000 n
0000003154 00000 n
0000004517 00000 n
0000005414 00000 n
0000009908 00000 n
0000014330 00000 n
0000017696 00000 n
0000020855 00000 n
0000022666 00000 n
0000026229 00000 n
0000029427 00000 n
0000032315 00000 n
0000036738 00000 n
trailer
<<
/ID
[<eb678512918bf07910151fb153fef42b><eb678512918bf07910151fb153fef42b>]
% ReportLab generated PDF document -- digest (opensource)
/Info 17 0 R
/Root 16 0 R
/Size 31
>>
startxref
37864
%%EOF
+101
View File
@@ -0,0 +1,101 @@
/**
* Stripe — Create Checkout Session
* --------------------------------
* Creates a Stripe-hosted Checkout Session and returns its URL so the frontend
* can redirect the customer to Stripe's secure payment page. We never see or
* store card details — that is the whole point of using hosted Checkout.
*
* Flow: Pay Now → POST here → Stripe Session → redirect to session.url →
* Stripe hosted page → success_url / cancel_url back to the app.
*
* Env (set in Vercel project settings, and .env locally for `vercel dev`):
* STRIPE_SECRET_KEY sk_live_… / sk_test_… (server only — never exposed)
*
* If STRIPE_SECRET_KEY is missing we return 503 with a clear message instead of
* crashing, so the module degrades gracefully until credentials are provided.
*/
// Server-side source of truth for invoice amounts. Prices are resolved HERE,
// never trusted from the client, so a tampered request can't change the amount.
// In production this would be a DB/CRM lookup; mirrors src/modules/payments/data/invoices.js.
const INVOICES = {
'INV-2041': { title: 'Roof Repair — Leak Detection & Shingle Replacement', amount: 184500, currency: 'usd' },
'INV-2039': { title: 'Full Roof Inspection & Storm Damage Report', amount: 32500, currency: 'usd' },
'INV-2012': { title: 'Gutter Cleaning & Maintenance Plan (Q1)', amount: 14900, currency: 'usd' },
};
export default async function handler(req, res) {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}
const secretKey = (process.env.STRIPE_SECRET_KEY || '').trim();
if (!secretKey) {
return res.status(503).json({
error: 'Stripe is not configured. Set STRIPE_SECRET_KEY to enable payments.',
code: 'stripe_not_configured',
});
}
try {
const { invoiceId, customerEmail, fallback } = req.body || {};
// The static map is authoritative for seed invoices (amount can't be
// tampered). DEMO ONLY: invoices the owner created live in the browser's
// localStorage, so the server doesn't know them — accept the client's
// amount/title as a fallback for those. In production every invoice would
// be resolved from the billing DB here and this fallback removed.
let invoice = INVOICES[invoiceId];
if (!invoice && fallback) {
const amount = Number(fallback.amount);
if (Number.isInteger(amount) && amount > 0) {
invoice = {
title: String(fallback.title || 'Payment').slice(0, 200),
amount,
currency: (fallback.currency || 'usd').toLowerCase(),
};
}
}
if (!invoice) {
return res.status(404).json({ error: `Unknown invoice: ${invoiceId}` });
}
// Lazy import keeps the module out of the cold-start path when unconfigured.
const Stripe = (await import('stripe')).default;
const stripe = new Stripe(secretKey, { apiVersion: '2024-06-20' });
// Where Stripe sends the customer back. Derive the app origin from the
// request so this works across local/preview/production without config.
const origin =
req.headers.origin ||
(req.headers.host ? `https://${req.headers.host}` : '');
const session = await stripe.checkout.sessions.create({
mode: 'payment',
payment_method_types: ['card'],
line_items: [
{
quantity: 1,
price_data: {
currency: invoice.currency,
unit_amount: invoice.amount, // integer, in cents
product_data: {
name: invoice.title,
metadata: { invoiceId },
},
},
},
],
...(customerEmail ? { customer_email: customerEmail } : {}),
// Echoed back on the session + every webhook event for reconciliation.
metadata: { invoiceId },
success_url: `${origin}/payments/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${origin}/payments/cancel?invoice=${encodeURIComponent(invoiceId)}`,
});
return res.status(200).json({ id: session.id, url: session.url });
} catch (err) {
console.error('[create-checkout-session] error:', err.message);
return res.status(500).json({ error: err.message || 'Failed to create checkout session' });
}
}
+55
View File
@@ -0,0 +1,55 @@
/**
* Stripe — Retrieve Checkout Session status
* -----------------------------------------
* The success return page calls this with the `session_id` Stripe appended to
* the success_url. We retrieve the session server-side (using the secret key)
* and return a trimmed, safe status object the UI can render and record.
*
* Returning here does NOT replace the webhook — the webhook is the authoritative
* record of truth (it fires even if the customer closes the tab). This endpoint
* just gives the returning customer immediate, verified confirmation.
*
* Env: STRIPE_SECRET_KEY
*/
export default async function handler(req, res) {
if (req.method !== 'GET') {
return res.status(405).json({ error: 'Method not allowed' });
}
const secretKey = (process.env.STRIPE_SECRET_KEY || '').trim();
if (!secretKey) {
return res.status(503).json({
error: 'Stripe is not configured.',
code: 'stripe_not_configured',
});
}
const sessionId = req.query?.session_id;
if (!sessionId) {
return res.status(400).json({ error: 'session_id is required' });
}
try {
const Stripe = (await import('stripe')).default;
const stripe = new Stripe(secretKey, { apiVersion: '2024-06-20' });
const session = await stripe.checkout.sessions.retrieve(sessionId);
return res.status(200).json({
id: session.id,
// status: open | complete | expired
status: session.status,
// payment_status: paid | unpaid | no_payment_required
paymentStatus: session.payment_status,
amountTotal: session.amount_total,
currency: session.currency,
customerEmail: session.customer_details?.email || null,
invoiceId: session.metadata?.invoiceId || null,
created: session.created ? session.created * 1000 : null,
});
} catch (err) {
console.error('[session-status] error:', err.message);
return res.status(500).json({ error: err.message || 'Failed to retrieve session' });
}
}
+27
View File
@@ -0,0 +1,27 @@
/**
* Stripe — Transaction history
* ----------------------------
* Returns the payment records written by the webhook (the server-side source of
* truth). Reads from Vercel KV when connected; returns an empty list otherwise
* so the frontend degrades gracefully and falls back to its local record.
*
* GET /api/payments/transactions -> { transactions: [...] }
*/
export default async function handler(req, res) {
if (req.method !== 'GET') {
return res.status(405).json({ error: 'Method not allowed' });
}
try {
const { kv } = await import('@vercel/kv');
const raw = await kv.lrange('payments:transactions', 0, 199);
const transactions = (raw || []).map((item) =>
typeof item === 'string' ? JSON.parse(item) : item
);
return res.status(200).json({ transactions });
} catch {
// KV not configured — the frontend will use its local record instead.
return res.status(200).json({ transactions: [] });
}
}
+125
View File
@@ -0,0 +1,125 @@
/**
* Stripe — Webhook receiver
* -------------------------
* The authoritative record of truth for payments. Stripe POSTs events here;
* we verify the signature, then react to payment lifecycle events. This fires
* server-to-server even if the customer closes their browser, so the
* transaction record must be written here (not only on the success page).
*
* Configure in the Stripe Dashboard → Developers → Webhooks:
* Endpoint URL: https://<your-app>.vercel.app/api/payments/webhook
* Events: checkout.session.completed,
* checkout.session.async_payment_succeeded,
* checkout.session.async_payment_failed,
* checkout.session.expired
*
* Env:
* STRIPE_SECRET_KEY sk_… (to construct the Stripe client)
* STRIPE_WEBHOOK_SECRET whsec_… (to verify the signature)
*
* Local testing: `stripe listen --forward-to localhost:5173/api/payments/webhook`
*
* Persistence uses Vercel KV when connected (same optional pattern as
* api/hail-webhook.js); without KV it logs the event and ack's 200.
*/
// Stripe requires the *raw* request body to verify the signature, so the
// default JSON body parser must be disabled for this route.
export const config = { api: { bodyParser: false } };
async function readRawBody(req) {
const chunks = [];
for await (const chunk of req) {
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
}
return Buffer.concat(chunks);
}
/** Persist a payment record (best-effort; no-op without Vercel KV). */
async function recordPayment(record) {
try {
const { kv } = await import('@vercel/kv');
// Keep a capped, newest-first list of transactions.
await kv.lpush('payments:transactions', JSON.stringify(record));
await kv.ltrim('payments:transactions', 0, 199);
if (record.invoiceId && record.status === 'paid') {
await kv.set(`payments:invoice:${record.invoiceId}`, 'paid');
}
console.log('[payments/webhook] recorded in KV:', record.id);
} catch {
console.log('[payments/webhook] KV unavailable — event received but not persisted:', record);
}
}
export default async function handler(req, res) {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}
const secretKey = (process.env.STRIPE_SECRET_KEY || '').trim();
const webhookSecret = (process.env.STRIPE_WEBHOOK_SECRET || '').trim();
if (!secretKey || !webhookSecret) {
return res.status(503).json({ error: 'Stripe webhook is not configured.' });
}
let event;
try {
const Stripe = (await import('stripe')).default;
const stripe = new Stripe(secretKey, { apiVersion: '2024-06-20' });
const rawBody = await readRawBody(req);
const signature = req.headers['stripe-signature'];
event = stripe.webhooks.constructEvent(rawBody, signature, webhookSecret);
} catch (err) {
// Signature verification failed — reject (could be a forged request).
console.error('[payments/webhook] signature verification failed:', err.message);
return res.status(400).send(`Webhook Error: ${err.message}`);
}
try {
switch (event.type) {
case 'checkout.session.completed':
case 'checkout.session.async_payment_succeeded': {
const s = event.data.object;
await recordPayment({
id: s.id,
invoiceId: s.metadata?.invoiceId || null,
amount: s.amount_total,
currency: s.currency,
customerEmail: s.customer_details?.email || null,
status: s.payment_status === 'paid' ? 'paid' : s.payment_status,
eventType: event.type,
createdAt: new Date().toISOString(),
});
break;
}
case 'checkout.session.async_payment_failed': {
const s = event.data.object;
await recordPayment({
id: s.id,
invoiceId: s.metadata?.invoiceId || null,
amount: s.amount_total,
currency: s.currency,
status: 'failed',
eventType: event.type,
createdAt: new Date().toISOString(),
});
break;
}
case 'checkout.session.expired': {
const s = event.data.object;
console.log('[payments/webhook] checkout session expired/cancelled:', s.id);
break;
}
default:
// Unhandled event types are acknowledged so Stripe stops retrying.
console.log('[payments/webhook] unhandled event:', event.type);
}
} catch (err) {
console.error('[payments/webhook] handler error:', err.message);
// Still 200 below if we got a valid event — returning 500 makes Stripe
// retry, which is only desirable for transient persistence failures.
}
return res.status(200).json({ received: true });
}
+7
View File
@@ -26,4 +26,11 @@ export default defineConfig([
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
},
},
{
// Serverless functions and build config run on Node, not the browser.
files: ['api/**/*.js', '**/*.config.js'],
languageOptions: {
globals: { ...globals.node },
},
},
])
+6918
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -16,6 +16,7 @@
"@dnd-kit/utilities": "^3.2.2",
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.5.0",
"@stripe/stripe-js": "^9.7.0",
"@vercel/analytics": "^1.6.1",
"autoprefixer": "^10.4.23",
"class-variance-authority": "^0.7.1",
@@ -36,6 +37,7 @@
"react-router-dom": "^7.13.0",
"recharts": "^3.7.0",
"sonner": "^2.0.7",
"stripe": "^22.2.0",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^3.4.1",
"three": "^0.182.0"
-4567
View File
File diff suppressed because it is too large Load Diff
+67 -1
View File
@@ -8,7 +8,9 @@ import Dashboard from './pages/Dashboard';
import Maps from './pages/Maps';
import AdminSchedule from './pages/AdminSchedule';
import CustomerProfile from './pages/CustomerProfile';
import CustomerDashboard from './pages/CustomerDashboard';
import LeaderboardPage from './pages/LeaderboardPage';
import { PaymentProvider, PaymentsPage, PaymentSuccess, PaymentCancelled, DemoCheckout } from './modules/payments';
import ErrorBoundary from './components/ErrorBoundary';
import SmoothScroll from './components/SmoothScroll';
import { Toaster } from 'sonner';
@@ -31,6 +33,7 @@ import VendorDashboard from './pages/vendor/VendorDashboard';
import VendorOrders from './pages/vendor/VendorOrders';
import ContractorDashboard from './pages/contractor/ContractorDashboard';
import SubContractorDashboard from './pages/subcontractor/SubContractorDashboard';
import PaymentManagement from './pages/owner/PaymentManagement';
import SubcontractorTaskDetailPage from './pages/subcontractor/SubcontractorTaskDetailPage';
import SubcontractorProjectsPage from './pages/subcontractor/SubcontractorProjectsPage';
import SubcontractorProjectDetailPage from './pages/subcontractor/SubcontractorProjectDetailPage';
@@ -45,6 +48,11 @@ import StormIntelPage from './pages/StormIntelPage';
import FieldStormZonePage from './pages/FieldStormZonePage';
import UserDetailsPage from './pages/UserDetailsPage';
import LeadVerificationPage from './pages/LeadVerification/LeadVerificationPage';
import CustomerEstimateWizard from './modules/estimateWizard/CustomerEstimateWizard';
import EstimateWizardAdmin from './modules/estimateWizard/EstimateWizardAdmin';
import SocialAdsEngine from './modules/socialAdsEngine/SocialAdsEngine';
import PublicAdLanding from './modules/socialAdsEngine/PublicAdLanding';
import CampaignXComingSoon from './pages/CampaignXComingSoon';
// ... (existing imports)
const ProtectedRoute = ({ children, allowedRoles }) => {
@@ -74,6 +82,27 @@ function App() {
{/* Public Routes */}
<Route path="/" element={<Landing />} />
<Route path="/login" element={<Login />} />
{/* Public DIY Roofing Estimate Wizard — homeowner-facing, no auth, chrome-less */}
<Route path="/estimate" element={<CustomerEstimateWizard />} />
{/* Public ad lead-capture landing page — homeowner-facing, no auth, chrome-less */}
<Route path="/ads/lp" element={<PublicAdLanding />} />
<Route path="/estimate-wizard/admin" element={
<ProtectedRoute allowedRoles={['OWNER', 'ADMIN']}>
<EstimateWizardAdmin />
</ProtectedRoute>
} />
{/* Social Media + Advertisement Engine — self-contained admin module (Owner/Admin) */}
<Route path="/marketing" element={
<ProtectedRoute allowedRoles={['OWNER', 'ADMIN']}>
<SocialAdsEngine />
</ProtectedRoute>
} />
{/* CampaignX — placeholder teaser screen for the upcoming marketing module (Owner/Admin) */}
<Route path="/campaignx" element={
<ProtectedRoute allowedRoles={['OWNER', 'ADMIN']}>
<CampaignXComingSoon />
</ProtectedRoute>
} />
<Route
path="/chat-assistant"
element={
@@ -83,13 +112,34 @@ function App() {
}
/>
{/* Protected Field Agent Routes */}
{/* Customer Portal Routes */}
<Route path="/portal/dashboard" element={
<ProtectedRoute allowedRoles={['CUSTOMER']}>
<CustomerDashboard />
</ProtectedRoute>
} />
<Route path="/portal/profile" element={
<ProtectedRoute allowedRoles={['CUSTOMER']}>
<CustomerProfile />
</ProtectedRoute>
} />
{/* Payments Module (independent) — Stripe hosted checkout */}
<Route path="/portal/payments" element={
<ProtectedRoute allowedRoles={['CUSTOMER']}>
<PaymentProvider>
<PaymentsPage />
</PaymentProvider>
</ProtectedRoute>
} />
{/* Payment Management (owner/admin side of the Payments module) */}
<Route path="/owner/payments" element={
<ProtectedRoute allowedRoles={['OWNER', 'ADMIN']}>
<PaymentManagement />
</ProtectedRoute>
} />
{/* Self-service profile for internal roles */}
<Route path="/profile" element={
<ProtectedRoute allowedRoles={['OWNER', 'ADMIN', 'FIELD_AGENT', 'CONTRACTOR', 'SUBCONTRACTOR']}>
@@ -432,6 +482,22 @@ function App() {
} />
</Route>
{/* Stripe hosted-checkout return pages (public, full-screen).
Public on purpose: the round-trip to Stripe drops the in-memory
auth session, so these must render without it. */}
<Route path="/payments/success" element={
<PaymentProvider>
<PaymentSuccess />
</PaymentProvider>
} />
<Route path="/payments/cancel" element={
<PaymentProvider>
<PaymentCancelled />
</PaymentProvider>
} />
{/* Simulated hosted checkout — only used in demo mode (no Stripe keys). */}
<Route path="/payments/demo-checkout" element={<DemoCheckout />} />
{/* Catch all */}
<Route path="*" element={<NotFound />} />
</Routes>
+36 -5
View File
@@ -6,7 +6,7 @@ import {
LayoutDashboard, Map, Calendar, LogOut, User, Home, MessageSquare,
ChevronLeft, ChevronRight, Sun, Moon, Trophy, Users, Briefcase,
FileText, Menu, X, Calculator, PlusCircle, ClipboardList, Zap, LayoutGrid, Settings, CloudLightning,
HardHat, ShieldCheck
HardHat, ShieldCheck, Sparkles, Megaphone, Rocket, CreditCard
} from 'lucide-react';
import PageTransition from './PageTransition';
@@ -128,6 +128,7 @@ const Layout = () => {
const commonItems = [
{ to: "/chat-assistant", icon: MessageSquare, label: "AI Assistant" },
{ to: "/estimate", icon: Sparkles, label: "Estimate Wizard" },
];
const homeItem = { to: "/", icon: Home, label: "Home" };
@@ -156,6 +157,11 @@ const Layout = () => {
{ to: "/owner/people", icon: Users, label: "People" },
{ to: "/owner/settings", icon: Settings, label: "Org Settings" },
...commonItems,
{ to: "/estimate-wizard/admin", icon: Settings, label: "Wizard Admin" },
{ to: "/marketing", icon: Megaphone, label: "Ad Engine" },
{ to: "/owner/payments", icon: CreditCard, label: "Manage Payments" },
{ type: "section", label: "Upcoming Features" },
{ to: "/campaignx", icon: Rocket, label: <span>Campaign<span className="text-amber-500">X</span></span> },
];
case 'ADMIN':
return [
@@ -177,6 +183,11 @@ const Layout = () => {
{ to: "/admin/subcontractor-tasks", icon: HardHat, label: "Subcontractor Tasks" },
{ to: "/admin/settings", icon: Settings, label: "Org Settings" },
...commonItems,
{ to: "/estimate-wizard/admin", icon: Settings, label: "Wizard Admin" },
{ to: "/marketing", icon: Megaphone, label: "Ad Engine" },
{ to: "/owner/payments", icon: CreditCard, label: "Manage Payments" },
{ type: "section", label: "Upcoming Features" },
{ to: "/campaignx", icon: Rocket, label: <span>Campaign<span className="text-amber-500">X</span></span> },
];
case 'CONTRACTOR':
case 'SUBCONTRACTOR':
@@ -209,7 +220,9 @@ const Layout = () => {
];
default: // Customer or Fallback
return [
{ to: "/customer/profile", icon: LayoutDashboard, label: "Dashboard" },
{ to: "/portal/dashboard", icon: LayoutDashboard, label: "Dashboard" },
{ to: "/portal/profile", icon: User, label: "My Profile" },
{ to: "/portal/payments", icon: CreditCard, label: "Payments" },
...commonItems,
];
}
@@ -282,7 +295,22 @@ const Layout = () => {
{/* 2. Navigation Items */}
<nav className="flex-1 px-3 py-6 space-y-1 overflow-y-auto custom-scrollbar" role="navigation">
{navItems.map((item) => (
{navItems.map((item, idx) => {
// Section header — a divider line followed by a small uppercase label.
if (item.type === 'section') {
const collapsed = isCollapsed && !isMobileMenuOpen;
return (
<div key={`section-${idx}`} className="pt-4 pb-1">
<div className="h-px bg-zinc-200 dark:bg-white/10 mb-2" />
{!collapsed && (
<span className="px-3 text-[10px] font-bold uppercase tracking-widest text-zinc-400 dark:text-zinc-500">
{item.label}
</span>
)}
</div>
);
}
return (
<SidebarItem
key={item.to}
to={item.to}
@@ -291,7 +319,8 @@ const Layout = () => {
isCollapsed={isCollapsed && !isMobileMenuOpen}
onClick={() => setIsMobileMenuOpen(false)}
/>
))}
);
})}
</nav>
{/* 3. User Profile & Footer */}
@@ -355,7 +384,9 @@ const Layout = () => {
</PageTransition>
</main>
<Chatbot />
{/* Hide the floating chat on full-page flows that have their own bottom CTAs
(estimate wizard, ad preview form, Ad Engine) so it doesn't overlap their buttons. */}
{!(location.pathname === '/estimate' || location.pathname === '/marketing' || location.pathname.startsWith('/ads/')) && <Chatbot />}
</div>
);
};
+6 -6
View File
@@ -4,6 +4,9 @@ import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { useTheme } from '../../context/ThemeContext';
import { DISPATCH_REPS, DISPATCH_RECOMMENDATIONS } from '../../data/mockStore';
// Shared color taxonomy — same source of truth the Territory Map reads from,
// so pin colors stay consistent across the app.
import { REP_STATUS_COLORS, REP_STATUS_LABELS, URGENCY_COLORS } from '../../data/statusTaxonomy';
// ---------------------------------------------------------------------------
// Constants
@@ -11,10 +14,6 @@ import { DISPATCH_REPS, DISPATCH_RECOMMENDATIONS } from '../../data/mockStore';
const PLANO_CENTER = [33.055, -96.752];
const DEFAULT_ZOOM = 12;
const REP_STATUS_COLORS = { available: '#10B981', en_route: '#3B82F6', busy: '#F59E0B' };
const REP_STATUS_LABELS = { available: 'Available', en_route: 'En Route', busy: 'Busy' };
const URGENCY_COLORS = { emergency: '#EF4444', high: '#F59E0B', standard: '#6B7280' };
// Storm polygon — northeast Plano, sweeping toward central
const STORM_POLYGON = [
[33.105, -96.745], [33.100, -96.695], [33.078, -96.682],
@@ -360,8 +359,9 @@ const DispatchMapPanel = ({ selectedLead, dispatchLeads, reps = DISPATCH_REPS, s
)}
</MapContainer>
{/* ── Legend overlay — bottom-left ── */}
<div className="absolute bottom-3 left-3 z-[1000] flex flex-col gap-1.5 pointer-events-none">
{/* ── Legend overlay — top-left (kept in view; the desktop map is
~1190px tall, so a bottom-anchored legend sat far below the fold) ── */}
<div className="absolute top-3 left-3 z-[1000] flex flex-col gap-1.5 pointer-events-none">
{/* Rep status legend */}
<div className="rounded-xl border bg-white/90 dark:bg-zinc-900/85 backdrop-blur-md border-zinc-200/80 dark:border-white/[0.08] px-2.5 py-2 shadow-lg">
<p className="text-[9px] font-black uppercase tracking-widest text-zinc-400 dark:text-zinc-500 mb-1.5">Reps</p>
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback } from 'react';
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Bot, CheckCircle, X, Star, Clock,
@@ -574,6 +574,87 @@ const RepPickerModal = ({ onClose, onSelect, accent, reps }) => (
</div>
);
// ---------------------------------------------------------------------------
// Proactive (ambient-AI) default — shown when no lead is selected so the panel
// is useful on load: surfaces the highest-priority unassigned lead and its
// best-fit rep with a one-tap dispatch.
// ---------------------------------------------------------------------------
const URGENCY_RANK = { emergency: 0, high: 1, standard: 2 };
const ProactiveSuggestion = ({ suggestion, stormMode, accent, onDispatch, onReview, onViewDetails }) => {
const { lead, rec, rep } = suggestion;
const repColor = REP_STATUS_COLORS[rep.status] || '#6B7280';
const scoreCol = scoreColor(rec.score);
const topReasons = rec.reasons.slice(0, 3);
return (
<motion.div
key="proactive"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="p-3 space-y-3"
>
{/* Ambient-AI banner */}
<div className="flex items-center gap-1.5 px-2 py-1 rounded-full w-fit" style={{ backgroundColor: `${accent}14`, color: accent }}>
<Sparkles size={11} className="shrink-0" />
<span className="text-[9px] font-black uppercase tracking-widest">Ambient AI · Suggested Dispatch</span>
</div>
<p className="text-[11px] text-zinc-500 dark:text-zinc-400 leading-snug -mt-1">
Your highest-priority unassigned lead with a ready best-fit rep. Dispatch in one tap, or review the full slate.
</p>
<LeadSummary lead={lead} stormMode={stormMode} onViewDetails={onViewDetails} />
{/* Best-fit rep */}
<div className="rounded-xl border px-3 py-2.5 space-y-2" style={{ backgroundColor: `${accent}0D`, borderColor: `${accent}30` }}>
<div className="flex items-center gap-2">
<RepAvatar initials={rep.initials} status={rep.status} />
<div className="flex-1 min-w-0">
<p className="text-[12px] font-bold text-zinc-900 dark:text-white leading-tight truncate">{rep.name}</p>
<p className="text-[10px] truncate" style={{ color: repColor }}>
{REP_STATUS_LABELS[rep.status]} · {rec.distanceMi} mi · {rep.closeRate}% close
</p>
</div>
<div className="text-right shrink-0">
<p className="text-[9px] font-bold uppercase tracking-widest text-zinc-400 leading-none mb-0.5">Score</p>
<p className="text-[1.4rem] font-black font-mono leading-none" style={{ color: scoreCol }}>{rec.score}</p>
</div>
</div>
{/* Top reasons */}
<div className="flex flex-wrap gap-1 pt-2 border-t" style={{ borderColor: `${accent}25` }}>
{topReasons.map((r, i) => (
<span key={i} className="inline-flex items-center text-[10px] font-medium px-2 py-0.5 rounded-full bg-white/70 dark:bg-zinc-800/70 text-zinc-600 dark:text-zinc-300">
{r}
</span>
))}
</div>
</div>
{/* Actions */}
<div className="flex gap-2">
<motion.button
whileHover={{ opacity: 0.9 }}
whileTap={{ scale: 0.97 }}
onClick={() => onDispatch(rep, false, lead)}
className="flex-1 flex items-center justify-center gap-1.5 py-2 rounded-xl text-xs font-bold uppercase tracking-wider text-white transition-all duration-200 shadow-sm"
style={{ backgroundColor: accent }}
>
<Zap size={12} />
Dispatch {rep.name.split(' ')[0]}
</motion.button>
<button
onClick={() => onReview(lead)}
className="px-3 py-2 rounded-xl text-xs font-bold uppercase tracking-wider text-zinc-500 dark:text-zinc-400 border border-zinc-200 dark:border-white/[0.1] hover:border-zinc-300 dark:hover:border-white/[0.18] hover:text-zinc-700 dark:hover:text-zinc-200 transition-all duration-200"
>
Review
</button>
</div>
</motion.div>
);
};
// ---------------------------------------------------------------------------
// Main Drawer
// ---------------------------------------------------------------------------
@@ -581,9 +662,11 @@ const DispatchRecommendationDrawer = ({
selectedLead,
isProcessing,
reps = DISPATCH_REPS,
leads = [],
stormMode,
accent,
onAssign,
onSelectLead,
onDismissAfterAssign,
onViewDetails,
isDesktop,
@@ -593,27 +676,43 @@ const DispatchRecommendationDrawer = ({
const recommendations = selectedLead ? (DISPATCH_RECOMMENDATIONS[selectedLead.id] ?? []) : [];
// Ambient-AI default — when nothing is selected, surface the top unassigned
// lead (by urgency, then longest-waiting) and its best-fit rep.
const suggestion = useMemo(() => {
if (selectedLead) return null;
const lead = (leads || [])
.filter(l => l.status === 'unassigned' && (DISPATCH_RECOMMENDATIONS[l.id]?.length))
.sort((a, b) =>
(URGENCY_RANK[a.urgency] ?? 9) - (URGENCY_RANK[b.urgency] ?? 9)
|| (b.arrivedMinsAgo || 0) - (a.arrivedMinsAgo || 0)
)[0];
if (!lead) return null;
const rec = DISPATCH_RECOMMENDATIONS[lead.id][0];
const rep = reps.find(r => r.id === rec.repId);
return rep ? { lead, rec, rep } : null;
}, [selectedLead, leads, reps]);
// Clear dispatched overlay when selectedLead changes
useEffect(() => {
setDispatchedInfo(null);
}, [selectedLead?.id]);
const handleAssign = useCallback((rep, isManual = false) => {
if (!selectedLead) return;
const handleAssign = useCallback((rep, isManual = false, lead = selectedLead) => {
if (!lead) return;
const recs = DISPATCH_RECOMMENDATIONS[selectedLead.id] ?? [];
const recs = DISPATCH_RECOMMENDATIONS[lead.id] ?? [];
const repRec = recs.find(r => r.repId === rep.id);
onAssign(selectedLead.id, rep.id, isManual);
onAssign(lead.id, rep.id, isManual);
toast.success(`${rep.name.split(' ')[0]} dispatched`, {
description: `${selectedLead.property.address} · ${repRec?.slotStart ?? 'TBD'}`,
description: `${lead.property.address} · ${repRec?.slotStart ?? 'TBD'}`,
duration: 4000,
});
setDispatchedInfo({
repFirstName: rep.name.split(' ')[0],
address: `${selectedLead.property.address}, ${selectedLead.property.city}`,
address: `${lead.property.address}, ${lead.property.city}`,
slot: repRec?.slotStart ?? null,
});
@@ -780,6 +879,18 @@ const DispatchRecommendationDrawer = ({
</button>
</motion.div>
/* ── PROACTIVE DEFAULT (ambient AI) ── */
) : suggestion ? (
<ProactiveSuggestion
key="proactive"
suggestion={suggestion}
stormMode={stormMode}
accent={accent}
onDispatch={handleAssign}
onReview={onSelectLead}
onViewDetails={onViewDetails}
/>
/* ── EMPTY STATE ── */
) : (
<motion.div
+17 -3
View File
@@ -1,7 +1,7 @@
import React from 'react';
import { useDraggable } from '@dnd-kit/core';
import { CSS } from '@dnd-kit/utilities';
import { MapPin, User, Clock, CloudLightning } from 'lucide-react';
import { MapPin, User, Clock, CloudLightning, GripVertical } from 'lucide-react';
import { DEMO_TODAY } from '../../data/mockStore';
function getInitials(name) {
@@ -54,9 +54,23 @@ export function KanbanCardDisplay({ lead, columnColor, isOverlay = false, onClic
style={{ backgroundColor: columnColor }}
/>
{/* Drag handle affordance — signals the card is draggable to advance stages.
Subtle by default, strengthens on hover/drag. Whole card stays draggable. */}
<div
aria-hidden="true"
title="Drag to move stage"
className={`absolute top-2 right-1.5 pointer-events-none transition-colors ${
isOverlay
? 'text-blue-400 dark:text-blue-500'
: 'text-zinc-300 dark:text-zinc-600 group-hover:text-zinc-500 dark:group-hover:text-zinc-400'
}`}
>
<GripVertical size={14} />
</div>
<div className="pl-4 pr-3 py-3">
{/* Name row */}
<div className="flex items-start gap-2 mb-2">
{/* Name row (right padding reserves space for the drag handle) */}
<div className="flex items-start gap-2 mb-2 pr-5">
<div className={`w-7 h-7 rounded-full flex items-center justify-center text-[11px] font-bold shrink-0 mt-0.5 ${avatarCls}`}>
{initials}
</div>
+6 -3
View File
@@ -1,5 +1,6 @@
import React, { useState, useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import { X, Home, DollarSign, User, Edit2, Save, Info, AlertCircle, ShieldCheck, Loader2, ChevronLeft, ChevronRight, Trash2 } from 'lucide-react';
import { SpotlightCard } from '../SpotlightCard';
@@ -309,7 +310,7 @@ const PropertyDetailDrawer = ({ isOpen, onClose, data, newLocation, onSave, load
);
};
return (
return createPortal(
<div
className={`fixed z-[1000] flex flex-col transition-transform duration-300 ease-out inset-0 md:inset-auto md:top-4 md:right-4 md:bottom-4 md:w-[500px] ${isOpen ? 'translate-x-0' : 'translate-x-full md:translate-x-[calc(100%+2rem)]'}`}
role="dialog"
@@ -325,7 +326,8 @@ const PropertyDetailDrawer = ({ isOpen, onClose, data, newLocation, onSave, load
<button
ref={closeButtonRef}
onClick={onClose}
className="absolute top-4 right-4 p-2 bg-white/50 dark:bg-white/10 hover:bg-white dark:hover:bg-white/20 rounded-full transition-all backdrop-blur-md text-zinc-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-zinc-500 z-50 cursor-pointer shadow-sm border border-black/5 dark:border-white/5"
style={{ top: 'max(1rem, env(safe-area-inset-top))' }}
className="absolute right-4 p-2 bg-white/50 dark:bg-white/10 hover:bg-white dark:hover:bg-white/20 rounded-full transition-all backdrop-blur-md text-zinc-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-zinc-500 z-50 cursor-pointer shadow-sm border border-black/5 dark:border-white/5"
aria-label="Close property details"
>
<X size={18} />
@@ -710,7 +712,8 @@ const PropertyDetailDrawer = ({ isOpen, onClose, data, newLocation, onSave, load
</div>
)}
</div>
</div>,
document.body
);
};
@@ -390,7 +390,9 @@ const CommissionDistributionPanel = ({
<p className="text-xs text-zinc-400 mt-1">Add team members with commission rules to see the distribution.</p>
</div>
) : (
<div className="overflow-x-auto rounded-xl border border-zinc-200 dark:border-white/5">
<>
{/* ── Desktop table (sm+) ── */}
<div className="hidden sm:block overflow-x-auto rounded-xl border border-zinc-200 dark:border-white/5">
<table className="w-full text-left min-w-[600px]">
<thead>
<tr className="border-b border-zinc-200 dark:border-white/5 bg-zinc-50 dark:bg-white/[0.02]">
@@ -465,6 +467,65 @@ const CommissionDistributionPanel = ({
</tfoot>
</table>
</div>
{/* ── Mobile cards (< sm) ── */}
<div className="sm:hidden space-y-2.5">
{filteredRows.length === 0 ? (
<div className="rounded-xl border border-zinc-200 dark:border-white/5 py-10 text-center">
<Users size={22} className="mx-auto mb-2 text-zinc-400" />
<p className="text-sm text-zinc-500">No commission entries match the current filters.</p>
<button
type="button"
onClick={clearFilters}
className="mt-3 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[11px] font-bold uppercase tracking-wider text-emerald-600 dark:text-emerald-400 hover:bg-emerald-50 dark:hover:bg-emerald-500/10 border border-emerald-200 dark:border-emerald-500/20 transition-colors"
>
<FilterX size={12} /> Clear Filters
</button>
</div>
) : (
<>
{filteredRows.map((row) => (
<div key={row.key} className="rounded-xl border border-zinc-200 dark:border-white/5 bg-white dark:bg-white/[0.02] p-3.5">
{/* Name + Amount */}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2.5 min-w-0">
<div className="w-7 h-7 rounded-full bg-emerald-500/15 flex items-center justify-center text-[10px] font-black text-emerald-600 dark:text-emerald-400 flex-shrink-0">
{row.name?.[0] || '?'}
</div>
<span className="text-sm font-semibold text-zinc-900 dark:text-white truncate">{row.name}</span>
</div>
<span className="font-mono text-sm font-bold text-green-600 dark:text-[#39ff14] shrink-0">{fmt(row.amount)}</span>
</div>
{/* Role */}
<div className="mt-2.5">
<span className={`text-[11px] font-bold rounded-md px-1.5 py-0.5 border ${ROLE_COLORS[row.role] || 'text-zinc-400 bg-zinc-500/10 border-zinc-500/20'}`}>
{row.role}
</span>
</div>
{/* Project / Type */}
<div className="grid grid-cols-2 gap-2 mt-3 pt-3 border-t border-zinc-100 dark:border-white/5">
<div className="flex flex-col gap-0.5 min-w-0">
<span className="text-[9px] font-mono font-bold text-zinc-400 dark:text-zinc-500 uppercase tracking-widest">Project ID</span>
<span className="text-[11px] font-mono text-zinc-500 dark:text-zinc-400 truncate">{row.projectId}</span>
</div>
<div className="flex flex-col gap-0.5 min-w-0">
<span className="text-[9px] font-mono font-bold text-zinc-400 dark:text-zinc-500 uppercase tracking-widest">Commission Type</span>
<span className="text-[11px] text-zinc-600 dark:text-zinc-400 truncate">{row.commissionType}</span>
</div>
</div>
</div>
))}
{/* Total */}
<div className="rounded-xl border border-zinc-300 dark:border-white/10 bg-zinc-50 dark:bg-white/[0.03] px-3.5 py-3 flex items-center justify-between">
<span className="text-[10px] font-mono font-bold text-zinc-500 uppercase tracking-widest">
{hasActiveFilters ? 'Filtered Total' : 'Total'}
</span>
<span className="font-mono text-sm font-extrabold text-green-600 dark:text-[#39ff14]">{fmt(total)}</span>
</div>
</>
)}
</div>
</>
)}
</div>
);
+64 -7
View File
@@ -393,8 +393,9 @@ const TeamManagementPanel = ({
transition={{ duration: 0.25 }}
className="overflow-hidden"
>
<div className="overflow-x-auto rounded-xl border border-zinc-200 dark:border-white/5">
<table className="w-full text-left">
{/* ── Desktop table (sm+) ── */}
<div className="hidden sm:block overflow-x-auto rounded-xl border border-zinc-200 dark:border-white/5">
<table className="w-full text-left min-w-[560px]">
<thead>
<tr className="border-b border-zinc-200 dark:border-white/5 bg-zinc-50 dark:bg-white/[0.02]">
<th className="px-4 py-2.5 text-[10px] font-mono font-bold text-zinc-500 uppercase tracking-widest">Member</th>
@@ -419,17 +420,17 @@ const TeamManagementPanel = ({
</div>
</td>
<td className="px-4 py-3">
<span className={`text-[11px] font-bold rounded-md px-1.5 py-0.5 border ${ROLE_COLORS[mc.jobRole] || 'text-zinc-400 bg-zinc-500/10 border-zinc-500/20'}`}>
<span className={`text-[11px] font-bold rounded-md px-1.5 py-0.5 border whitespace-nowrap ${ROLE_COLORS[mc.jobRole] || 'text-zinc-400 bg-zinc-500/10 border-zinc-500/20'}`}>
{mc.jobRole}
</span>
</td>
<td className="px-4 py-3 text-xs text-zinc-600 dark:text-zinc-400">{COMMISSION_TYPES[mc.resolved.type] || mc.resolved.type}</td>
<td className="px-4 py-3 text-right font-mono text-xs text-zinc-900 dark:text-white">
<td className="px-4 py-3 text-xs text-zinc-600 dark:text-zinc-400 whitespace-nowrap">{COMMISSION_TYPES[mc.resolved.type] || mc.resolved.type}</td>
<td className="px-4 py-3 text-right font-mono text-xs text-zinc-900 dark:text-white whitespace-nowrap">
{mc.resolved.type === 'flat' || mc.resolved.type === 'custom'
? formatCurrency(mc.resolved.rate)
: `${mc.resolved.rate}%`}
</td>
<td className="px-4 py-3 text-right font-mono text-xs font-bold text-green-600 dark:text-[#39ff14]">
<td className="px-4 py-3 text-right font-mono text-xs font-bold text-green-600 dark:text-[#39ff14] whitespace-nowrap">
{formatCurrency(mc.value)}
</td>
<td className="px-4 py-3 text-center">
@@ -445,7 +446,7 @@ const TeamManagementPanel = ({
<tfoot className="border-t-2 border-zinc-300 dark:border-white/10">
<tr className="bg-zinc-50 dark:bg-white/[0.03]">
<td colSpan={4} className="px-4 py-3 text-[10px] font-mono font-bold text-zinc-500 uppercase tracking-widest">Total Distribution</td>
<td className="px-4 py-3 text-right font-mono text-xs font-bold text-green-600 dark:text-[#39ff14]">
<td className="px-4 py-3 text-right font-mono text-xs font-bold text-green-600 dark:text-[#39ff14] whitespace-nowrap">
{formatCurrency(memberCommissions.filter(m => m.status === 'active').reduce((s, mc) => s + mc.value, 0))}
</td>
<td />
@@ -453,6 +454,62 @@ const TeamManagementPanel = ({
</tfoot>
</table>
</div>
{/* ── Mobile cards (< sm) ── */}
<div className="sm:hidden space-y-2.5">
{memberCommissions.filter(m => m.status === 'active').map((mc) => {
const srcCfg = COMMISSION_SOURCE_CFG[mc.resolved.source] || COMMISSION_SOURCE_CFG.org;
return (
<div key={mc.id} className="rounded-xl border border-zinc-200 dark:border-white/5 bg-white dark:bg-white/[0.02] p-3.5">
{/* Member + source */}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<div className="w-7 h-7 rounded-full bg-violet-500/15 flex items-center justify-center text-[11px] font-black text-violet-400 flex-shrink-0">
{mc.name?.[0]}
</div>
<span className="text-sm font-semibold text-zinc-900 dark:text-white truncate">{mc.name}</span>
</div>
<span className={`inline-flex items-center gap-1 text-[10px] font-bold rounded-full px-2 py-0.5 border shrink-0 ${srcCfg.bg}`}>
<span className={`w-1.5 h-1.5 rounded-full ${srcCfg.dot}`} />
<span className={srcCfg.color}>{srcCfg.label}</span>
</span>
</div>
{/* Role */}
<div className="mt-2.5">
<span className={`text-[11px] font-bold rounded-md px-1.5 py-0.5 border ${ROLE_COLORS[mc.jobRole] || 'text-zinc-400 bg-zinc-500/10 border-zinc-500/20'}`}>
{mc.jobRole}
</span>
</div>
{/* Type / Rate / Value */}
<div className="grid grid-cols-3 gap-2 mt-3 pt-3 border-t border-zinc-100 dark:border-white/5">
<div className="flex flex-col gap-0.5">
<span className="text-[9px] font-mono font-bold text-zinc-400 dark:text-zinc-500 uppercase tracking-widest">Type</span>
<span className="text-[11px] text-zinc-700 dark:text-zinc-300">{COMMISSION_TYPES[mc.resolved.type] || mc.resolved.type}</span>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-[9px] font-mono font-bold text-zinc-400 dark:text-zinc-500 uppercase tracking-widest">Rate</span>
<span className="font-mono text-[11px] text-zinc-900 dark:text-white">
{mc.resolved.type === 'flat' || mc.resolved.type === 'custom'
? formatCurrency(mc.resolved.rate)
: `${mc.resolved.rate}%`}
</span>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-[9px] font-mono font-bold text-zinc-400 dark:text-zinc-500 uppercase tracking-widest">Value</span>
<span className="font-mono text-[11px] font-bold text-green-600 dark:text-[#39ff14]">{formatCurrency(mc.value)}</span>
</div>
</div>
</div>
);
})}
{/* Total */}
<div className="rounded-xl border border-zinc-300 dark:border-white/10 bg-zinc-50 dark:bg-white/[0.03] px-3.5 py-3 flex items-center justify-between">
<span className="text-[10px] font-mono font-bold text-zinc-500 uppercase tracking-widest">Total Distribution</span>
<span className="font-mono text-sm font-bold text-green-600 dark:text-[#39ff14]">
{formatCurrency(memberCommissions.filter(m => m.status === 'active').reduce((s, mc) => s + mc.value, 0))}
</span>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
+1 -5
View File
@@ -9,12 +9,8 @@ export const ThemeProvider = ({ children }) => {
if (savedTheme) {
return savedTheme;
}
// Check system preference
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
return 'dark';
}
}
return 'light'; // Default to light if no preference
return 'dark'; // Default to dark for first-time visitors
});
useEffect(() => {
+121 -9
View File
@@ -652,9 +652,9 @@ function generateProperties() {
const MOCK_USERS = [
// Customers
{ id: 'c1', type: 'customer', username: 'alice', email: 'alice@example.com', password: 'password', name: 'Alice Customer', propertyId: 'P-2600' },
{ id: 'c2', type: 'customer', username: 'bob', email: 'bob@example.com', password: 'password', name: 'Bob Buyer' },
{ id: 'c3', type: 'customer', username: 'charlie', email: 'charlie@example.com', password: 'password', name: 'Charlie Client' },
{ id: 'c1', type: 'customer', username: 'alice', email: 'alice@example.com', password: 'password', name: 'Alice Customer', propertyId: 'P-2600', phone: '214-555-2600' },
{ id: 'c2', type: 'customer', username: 'bob', email: 'bob@example.com', password: 'password', name: 'Bob Buyer', phone: '214-555-2601' },
{ id: 'c3', type: 'customer', username: 'charlie', email: 'charlie@example.com', password: 'password', name: 'Charlie Client', phone: '214-555-2602' },
// Field Agents (5 Agents)
{ id: 'e1', type: 'employee', empId: 'LUP-1040', email: 'agent1@plano.com', password: 'password', role: 'FIELD_AGENT', name: 'Cody Tatum', xp: 12450, doorsKnocked: 342, leadsGained: 45, appointmentsSet: 18, streakDays: 5, achievements: ['Hot Spot Hunter', 'Storm Chaser'] },
@@ -986,7 +986,7 @@ function generateMockMeetings() {
status: 'Completed',
agentName: 'Hannah Reyes',
dealValue: 1500,
notes: 'Minor repairs completed. Payment received.',
notes: 'Minor repairs completed.',
issueDescription: 'Leak in garage.',
customerComments: 'Please call before arriving.',
outcome: 'Repairs Completed'
@@ -2416,6 +2416,20 @@ const MOCK_PROJECTS = [
{ name: 'City of Plano', category: 'Permits & Fees', allocated: 1000, committed: 1000, actual: 950 },
{ name: 'Reserve Fund', category: 'Contingency', allocated: 5000, committed: 1200, actual: 950 }
],
// Cost breakdown / expenses actuals reflect spend-to-date and each line is fully
// paid (paymentRequests cover the actual), so outstanding expenses are $0.
expenses: [
{ id: 'exp_003_1', type: 'material', name: 'Comfort Air Systems', category: 'Equipment & Materials', allocated: 15000, actual: 12500, paid: true, paymentRequests: [ { id: 'req_003_1_1', requestDate: '2026-01-10', requestedAmount: 12500, amountPaid: 12500, paymentDate: '2026-01-14' } ] },
{ id: 'exp_003_2', type: 'labor', name: 'Comfort Air Systems', category: 'Labor - HVAC', allocated: 8000, actual: 6000, paid: true, paymentRequests: [ { id: 'req_003_2_1', requestDate: '2026-01-22', requestedAmount: 6000, amountPaid: 6000, paymentDate: '2026-01-26' } ] },
{ id: 'exp_003_3', type: 'labor', name: 'Sparky Electric', category: 'Labor - Electrical', allocated: 3000, actual: 2400, paid: true, paymentRequests: [ { id: 'req_003_3_1', requestDate: '2026-02-05', requestedAmount: 2400, amountPaid: 2400, paymentDate: '2026-02-08' } ] },
{ id: 'exp_003_4', type: 'other', name: 'City of Plano', category: 'Permits & Fees', allocated: 1000, actual: 950, paid: true, paymentRequests: [ { id: 'req_003_4_1', requestDate: '2026-01-06', requestedAmount: 950, amountPaid: 950, paymentDate: '2026-01-08' } ] },
{ id: 'exp_003_5', type: 'other', name: 'Reserve Fund', category: 'Contingency', allocated: 5000, actual: 600, paid: true, paymentRequests: [ { id: 'req_003_5_1', requestDate: '2026-02-10', requestedAmount: 600, amountPaid: 600, paymentDate: '2026-02-12' } ] },
{ id: 'exp_003_6', type: 'team', name: 'Jesus Gonzales', category: 'Sales Rep', costType: 'Commission', allocated: 3200, actual: 3200, paid: true, paymentRequests: [ { id: 'req_003_6_1', requestDate: '2026-02-12', requestedAmount: 3200, amountPaid: 3200, paymentDate: '2026-02-15' } ] }
],
paymentsReceived: [
{ id: 'rcv_003_1', from: 'Homeowner', method: 'ACH', amount: 18000, date: '2026-01-08', refNumber: 'ACH-003-1', memo: 'HVAC deposit & equipment payment' },
{ id: 'rcv_003_2', from: 'Homeowner', method: 'Check', amount: 12000, date: '2026-02-20', refNumber: 'CHK-003', memo: 'Progress payment — installation' }
],
invoices: [
{ id: 'inv_003_1', amount: 8200, submittedBy: 'con_001', status: 'paid', dueDate: '2026-01-15', datePaid: '2026-01-16' },
{ id: 'inv_003_2', amount: 10500.25, submittedBy: 'v6', status: 'pending', dueDate: '2026-02-19' },
@@ -2623,6 +2637,20 @@ const MOCK_PROJECTS = [
{ name: 'Texas Builders LLC', category: 'Structural Repairs (CO)', allocated: 0, committed: 850, actual: 850 },
{ name: 'Reserve Fund', category: 'Contingency', allocated: 5000, committed: 1350, actual: 350 }
],
// Cost breakdown / expenses actuals reflect spend-to-date and each line is fully
// paid (paymentRequests cover the actual), so outstanding expenses are $0.
expenses: [
{ id: 'exp_005_1', type: 'material', name: 'ABC Supply Co.', category: 'Materials - Siding', allocated: 12000, actual: 8000, paid: true, paymentRequests: [ { id: 'req_005_1_1', requestDate: '2026-01-10', requestedAmount: 8000, amountPaid: 8000, paymentDate: '2026-01-13' } ] },
{ id: 'exp_005_2', type: 'labor', name: 'Siding Solutions TX', category: 'Labor - Prep & Install', allocated: 9000, actual: 3500, paid: true, paymentRequests: [ { id: 'req_005_2_1', requestDate: '2026-01-16', requestedAmount: 3500, amountPaid: 3500, paymentDate: '2026-01-20' } ] },
{ id: 'exp_005_3', type: 'other', name: 'Sunbelt Rentals', category: 'Equipment Rental', allocated: 2000, actual: 1500, paid: true, paymentRequests: [ { id: 'req_005_3_1', requestDate: '2026-01-09', requestedAmount: 1500, amountPaid: 1500, paymentDate: '2026-01-12' } ] },
{ id: 'exp_005_4', type: 'other', name: 'Texas Builders LLC', category: 'Structural Repairs (CO)', allocated: 850, actual: 850, paid: true, paymentRequests: [ { id: 'req_005_4_1', requestDate: '2026-01-19', requestedAmount: 850, amountPaid: 850, paymentDate: '2026-01-22' } ] },
{ id: 'exp_005_5', type: 'other', name: 'Reserve Fund', category: 'Contingency', allocated: 5000, actual: 350, paid: true, paymentRequests: [ { id: 'req_005_5_1', requestDate: '2026-01-25', requestedAmount: 350, amountPaid: 350, paymentDate: '2026-01-27' } ] },
{ id: 'exp_005_6', type: 'team', name: 'Sarah Calloway', category: 'Sales Rep', costType: 'Commission', allocated: 2800, actual: 2800, paid: true, paymentRequests: [ { id: 'req_005_6_1', requestDate: '2026-01-28', requestedAmount: 2800, amountPaid: 2800, paymentDate: '2026-01-31' } ] }
],
paymentsReceived: [
{ id: 'rcv_005_1', from: 'Homeowner', method: 'ACH', amount: 13000, date: '2026-01-08', refNumber: 'ACH-005-1', memo: 'Siding project deposit' },
{ id: 'rcv_005_2', from: 'Homeowner', method: 'Check', amount: 9000, date: '2026-02-06', refNumber: 'CHK-005', memo: 'Progress payment — tear-off & barrier' }
],
invoices: [
{ id: 'inv_005_1', amount: 8400, submittedBy: 'con_001', status: 'paid', dueDate: '2026-01-12', datePaid: '2026-01-13' },
{ id: 'inv_005_2', amount: 5800, submittedBy: 'v3', status: 'paid', dueDate: '2026-01-20', datePaid: '2026-01-21' },
@@ -3585,9 +3613,9 @@ const MOCK_PROJECTS = [
budget: 19600,
approvedBudget: 19600,
committedCost: 18100,
actualCost: 0,
spent: 0,
variancePercent: -100.0,
actualCost: 18100,
spent: 18100,
variancePercent: -7.7,
margin: 0.08,
commissionType: 'percent_gross',
commissionRate: 10,
@@ -3604,6 +3632,22 @@ const MOCK_PROJECTS = [
{ name: 'City of Plano', category: 'Permits', allocated: 600, committed: 600, actual: 0 },
{ name: 'Reserve Fund', category: 'Contingency', allocated: 1500, committed: 0, actual: 0 },
],
expenses: [
{ id: 'exp_017_1', type: 'material', name: 'Owens Corning', category: 'Materials', allocated: 10500, actual: 10500, paid: true, paymentRequests: [
{ id: 'req_017_1_1', requestDate: '2026-04-02', requestedAmount: 10500, amountPaid: 10500, paymentDate: '2026-04-04' }
] },
{ id: 'exp_017_2', type: 'labor', name: 'Plano Roofing Crew C', category: 'Labor', allocated: 7000, actual: 7000, paid: true, paymentRequests: [
{ id: 'req_017_2_1', requestDate: '2026-04-22', requestedAmount: 7000, amountPaid: 7000, paymentDate: '2026-04-24' }
] },
{ id: 'exp_017_3', type: 'other', name: 'City of Plano', category: 'Permits', allocated: 600, actual: 600, paid: true, paymentRequests: [
{ id: 'req_017_3_1', requestDate: '2026-04-10', requestedAmount: 600, amountPaid: 600, paymentDate: '2026-04-10' }
] }
],
paymentsReceived: [
{ id: 'rcv_017_1', from: 'Adriana Voss', method: 'Check', amount: 5880, date: '2026-03-28', refNumber: 'CHK-2017-01', memo: 'Deposit — 30%' },
{ id: 'rcv_017_2', from: 'Farmers Insurance', method: 'ACH', amount: 7840, date: '2026-04-25', refNumber: 'ACH-2017-02', memo: 'Progress payment — 40%' },
{ id: 'rcv_017_3', from: 'Farmers Insurance', method: 'ACH', amount: 5880, date: '2026-04-28', refNumber: 'ACH-2017-03', memo: 'Final payment — 30%' },
],
changeOrders: [
{ id: 'CO-017-01', title: 'Supplemental — Ice & Water Barrier', amount: 1800, status: 'approved', description: 'Insurance adjuster agreed to add ice/water shield on all valleys and eaves.' },
],
@@ -3612,8 +3656,8 @@ const MOCK_PROJECTS = [
issueLog: [],
paymentSchedule: [
{ id: 'ps_017_1', milestone: 'Deposit — 30%', amount: 5880, dueDate: '2026-03-28', status: 'paid', paidDate: '2026-03-28' },
{ id: 'ps_017_2', milestone: 'Progress — 40%', amount: 7840, dueDate: '2026-04-25', status: 'pending' },
{ id: 'ps_017_3', milestone: 'Final — 30%', amount: 5880, dueDate: '2026-04-28', status: 'pending' },
{ id: 'ps_017_2', milestone: 'Progress — 40%', amount: 7840, dueDate: '2026-04-25', status: 'paid', paidDate: '2026-04-25' },
{ id: 'ps_017_3', milestone: 'Final — 30%', amount: 5880, dueDate: '2026-04-28', status: 'paid', paidDate: '2026-04-28' },
],
invoices: [],
milestones: [
@@ -3798,6 +3842,20 @@ const MOCK_PROJECTS = [
{ name: 'City of Plano', category: 'Permits', allocated: 800, committed: 800, actual: 800 },
{ name: 'Reserve Fund', category: 'Contingency', allocated: 1900, committed: 1000, actual: 0 },
],
// Cost breakdown / expenses materials, permits and commission are fully paid, while
// the in-progress labor and the structural-rot change order are only partially paid,
// so the project still carries some outstanding expenses.
expenses: [
{ id: 'exp_019_1', type: 'material', name: 'Owens Corning', category: 'Materials', allocated: 15400, actual: 14200, paid: true, paymentRequests: [ { id: 'req_019_1_1', requestDate: '2026-03-24', requestedAmount: 14200, amountPaid: 14200, paymentDate: '2026-03-26' } ] },
{ id: 'exp_019_2', type: 'labor', name: 'Plano Siding Crew', category: 'Labor', allocated: 10400, actual: 4800, paid: false, paymentRequests: [ { id: 'req_019_2_1', requestDate: '2026-03-27', requestedAmount: 4800, amountPaid: 2800, paymentDate: '2026-03-28' } ] },
{ id: 'exp_019_3', type: 'other', name: 'City of Plano', category: 'Permits', allocated: 800, actual: 800, paid: true, paymentRequests: [ { id: 'req_019_3_1', requestDate: '2026-03-04', requestedAmount: 800, amountPaid: 800, paymentDate: '2026-03-05' } ] },
{ id: 'exp_019_4', type: 'other', name: 'Texas Builders LLC', category: 'Structural Sheathing (CO-019-02)', allocated: 3800, actual: 1800, paid: false, paymentRequests: [ { id: 'req_019_4_1', requestDate: '2026-03-29', requestedAmount: 1800, amountPaid: 800, paymentDate: '2026-03-30' } ] },
{ id: 'exp_019_5', type: 'team', name: 'Jesus Gonzales', category: 'Sales Rep', costType: 'Commission', allocated: 2850, actual: 2850, paid: true, paymentRequests: [ { id: 'req_019_5_1', requestDate: '2026-03-02', requestedAmount: 2850, amountPaid: 2850, paymentDate: '2026-03-05' } ] }
],
paymentsReceived: [
{ id: 'rcv_019_1', from: 'Insurance Carrier', method: 'ACH', amount: 21375, date: '2026-03-01', refNumber: 'ACH-019-1', memo: 'Insurance claim payout — siding replacement' },
{ id: 'rcv_019_2', from: 'Sandra Tran', method: 'Check', amount: 7125, date: '2026-02-25', refNumber: 'CHK-019', memo: 'Homeowner deposit (25%)' }
],
changeOrders: [
{ id: 'CO-019-01', title: 'Rot Repair — 2 Panels', amount: 380, status: 'approved', description: 'Two panels of rotted OSB substrate discovered during demo. Repaired.' },
{ id: 'CO-019-02', title: 'Hidden Rot & Structural Sheathing Replacement', amount: 3800, status: 'approved', dateSubmitted: '2026-03-28', description: 'Full west wall sheathing found severely rotted behind old siding — 320 sq ft of OSB substructure replaced plus additional labor days. Exceeds original labor budget by $3,800.' },
@@ -9140,6 +9198,25 @@ const DEFAULT_EMPTY_PROFILE = {
const MockStoreContext = createContext();
// ---------------------------------------------------------------------------
// Estimate Wizard CRM lead persistence.
// The public DIY Estimate Wizard adds completed estimates as real leads through the
// in-memory store (addWizardLead setLeads). Nothing is persisted to localStorage,
// so leads reset on a full page reload, like all CRM demo data.
//
// When the wizard runs in the SAME tab as the CRM it shares this provider, so the
// lead appears immediately. When it runs in a SEPARATE tab (the public /estimate
// link is its own JS context), we bridge it with BroadcastChannel an in-memory,
// no-persistence message bus so the lead still reaches the CRM tab live without
// writing anything to storage.
// ---------------------------------------------------------------------------
const WIZARD_CHANNEL = 'lup-estimate-wizard';
const wizardBus = (typeof BroadcastChannel !== 'undefined') ? new BroadcastChannel(WIZARD_CHANNEL) : null;
function broadcastWizardLead(lead) {
try { wizardBus?.postMessage({ type: 'wizard-lead', lead }); } catch { /* unsupported */ }
}
export const MockStoreProvider = ({ children }) => {
const [properties, setProperties] = useState([]);
const [users, setUsers] = useState(MOCK_USERS);
@@ -9358,6 +9435,25 @@ export const MockStoreProvider = ({ children }) => {
setProperties(data);
}, []);
// Receive wizard leads created in another tab (public /estimate) and merge them
// into the leads list live no reload, no localStorage. Deduped by wizard
// session id (and id) so an echo or repeat message can't double-add.
useEffect(() => {
if (typeof BroadcastChannel === 'undefined') return;
const bus = new BroadcastChannel(WIZARD_CHANNEL);
bus.onmessage = (e) => {
const lead = e?.data?.type === 'wizard-lead' ? e.data.lead : null;
if (!lead) return;
setLeads(prev => {
const sid = lead.estimateWizard?.sessionId;
if (sid && prev.some(l => l?.estimateWizard?.sessionId === sid)) return prev;
if (lead.id && prev.some(l => l?.id === lead.id)) return prev;
return [lead, ...prev];
});
};
return () => bus.close();
}, []);
const updatePropertyStatus = (id, newStatus) => {
setProperties(prev => prev.map(p =>
p.id === id ? { ...p, canvassingStatus: newStatus } : p
@@ -9979,6 +10075,22 @@ export const MockStoreProvider = ({ children }) => {
return newLead;
},
// Lead created by the public Estimate Wizard. In-memory only (no
// localStorage) and deduped by wizard session id so a StrictMode double
// mount can't create the lead twice.
addWizardLead: (lead) => {
const created = {
...lead,
id: lead.id || `lead_wiz_${Date.now()}`,
createdAt: lead.createdAt || new Date().toISOString(),
status: 'New',
};
const sid = created.estimateWizard?.sessionId;
setLeads(prev => (sid && prev.some(l => l?.estimateWizard?.sessionId === sid)) ? prev : [created, ...prev]);
broadcastWizardLead(created); // reach the CRM tab if the wizard is in another tab
return created;
},
// Templates
templates,
addTemplate: (tpl) => {
+77
View File
@@ -0,0 +1,77 @@
// ─────────────────────────────────────────────────────────────────────────────
// Single source of truth for map status taxonomies & color tokens.
//
// The Territory Map (property canvassing lifecycle) and LynkDispatch (live
// dispatch ops) track different entities, so they keep separate taxonomies —
// but BOTH read their colors from the shared hue tokens below, so the palette
// never drifts or contradicts itself across the app.
//
// One hue = one meaning:
// red → urgent / hot amber → needs attention / busy
// emerald→ positive / converted blue → in progress / active
// violet → undecided / neutral slate → inactive / declined
// gray → low priority / idle
// ─────────────────────────────────────────────────────────────────────────────
export const STATUS_HUES = {
red: '#ef4444',
amber: '#f59e0b',
emerald: '#10b981',
blue: '#3b82f6',
violet: '#a78bfa',
slate: '#cbd5e1',
gray: '#6b7280',
};
// ── Territory Map — property canvassing lifecycle ───────────────────────────
// `color` — marker/polygon stroke & fill (hex)
// `fillOpacity` — polygon fill alpha
// `badge` — Tailwind classes for the status pill
// `dot` — Tailwind bg class for the legend swatch
export const CANVASSING_STATUS = {
'Hot Lead': { color: STATUS_HUES.red, fillOpacity: 0.40, badge: 'bg-red-500 text-white', dot: 'bg-red-500' },
'Customer': { color: STATUS_HUES.emerald, fillOpacity: 0.30, badge: 'bg-emerald-500 text-white', dot: 'bg-emerald-500' },
'Renovated': { color: STATUS_HUES.blue, fillOpacity: 0.20, badge: 'bg-blue-500 text-white', dot: 'bg-blue-500' },
'Neutral': { color: STATUS_HUES.violet, fillOpacity: 0.20, badge: 'bg-violet-400 text-white', dot: 'bg-violet-400' },
'Not Interested': { color: STATUS_HUES.slate, fillOpacity: 0.35, badge: 'bg-slate-300 text-slate-900', dot: 'bg-slate-300' },
};
// Render/legend order for the canvassing statuses.
export const CANVASSING_STATUS_ORDER = ['Hot Lead', 'Customer', 'Renovated', 'Neutral', 'Not Interested'];
// Fallback used when a property has no (or an unknown) canvassing status.
export const CANVASSING_STATUS_DEFAULT = CANVASSING_STATUS.Neutral;
// ── LynkDispatch — live dispatch operations ─────────────────────────────────
export const REP_STATUS = {
available: { color: STATUS_HUES.emerald, label: 'Available' },
en_route: { color: STATUS_HUES.blue, label: 'En Route' },
busy: { color: STATUS_HUES.amber, label: 'Busy' },
};
export const LEAD_URGENCY = {
emergency: { color: STATUS_HUES.red, label: 'Emergency' },
high: { color: STATUS_HUES.amber, label: 'High' },
standard: { color: STATUS_HUES.gray, label: 'Standard' },
};
// Flat id→value maps for existing call sites that index by status/urgency key.
export const REP_STATUS_COLORS = Object.fromEntries(Object.entries(REP_STATUS).map(([k, v]) => [k, v.color]));
export const REP_STATUS_LABELS = Object.fromEntries(Object.entries(REP_STATUS).map(([k, v]) => [k, v.label]));
export const URGENCY_COLORS = Object.fromEntries(Object.entries(LEAD_URGENCY).map(([k, v]) => [k, v.color]));
// ── Team Schedule — appointment / meeting status ────────────────────────────
// `badge` — Tailwind classes for the status pill
// `dot` — Tailwind bg class for the legend swatch
// `desc` — one-line meaning shown in the legend (so Converted vs Scheduled is clear)
export const MEETING_STATUS = {
Scheduled: { label: 'Scheduled', badge: 'bg-blue-500/20 text-blue-600 dark:text-blue-400', dot: 'bg-blue-500', desc: 'Upcoming appointment' },
Rescheduled: { label: 'Rescheduled', badge: 'bg-amber-500/20 text-amber-600 dark:text-amber-400', dot: 'bg-amber-500', desc: 'Moved to a new date or time' },
Completed: { label: 'Completed', badge: 'bg-emerald-500/20 text-emerald-600 dark:text-emerald-400', dot: 'bg-emerald-500', desc: 'Visit took place' },
Converted: { label: 'Converted', badge: 'bg-violet-500/20 text-violet-600 dark:text-violet-400', dot: 'bg-violet-500', desc: 'Won — became a signed job' },
Cancelled: { label: 'Cancelled', badge: 'bg-slate-500/20 text-slate-600 dark:text-slate-400', dot: 'bg-slate-400', desc: 'Called off' },
};
export const MEETING_STATUS_ORDER = ['Scheduled', 'Rescheduled', 'Completed', 'Converted', 'Cancelled'];
export const MEETING_STATUS_DEFAULT = { label: 'Unknown', badge: 'bg-zinc-400/20 text-zinc-500 dark:text-zinc-400', dot: 'bg-zinc-400', desc: '' };
@@ -0,0 +1,59 @@
/**
* CustomerEstimateWizard — public entry point for the DIY Roofing Estimate Wizard.
* Rendered chrome-less (no CRM sidebar) at the public /estimate route. The homeowner
* is the primary user (spec §1). Wrapped in its own provider so the module stays
* fully self-contained.
*/
import React from 'react';
import { CheckCircle2 } from 'lucide-react';
import { EstimateWizardProvider, useEstimateWizard } from './EstimateWizardContext';
import WizardShell from './components/WizardShell';
const CompletionScreen = () => {
const { inputs, reset } = useEstimateWizard();
return (
<div className="min-h-[100dvh] w-full bg-zinc-50 dark:bg-[#09090b] flex items-center justify-center px-4 py-10">
<div className="max-w-md text-center">
<CheckCircle2 size={56} className="text-green-500 mx-auto mb-4" />
<h1 className="text-2xl font-bold text-zinc-900 dark:text-white">
Thanks{inputs.name ? `, ${inputs.name.split(' ')[0]}` : ''}!
</h1>
<p className="text-zinc-500 dark:text-zinc-400 mt-2">
Your roof options report is ready and your details have been sent to our team
a rep will reach out to confirm your free inspection.
</p>
<button
onClick={reset}
className="mt-6 rounded-xl bg-gradient-to-r from-amber-400 to-orange-500 text-white font-semibold px-6 py-2.5 shadow-lg shadow-amber-500/25"
>
Build another estimate
</button>
</div>
</div>
);
};
const FeatureDisabledScreen = () => (
<div className="min-h-[100dvh] w-full bg-zinc-50 dark:bg-[#09090b] flex items-center justify-center px-4 py-10">
<div className="max-w-md text-center">
<h1 className="text-xl font-bold text-zinc-900 dark:text-white">Estimate wizard unavailable</h1>
<p className="text-zinc-500 dark:text-zinc-400 mt-2">
This tool is temporarily turned off. Please contact us directly and well be glad to help.
</p>
</div>
</div>
);
const WizardRouter = () => {
const { session, tenantConfig } = useEstimateWizard();
if (tenantConfig.featureEnabled === false) return <FeatureDisabledScreen />; // §18 kill-switch
return session.status === 'completed' ? <CompletionScreen /> : <WizardShell />;
};
const CustomerEstimateWizard = ({ source = 'public' }) => (
<EstimateWizardProvider source={source}>
<WizardRouter />
</EstimateWizardProvider>
);
export default CustomerEstimateWizard;
@@ -0,0 +1,275 @@
/**
* EstimateWizardAdmin — internal admin controls + analytics (spec §14, §16, §18).
*
* Tenant-controls (no hard-coded pricing/copy): feature kill-switch, AI toggle,
* conservative mode, per-package price + base discount + service life, and report
* branding. Persists as overrides via saveConfigOverrides(). Also shows a funnel
* summary built from completed-session submissions (spec §16).
*/
import React, { useMemo, useState } from 'react';
import { toast } from 'sonner';
import { Save, RotateCcw, Power, Sparkles, ShieldCheck, BarChart3, Package, History } from 'lucide-react';
import {
getTenantConfig, loadConfigOverrides, publishConfigOverrides, resetConfigOverrides,
} from './data/tenantConfig';
import { loadSubmissions } from './api/crmHandoff';
const PKG_LABEL = { GOOD: 'Good', BETTER: 'Better', BEST: 'Best' };
const Toggle = ({ label, desc, checked, onChange, icon: Icon }) => (
<label className="flex items-start justify-between gap-3 rounded-xl border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 p-4 cursor-pointer">
<div className="flex items-start gap-2.5">
{Icon && <Icon size={18} className="text-amber-500 mt-0.5" />}
<div>
<div className="font-semibold text-zinc-900 dark:text-white">{label}</div>
<div className="text-xs text-zinc-500 dark:text-zinc-400">{desc}</div>
</div>
</div>
<input type="checkbox" className="mt-1 h-5 w-5 accent-amber-500 shrink-0" checked={checked} onChange={(e) => onChange(e.target.checked)} />
</label>
);
const numInput = 'w-full rounded-lg border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 px-2.5 py-1.5 text-sm text-zinc-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-amber-500';
const textInput = numInput;
const areaInput = numInput + ' min-h-[64px] resize-y leading-relaxed';
const EstimateWizardAdmin = () => {
const initial = useMemo(() => getTenantConfig(), []);
const [cfg, setCfg] = useState(initial);
const [versionInfo, setVersionInfo] = useState(() => {
const o = loadConfigOverrides();
return { version: initial.version, versionedAt: o.versionedAt || null, history: o.versionHistory || [] };
});
const submissions = useMemo(() => loadSubmissions(), []);
const analytics = useMemo(() => {
const total = submissions.length;
const dist = (key) => submissions.reduce((m, s) => { const k = s[key]; if (k) m[k] = (m[k] || 0) + 1; return m; }, {});
const booked = submissions.filter((s) => s.report?.appointmentBooked).length;
const addonAttach = total ? (submissions.reduce((n, s) => n + (s.selectedAddons?.length || 0), 0) / total) : 0;
return { total, recommended: dist('recommendedPackage'), selected: dist('selectedPackage'), booked, addonAttach };
}, [submissions]);
const setFlag = (k, v) => setCfg((c) => ({ ...c, [k]: v }));
const setPkg = (type, field, value) => setCfg((c) => ({
...c,
packages: c.packages.map((p) => (p.type === type ? { ...p, [field]: value } : p)),
}));
// Included scope is edited as one item per line.
const setScope = (type, text) => setPkg(type, 'scope', text.split('\n').map((s) => s.trim()).filter(Boolean));
const setDiscount = (type, value) => setCfg((c) => ({
...c,
discountScenarios: { ...c.discountScenarios, [type]: { ...c.discountScenarios[type], base: value } },
}));
const setBranding = (field, value) => setCfg((c) => ({ ...c, branding: { ...c.branding, [field]: value } }));
const handleSave = () => {
const prev = loadConfigOverrides();
const overrides = {
...prev,
featureEnabled: cfg.featureEnabled,
aiExplanationsEnabled: cfg.aiExplanationsEnabled,
conservativeMode: cfg.conservativeMode,
branding: cfg.branding,
discountScenarios: cfg.discountScenarios,
// Full Good/Better/Best content is tenant-controlled (spec §4/§14).
packages: cfg.packages.map((p) => ({
type: p.type,
name: p.name,
category: p.category,
warrantyLabel: p.warrantyLabel,
hailResistance: p.hailResistance,
position: p.position,
fit: p.fit,
scope: p.scope,
pricePerSquare: p.pricePerSquare,
serviceLifeYears: p.serviceLifeYears,
})),
};
const saved = publishConfigOverrides(overrides); // bumps version + history
setVersionInfo({ version: saved.version, versionedAt: saved.versionedAt, history: saved.versionHistory });
setCfg((c) => ({ ...c, version: saved.version }));
toast.success(`Published ${saved.version}`);
};
const handleReset = () => {
resetConfigOverrides();
const fresh = getTenantConfig();
setCfg(fresh);
setVersionInfo({ version: fresh.version, versionedAt: null, history: [] });
toast.message('Reset to defaults');
};
return (
<div className="h-full overflow-auto bg-zinc-50 dark:bg-[#09090b]">
<div className="max-w-4xl mx-auto p-4 md:p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-zinc-900 dark:text-white flex items-center gap-2">
<span className="w-2 h-6 bg-amber-500 rounded-full" /> Estimate Wizard Admin
</h1>
<p className="text-zinc-500 dark:text-zinc-400 text-sm ml-4">
Tenant controls for package content, pricing, discounts, AI, branding, and analytics.
</p>
<div className="ml-4 mt-1.5 flex items-center gap-2 text-[11px]">
<span className="inline-flex items-center gap-1 rounded-full bg-zinc-100 dark:bg-white/5 px-2 py-0.5 font-mono font-semibold text-zinc-600 dark:text-zinc-300">
<History size={11} /> {versionInfo.version}
</span>
{versionInfo.versionedAt && (
<span className="text-zinc-400">
published {new Date(versionInfo.versionedAt).toLocaleString()}
</span>
)}
</div>
</div>
<div className="flex gap-2">
<button onClick={handleReset} className="rounded-xl border border-zinc-200 dark:border-white/10 px-3 py-2 text-sm font-medium text-zinc-600 dark:text-zinc-300 hover:border-amber-400 flex items-center gap-1.5">
<RotateCcw size={15} /> Reset
</button>
<button onClick={handleSave} className="rounded-xl bg-gradient-to-r from-amber-400 to-orange-500 text-white px-4 py-2 text-sm font-semibold flex items-center gap-1.5 shadow-lg shadow-amber-500/25">
<Save size={15} /> Save &amp; publish
</button>
</div>
</div>
{/* Feature flags */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<Toggle icon={Power} label="Feature enabled" desc="Master kill-switch for the public wizard." checked={cfg.featureEnabled} onChange={(v) => setFlag('featureEnabled', v)} />
<Toggle icon={Sparkles} label="AI explanations" desc="Use AI copy (off = deterministic text)." checked={cfg.aiExplanationsEnabled} onChange={(v) => setFlag('aiExplanationsEnabled', v)} />
<Toggle icon={ShieldCheck} label="Conservative mode" desc="Hide customer-facing savings claims." checked={cfg.conservativeMode} onChange={(v) => setFlag('conservativeMode', v)} />
</div>
{/* Good / Better / Best content — fully tenant-controlled (spec §4/§14) */}
<section className="rounded-2xl border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 p-4">
<h2 className="font-semibold text-zinc-900 dark:text-white mb-1 flex items-center gap-2">
<Package size={17} className="text-amber-500" /> Good / Better / Best content
</h2>
<p className="text-[11px] text-zinc-400 mb-4">
Every customer-facing field is editable here nothing is hard-coded. Saving publishes a new version.
</p>
<div className="space-y-5">
{cfg.packages.map((p) => (
<div key={p.type} className="rounded-xl border border-zinc-200 dark:border-white/10 p-3.5">
<div className="text-xs font-bold uppercase tracking-wide text-amber-600 dark:text-amber-400 mb-3">{PKG_LABEL[p.type]}</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<label className="text-xs text-zinc-500 dark:text-zinc-400">Package name
<input className={textInput} value={p.name} onChange={(e) => setPkg(p.type, 'name', e.target.value)} />
</label>
<label className="text-xs text-zinc-500 dark:text-zinc-400">Category / description
<input className={textInput} value={p.category} onChange={(e) => setPkg(p.type, 'category', e.target.value)} />
</label>
<label className="text-xs text-zinc-500 dark:text-zinc-400">Warranty label
<input className={textInput} value={p.warrantyLabel} onChange={(e) => setPkg(p.type, 'warrantyLabel', e.target.value)} />
</label>
<label className="text-xs text-zinc-500 dark:text-zinc-400">Hail resistance label
<input className={textInput} value={p.hailResistance} onChange={(e) => setPkg(p.type, 'hailResistance', e.target.value)} />
</label>
<label className="text-xs text-zinc-500 dark:text-zinc-400 sm:col-span-2">Positioning / upgrade copy
<input className={textInput} value={p.position} onChange={(e) => setPkg(p.type, 'position', e.target.value)} />
</label>
<label className="text-xs text-zinc-500 dark:text-zinc-400 sm:col-span-2">Best-fit description
<textarea className={areaInput} value={p.fit} onChange={(e) => setPkg(p.type, 'fit', e.target.value)} />
</label>
<label className="text-xs text-zinc-500 dark:text-zinc-400 sm:col-span-2">Included scope (one item per line)
<textarea className={areaInput} value={(p.scope || []).join('\n')} onChange={(e) => setScope(p.type, e.target.value)} />
</label>
</div>
<div className="grid grid-cols-3 gap-3 mt-3">
<label className="text-xs text-zinc-500 dark:text-zinc-400">Price / square ($)
<input type="number" className={numInput} value={p.pricePerSquare} onChange={(e) => setPkg(p.type, 'pricePerSquare', Number(e.target.value))} />
</label>
<label className="text-xs text-zinc-500 dark:text-zinc-400">Service life (yrs)
<input type="number" className={numInput} value={p.serviceLifeYears} onChange={(e) => setPkg(p.type, 'serviceLifeYears', Number(e.target.value))} />
</label>
<label className="text-xs text-zinc-500 dark:text-zinc-400">Base discount (%)
<input type="number" className={numInput} value={cfg.discountScenarios[p.type]?.base ?? 0} onChange={(e) => setDiscount(p.type, Number(e.target.value))} />
</label>
</div>
</div>
))}
</div>
<p className="text-[11px] text-zinc-400 mt-3">Customer copy always states discounts are illustrative "ask your carrier to confirm."</p>
</section>
{/* Branding */}
<section className="rounded-2xl border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 p-4">
<h2 className="font-semibold text-zinc-900 dark:text-white mb-3">Report branding</h2>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<label className="text-xs text-zinc-500 dark:text-zinc-400">Company
<input className={numInput} value={cfg.branding.company} onChange={(e) => setBranding('company', e.target.value)} />
</label>
<label className="text-xs text-zinc-500 dark:text-zinc-400">Phone
<input className={numInput} value={cfg.branding.phone} onChange={(e) => setBranding('phone', e.target.value)} />
</label>
<label className="text-xs text-zinc-500 dark:text-zinc-400">Primary color
<input className={numInput} value={cfg.branding.primaryColor} onChange={(e) => setBranding('primaryColor', e.target.value)} />
</label>
</div>
</section>
{/* Analytics */}
<section className="rounded-2xl border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 p-4">
<h2 className="font-semibold text-zinc-900 dark:text-white mb-3 flex items-center gap-2">
<BarChart3 size={17} className="text-amber-500" /> Funnel analytics
</h2>
{analytics.total === 0 ? (
<p className="text-sm text-zinc-500 dark:text-zinc-400">No completed estimates yet. Finish a wizard run to populate metrics.</p>
) : (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<Metric label="Completed reports" value={analytics.total} />
<Metric label="Appointments booked" value={analytics.booked} />
<Metric label="Avg add-ons / report" value={analytics.addonAttach.toFixed(1)} />
<Metric label="Best recommended" value={analytics.recommended.BEST || 0} />
<DistRow label="Recommended split" dist={analytics.recommended} />
<DistRow label="Customer-selected split" dist={analytics.selected} />
</div>
)}
</section>
{/* Version history */}
<section className="rounded-2xl border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 p-4">
<h2 className="font-semibold text-zinc-900 dark:text-white mb-3 flex items-center gap-2">
<History size={17} className="text-amber-500" /> Version history
</h2>
{(!versionInfo.history || versionInfo.history.length === 0) ? (
<p className="text-sm text-zinc-500 dark:text-zinc-400">
No published changes yet currently on defaults ({versionInfo.version}).
Each save publishes a new version that reports are stamped with.
</p>
) : (
<ul className="space-y-1.5">
{versionInfo.history.map((h) => (
<li key={h.version} className="flex items-center justify-between text-sm border-b border-zinc-100 dark:border-white/5 pb-1.5 last:border-0">
<span className="font-mono font-semibold text-zinc-700 dark:text-zinc-200">{h.version}</span>
<span className="text-xs text-zinc-400">{new Date(h.versionedAt).toLocaleString()}</span>
</li>
))}
</ul>
)}
</section>
</div>
</div>
);
};
const Metric = ({ label, value }) => (
<div className="rounded-xl bg-zinc-50 dark:bg-white/5 p-3">
<div className="text-xs text-zinc-400">{label}</div>
<div className="text-xl font-bold text-zinc-900 dark:text-white mt-0.5">{value}</div>
</div>
);
const DistRow = ({ label, dist }) => (
<div className="rounded-xl bg-zinc-50 dark:bg-white/5 p-3 col-span-2">
<div className="text-xs text-zinc-400 mb-1">{label}</div>
<div className="flex gap-3 text-sm font-medium text-zinc-700 dark:text-zinc-200">
{['GOOD', 'BETTER', 'BEST'].map((t) => (
<span key={t}>{PKG_LABEL[t]}: <span className="text-amber-600 dark:text-amber-400">{dist[t] || 0}</span></span>
))}
</div>
</div>
);
export default EstimateWizardAdmin;
@@ -0,0 +1,181 @@
/**
* EstimateWizardContext — self-contained session state for the DIY Estimate Wizard.
*
* This is the client-side stand-in for the spec's backend entities (§12):
* estimate_sessions, estimate_inputs, hail_risk_snapshots, recommendations,
* customer_reports, estimate_events.
* Session state is held in React memory only — like the rest of the CRM demo store,
* nothing is written to localStorage, so a full page reload starts a fresh session.
*
* Kept separate from the global MockStoreProvider on purpose — this is a new,
* isolated module. A Phase 5 adapter can mirror sessions into the CRM mock store.
*/
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
import { STEP_FLOW } from './data/questions';
import { getTenantConfig, CONFIG_CHANGE_EVENT } from './data/tenantConfig';
// One-click demo seed (spec demo support). Every value matches a valid choice in
// questions.js so the wizard renders selected cards correctly. Address is a real
// Plano, TX location so the Nominatim geocoder + storm-history lookup resolve.
export const DEMO_INPUTS = {
name: 'Jordan Sample',
address: '2700 W 15th St, Plano, TX 75075',
phone: '(972) 555-0142',
email: 'jordan.sample@example.com',
contactMethod: 'text',
consent: true,
roofAgeYears: 14,
issueType: 'storm',
ownershipHorizon: '16+',
premiumAmount: 2400,
premiumUnknown: false,
budgetPreference: 'balanced',
hoaConstraints: 'no',
atticConcern: 'energy_bills',
pestConcern: 'no',
financingInterest: 'maybe',
};
const EstimateWizardContext = createContext(null);
// A stable-ish id without relying on Date.now/Math.random being available everywhere.
function makeSessionId() {
const t = new Date().toISOString().replace(/[^0-9]/g, '').slice(0, 14);
const r = Math.floor(performance.now() % 100000).toString().padStart(5, '0');
return `es_${t}_${r}`;
}
function freshSession(source = 'public') {
return {
id: makeSessionId(),
tenantId: 'lynkeduppro',
status: 'started', // started → completed | abandoned
source, // website | ad | qr | crm | public
startedAt: new Date().toISOString(),
completedAt: null,
// estimate_inputs (§12)
inputs: {
name: '', address: '', phone: '', email: '', consent: false, contactMethod: 'email',
roofAgeYears: 10,
issueType: null,
ownershipHorizon: null,
premiumAmount: null, premiumUnknown: false,
budgetPreference: null,
hoaConstraints: null,
atticConcern: null,
pestConcern: null,
financingInterest: null,
},
property: null, // property_profiles (Phase 2)
hailSnapshot: null, // hail_risk_snapshots (Phase 2)
selectedPackage: null, // customer's chosen option (Phase 3)
selectedAddons: [], // add-on ids (Phase 3)
recommendation: null, // recommendations (Phase 4)
report: null, // customer_reports (Phase 5)
events: [], // estimate_events (audit/analytics)
stepIndex: 0,
};
}
export function EstimateWizardProvider({ children, source = 'public' }) {
// Snapshot tenant config at mount, but stay reactive: the admin can publish flags
// (kill-switch, AI copy, conservative mode), pricing, and package content while a
// wizard is open. Refresh on the same-tab publish event so toggles take effect
// without a hard reload (spec §14/§18).
const [tenantConfig, setTenantConfig] = useState(getTenantConfig);
const [session, setSession] = useState(() => freshSession(source));
const [resumed] = useState(false); // no persistence → never a resumed session
useEffect(() => {
const refresh = () => setTenantConfig(getTenantConfig());
window.addEventListener(CONFIG_CHANGE_EVENT, refresh); // admin publish
return () => window.removeEventListener(CONFIG_CHANGE_EVENT, refresh);
}, []);
const logEvent = useCallback((eventType, payload = {}) => {
setSession(s => ({
...s,
events: [...s.events, { eventType, payload, occurredAt: new Date().toISOString() }],
}));
}, []);
const setInput = useCallback((key, value) => {
setSession(s => ({ ...s, inputs: { ...s.inputs, [key]: value } }));
}, []);
const patchSession = useCallback((patch) => {
setSession(s => ({ ...s, ...patch }));
}, []);
const goToStep = useCallback((index) => {
const clamped = Math.max(0, Math.min(STEP_FLOW.length - 1, index));
setSession(s => ({ ...s, stepIndex: clamped }));
}, []);
const next = useCallback(() => {
setSession(s => {
const target = Math.min(STEP_FLOW.length - 1, s.stepIndex + 1);
const stepId = STEP_FLOW[s.stepIndex]?.id;
return {
...s,
stepIndex: target,
events: [...s.events, { eventType: 'step_complete', payload: { stepId }, occurredAt: new Date().toISOString() }],
};
});
}, []);
const back = useCallback(() => {
setSession(s => ({ ...s, stepIndex: Math.max(0, s.stepIndex - 1) }));
}, []);
const complete = useCallback(() => {
setSession(s => ({ ...s, status: 'completed', completedAt: new Date().toISOString() }));
}, []);
const reset = useCallback(() => {
setSession(freshSession(source));
}, [source]);
// Fill every input with a valid demo answer so the wizard can be exercised fast.
const prefillDemo = useCallback(() => {
setSession(s => ({
...s,
inputs: { ...s.inputs, ...DEMO_INPUTS },
property: null, // force a fresh geocode on the address step
hailSnapshot: null,
events: [...s.events, { eventType: 'demo_prefill', payload: {}, occurredAt: new Date().toISOString() }],
}));
}, []);
const value = {
session,
inputs: session.inputs,
tenantConfig,
resumed,
currentStep: STEP_FLOW[session.stepIndex],
stepIndex: session.stepIndex,
totalSteps: STEP_FLOW.length,
setInput,
patchSession,
logEvent,
goToStep,
next,
back,
complete,
reset,
prefillDemo,
};
return (
<EstimateWizardContext.Provider value={value}>
{children}
</EstimateWizardContext.Provider>
);
}
export function useEstimateWizard() {
const ctx = useContext(EstimateWizardContext);
if (!ctx) throw new Error('useEstimateWizard must be used within EstimateWizardProvider');
return ctx;
}
@@ -0,0 +1,44 @@
/**
* bannedClaims.js — guardrail for AI-generated customer copy (spec §15, §18).
*
* Every AI explanation MUST pass validateClaim() before it is shown or stored.
* On any violation the caller discards the AI text and uses the deterministic
* fallback. This enforces: no guaranteed discount, no guaranteed hail event,
* no insurance/legal advice, no manufacturer names (unless brand mode enabled).
*/
// Manufacturer / brand names that must not appear in customer-facing copy (§3, §15).
const MANUFACTURER_NAMES = [
'gaf', 'owens corning', 'certainteed', 'malarkey', 'iko', 'tamko',
'atlas', 'decra', 'davinci', 'brava', 'f-wave', 'euroshield', 'boral', 'timberline',
];
const BANNED_PATTERNS = [
{ id: 'guaranteed_discount', re: /\b(guarantee[ds]?|guaranteed)\b[^.]*\b(discount|savings|premium)\b/i },
{ id: 'guaranteed_discount2', re: /\b(discount|savings)\b[^.]*\bguarantee/i },
{ id: 'will_get_hail', re: /\b(will|going to|guaranteed to)\b[^.]*\b(hail|storm|be hit|get hit)\b/i },
{ id: 'never_replace', re: /\bnever (have to )?replace\b/i },
{ id: 'lifetime_no_maintenance', re: /\b(maintenance[- ]free|zero maintenance) (for )?life/i },
{ id: 'file_a_claim', re: /\b(file|submit) (a|an|your) (insurance )?claim\b/i },
{ id: 'insurance_advice', re: /\b(you should|we recommend|advise you to) (change|switch|drop|cancel) (your )?insurance\b/i },
];
/**
* @returns {{ ok: boolean, violations: string[] }}
*/
export function validateClaim(text = '', { allowBrands = false } = {}) {
const violations = [];
const lower = text.toLowerCase();
for (const { id, re } of BANNED_PATTERNS) {
if (re.test(text)) violations.push(id);
}
if (!allowBrands) {
for (const name of MANUFACTURER_NAMES) {
if (lower.includes(name)) { violations.push(`manufacturer:${name}`); break; }
}
}
return { ok: violations.length === 0, violations };
}
export const BANNED_CLAIM_IDS = BANNED_PATTERNS.map((p) => p.id);
+122
View File
@@ -0,0 +1,122 @@
/**
* explain.js — AI recommendation explanation + rep brief (spec §9).
*
* Design:
* - The deterministic engine already chose the package and the reasons. The AI
* only turns them into warm plain-language copy; it cannot change the result.
* - Reuses the app's existing Groq integration (config.groqApiKey). With no valid
* key (config.isDemoMode) it skips the API and uses a deterministic fallback.
* - EVERY AI output passes validateClaim(); on any violation we discard the AI
* text and use the safe fallback (spec §15, §18).
*/
import Groq from 'groq-sdk';
import { config } from '../../../config/env';
import { SYSTEM_PROMPT } from './knowledgeBase';
import { validateClaim } from './bannedClaims';
const AI_MODEL = 'openai/gpt-oss-120b';
const PKG_LABEL = { GOOD: 'Good', BETTER: 'Better', BEST: 'Best' };
function fmtUSD(n) { return `$${Math.round(n).toLocaleString('en-US')}`; }
// ── Deterministic fallback (also the safety net if AI is unavailable/invalid) ──
function fallbackExplanation({ inputs, recommendation, valueModel }) {
const type = recommendation.selectedPackage;
const label = PKG_LABEL[type];
const reasons = recommendation.reasons;
const row = valueModel.rows.find((r) => r.type === type);
let savingsLine = '';
if (valueModel.premiumKnown && row?.savings) {
savingsLine = ` Over 10 years, an illustrative savings scenario ranges from ${fmtUSD(row.savings.low)} to ${fmtUSD(row.savings.high)} — this is only an estimate and must be confirmed with your insurance carrier.`;
}
const customerExplanation =
`Based on your answers, the ${label} option appears to be the strongest fit for your home and goals.` +
` ${reasons[0] || ''}${savingsLine} We still recommend reviewing all three options with your roofing consultant, since final pricing, roof measurement, ventilation, and insurance eligibility must be verified.`;
return { customerExplanation, reasons, repBrief: buildRepBrief({ inputs, recommendation }), aiUsed: false, guardrail: { ok: true, violations: [] } };
}
// Rep brief is deterministic regardless of AI (spec §9) — concise sales prep.
function buildRepBrief({ inputs, recommendation }) {
const parts = [];
parts.push(`Recommended: ${PKG_LABEL[recommendation.selectedPackage]} (${recommendation.modelVersion}).`);
if (inputs.ownershipHorizon) parts.push(`Ownership ${inputs.ownershipHorizon} yrs.`);
if (inputs.budgetPreference) parts.push(`Budget pref: ${inputs.budgetPreference}.`);
if (inputs.premiumUnknown || !inputs.premiumAmount) parts.push('Premium skipped — bring savings-scenario explanation.');
else parts.push(`Premium ~$${inputs.premiumAmount}/yr.`);
if (inputs.atticConcern && inputs.atticConcern !== 'none') parts.push('Mentioned attic/ventilation — bring ventilation guidance.');
if (inputs.pestConcern === 'yes') parts.push('Pest concern — discuss critter guard.');
if (inputs.issueType === 'leak') parts.push('Active leak — prioritize inspection.');
if (inputs.hoaConstraints === 'yes') parts.push('HOA constraints — verify approval before final material.');
if (inputs.financingInterest === 'yes') parts.push('Interested in financing — prepare options.');
if (recommendation.budgetGuardApplied) parts.push('Budget guard applied (did not upsell to Best over stated low-cost preference).');
if (recommendation.uncertaintyFlags.length) parts.push(`Data gaps: ${recommendation.uncertaintyFlags.join(', ')}.`);
return parts.join(' ');
}
// ── AI path ───────────────────────────────────────────────────────────────────
function buildUserPrompt({ inputs, hailSnapshot, recommendation, valueModel }) {
const type = recommendation.selectedPackage;
const row = valueModel.rows.find((r) => r.type === type);
return `Write a 3-4 sentence plain-language explanation for the homeowner of why the ${PKG_LABEL[type]} roof option is highlighted, then a one-line internal rep brief.
Context (do not contradict; do not add numbers not here):
- Recommended option: ${PKG_LABEL[type]}
- Deterministic reasons: ${recommendation.reasons.join(' | ')}
- Ownership horizon: ${inputs.ownershipHorizon || 'unknown'}
- Budget preference: ${inputs.budgetPreference || 'unknown'}
- Roof age: ${inputs.roofAgeYears ?? 'unknown'} years
- Area hail exposure (historical): ${hailSnapshot?.exposureTier || 'unknown'}
- Annual premium: ${inputs.premiumUnknown || !inputs.premiumAmount ? 'not provided' : '$' + inputs.premiumAmount}
- 10-yr illustrative savings (base, ${type}): ${row?.savings ? '$' + row.savings.base : 'n/a'}
Respond ONLY as JSON: {"customerExplanation": "...", "repBrief": "..."}`;
}
export async function generateExplanation(ctx) {
// Admin disabled AI (spec §18) or no valid key → deterministic mode.
if (ctx.tenantConfig?.aiExplanationsEnabled === false || config.isDemoMode(config.groqApiKey)) {
return fallbackExplanation(ctx);
}
try {
const groq = new Groq({ apiKey: config.groqApiKey, dangerouslyAllowBrowser: true });
const completion = await groq.chat.completions.create({
model: AI_MODEL,
temperature: 0.3,
max_tokens: 400,
response_format: { type: 'json_object' },
messages: [
{ role: 'system', content: SYSTEM_PROMPT },
{ role: 'user', content: buildUserPrompt(ctx) },
],
});
const raw = completion.choices?.[0]?.message?.content || '';
let parsed;
try { parsed = JSON.parse(raw); } catch { return fallbackExplanation(ctx); }
const customerExplanation = String(parsed.customerExplanation || '').trim();
const repBriefAI = String(parsed.repBrief || '').trim();
// Guardrail: validate the customer-facing text. Any violation → safe fallback.
const guardrail = validateClaim(customerExplanation);
if (!guardrail.ok || customerExplanation.length < 20) {
const fb = fallbackExplanation(ctx);
return { ...fb, guardrail, blockedAiText: customerExplanation || null };
}
// Rep brief stays deterministic + optional AI sentence (also validated).
const repValidated = validateClaim(repBriefAI).ok ? repBriefAI : '';
const repBrief = [buildRepBrief(ctx), repValidated].filter(Boolean).join(' ');
return { customerExplanation, reasons: ctx.recommendation.reasons, repBrief, aiUsed: true, guardrail };
} catch {
return fallbackExplanation(ctx);
}
}
@@ -0,0 +1,34 @@
/**
* knowledgeBase.js — approved content blocks for AI grounding (spec §9, §15).
* The AI must ground answers in these; unknown questions route to the rep. Keeping
* the approved facts in one place makes the guardrails auditable.
*/
export const APPROVED_FACTS = {
class4: 'Class 4 means the roofing material has passed a higher impact-resistance test (UL 2218). Whether it affects an insurance premium is decided by the carrier, not the contractor.',
hailFraming: 'Historical hail data describes the surrounding AREA over time. It is not a forecast or a guarantee that a specific home will be hit.',
insuranceDisclaimer: 'Any insurance premium savings are illustrative and must be confirmed with the homeowners insurance carrier or licensed agent. Discounts depend on state, carrier, policy, roof class, and documentation.',
serviceLife: 'Premium systems are designed for longer service life, but actual lifespan depends on installation, ventilation, maintenance, climate, and product. Avoid promises like “never replace again.”',
preliminary: 'Every wizard estimate is preliminary until a roof measurement and contractor inspection verify it.',
neutrality: 'Describe systems by generic category (architectural shingle, Class 4 impact-rated shingle, composite/synthetic or stone-coated steel). Do not name manufacturers.',
};
// Compact system prompt for the AI explainer (kept short for fast demo responses).
export const SYSTEM_PROMPT = `You are LynkedUp Pro's roofing estimate explainer. You write short, plain-language, HONEST explanations for a homeowner comparing Good/Better/Best roof options.
NON-NEGOTIABLE RULES:
- Never guarantee an insurance discount or savings. Say savings are illustrative and must be confirmed with their carrier.
- Never say a home WILL get hail or be hit. Only describe historical AREA exposure.
- Never give insurance or legal advice. Never tell them to file a claim or change insurance.
- Never name roofing manufacturers/brands. Use generic categories only.
- Never promise "never replace again" — say "designed for longer service life."
- Do not invent measurements, prices, or discounts beyond what you are given.
- The recommended option is decided by a deterministic engine; you EXPLAIN it, you do not change it.
Approved facts you may use:
- ${APPROVED_FACTS.class4}
- ${APPROVED_FACTS.hailFraming}
- ${APPROVED_FACTS.insuranceDisclaimer}
- ${APPROVED_FACTS.serviceLife}
Tone: warm, clear, non-pushy. No hype.`;
@@ -0,0 +1,153 @@
/**
* crmHandoff.js — completion handoff to CRM + nurture (spec §11, §13, §17 E2).
*
* The wizard is a self-contained module. On completion it records a CRM-ready
* submission (read by the admin funnel analytics) and the real lead is added to the
* global mock store separately. Submissions are held in memory only — like the rest
* of the CRM demo store, nothing is written to localStorage, so the list resets on a
* full page reload. Also returns the immutable compliance record (spec §18 G1).
*/
// Module-scoped so submissions survive route changes within a session, with no
// persistence (matches the in-memory CRM store).
let submissionsStore = [];
const PKG_LABEL = { GOOD: 'Good', BETTER: 'Better', BEST: 'Best' };
// Parse a free-typed "Street, City, ST 12345" string into CRM lead fields.
function parseAddress(raw) {
const address = (raw || '').trim();
const zip = (address.match(/\b(\d{5})(?:-\d{4})?\b/) || [])[1] || '';
const stateM = address.match(/,\s*([A-Z]{2})\b/) || address.match(/\b([A-Z]{2})\b(?=\s+\d{5})/);
const state = stateM ? stateM[1] : 'TX';
const cityM = address.match(/,\s*([^,]+?)\s*,\s*[A-Z]{2}\b/);
const city = cityM ? cityM[1].trim() : 'Plano';
return { address, city, state, zip };
}
/**
* Map a completed wizard session → the global CRM mock store lead shape, so the
* estimate shows up as a real lead. The caller passes this to MockStore.addLead()
* (addLead injects id / createdAt / status). Pure — no React, no side effects.
*/
export function buildLeadFromSession(session, tenantConfig, reportMeta) {
const i = session.inputs;
const { address, city, state, zip } = parseAddress(i.address);
const nameParts = (i.name || '').trim().split(/\s+/);
const firstName = nameParts[0] || 'Homeowner';
const lastName = nameParts.slice(1).join(' ');
const tier = session.hailSnapshot?.exposureTier || null;
const urgency = tier === 'high' ? 'high' : 'standard';
const priority = tier === 'high' ? 'high' : tier === 'moderate' ? 'medium' : 'low';
const rec = session.recommendation?.selectedPackage;
const sel = session.selectedPackage;
const noteParts = [
'DIY Estimate Wizard self-serve estimate.',
rec ? `Recommended: ${PKG_LABEL[rec] || rec}.` : '',
sel ? `Customer selected: ${PKG_LABEL[sel] || sel}.` : '',
tier ? `Historical hail exposure: ${tier}.` : '',
session.selectedAddons?.length ? `Add-ons: ${session.selectedAddons.join(', ')}.` : '',
reportMeta?.reportId ? `Report ${reportMeta.reportId}.` : '',
].filter(Boolean);
return {
firstName,
lastName,
address,
city,
state,
zip,
lat: session.property?.lat ?? null,
lng: session.property?.lon ?? null,
urgency,
priority,
leadSource: 'Estimate Wizard',
phones: i.phone ? [{ id: '1', number: i.phone, type: 'Mobile', isPrimary: true }] : [],
emails: i.email ? [{ email: i.email, isPrimary: true }] : [],
contactMethod: i.contactMethod,
propertyType: 'Single Family',
propertyPhotos: [],
leadType: 'Retail',
workType: 'Roof Replacement',
tradeType: 'Roofing',
referralNote: '',
canvasserId: '',
canvasserName: '',
assignedTo: '',
followUpDate: '',
isQuickCapture: false,
insuranceCompany: '', claimNumber: '', claimStatus: '',
adjusterName: '', adjusterPhone: '', policyNumber: '',
notes: noteParts.join(' '),
createdByName: tenantConfig?.branding?.company || 'Estimate Wizard',
// Traceability back to the wizard session / report.
estimateWizard: {
sessionId: session.id,
reportId: reportMeta?.reportId || null,
recommendedPackage: rec || null,
selectedPackage: sel || null,
estimatedTotal: reportMeta?.estimatedTotal ?? null,
},
};
}
export function loadSubmissions() {
return submissionsStore;
}
function persist(list) {
submissionsStore = list;
}
/**
* Build the immutable compliance record stored with the report (spec §18 G1):
* assumptions + source data + disclaimer version + package-config version + consent.
*/
export function buildComplianceRecord(session, tenantConfig) {
return {
consent: !!session.inputs.consent,
tenantConfigVersion: tenantConfig.version,
disclaimerVersion: tenantConfig.disclaimerVersion,
scoringModelVersion: session.recommendation?.modelVersion || null,
hailSource: session.hailSnapshot?.source || null,
hailWindowYears: session.hailSnapshot?.windowYears || null,
hailConfidence: session.hailSnapshot?.confidence || null,
assumedSquares: tenantConfig.pricing.assumedSquares,
conservativeMode: tenantConfig.conservativeMode,
aiUsed: session.recommendation?.explanation?.aiUsed ?? false,
generatedAt: new Date().toISOString(),
};
}
/**
* Create a CRM contact/lead payload + nurture trigger from a completed session.
* @returns the submission record (also persisted).
*/
export function submitToCrm(session, tenantConfig, reportMeta) {
const i = session.inputs;
const submission = {
sessionId: session.id,
submittedAt: new Date().toISOString(),
contact: { name: i.name, address: i.address, phone: i.phone, email: i.email, contactMethod: i.contactMethod },
source: session.source,
selectedPackage: session.selectedPackage,
selectedAddons: session.selectedAddons,
recommendedPackage: session.recommendation?.selectedPackage || null,
hail: session.hailSnapshot
? { exposureTier: session.hailSnapshot.exposureTier, p10Base: session.hailSnapshot.probability10yr?.base }
: null,
repBrief: session.recommendation?.explanation?.repBrief || null,
report: reportMeta || null,
crmLeadId: reportMeta?.crmLeadId || null, // link to the global mock-store lead
compliance: buildComplianceRecord(session, tenantConfig),
// Nurture handoff (spec §14): which playbook trigger fired.
nurtureTrigger: 'wizard_completed',
};
const list = loadSubmissions();
list.unshift(submission);
persist(list);
return submission;
}
+65
View File
@@ -0,0 +1,65 @@
/**
* geocode.js — module-local address geocoder for the Estimate Wizard.
*
* Uses OpenStreetMap Nominatim (keyless, CORS-open public service), consistent
* with the repo's existing keyless-public-API approach (IEM / NOAA in the storm
* module). This is NEW module code — it does not modify any existing hail/storm
* module; it only produces the lat/lon those modules' endpoints consume.
*
* Always resolves: on empty input, network failure, or no match it falls back to
* the demo service-area center (Plano, TX) so the wizard never dead-ends. The
* caller is told via `confidence` / `fallback` so wording stays honest (spec §6).
*/
// Demo service-area center — matches the storm module's WFO=FWD coverage.
const FALLBACK = {
lat: 33.0198,
lon: -96.6989,
displayName: 'Plano, TX (approximate area)',
county: 'Collin',
state: 'TX',
confidence: 'low',
fallback: true,
};
const NOMINATIM = 'https://nominatim.openstreetmap.org/search';
export async function geocodeAddress(address) {
const q = (address || '').trim();
if (q.length < 4) return { ...FALLBACK };
try {
const params = new URLSearchParams({
q,
format: 'json',
addressdetails: '1',
limit: '1',
countrycodes: 'us',
});
const res = await fetch(`${NOMINATIM}?${params.toString()}`, {
headers: { Accept: 'application/json' },
});
if (!res.ok) throw new Error(`geocode ${res.status}`);
const data = await res.json();
const hit = Array.isArray(data) ? data[0] : null;
if (!hit) return { ...FALLBACK };
const a = hit.address || {};
// Nominatim "importance" (0..1) is a rough match-quality signal.
const importance = typeof hit.importance === 'number' ? hit.importance : 0.4;
const confidence = importance >= 0.5 ? 'high' : importance >= 0.3 ? 'moderate' : 'low';
return {
lat: parseFloat(hit.lat),
lon: parseFloat(hit.lon),
displayName: hit.display_name || q,
county: (a.county || '').replace(/ County$/i, ''),
state: a.state || a['ISO3166-2-lvl4'] || '',
confidence,
fallback: false,
};
} catch {
// Network/CORS/parse failure → safe fallback, flagged as such.
return { ...FALLBACK };
}
}
@@ -0,0 +1,55 @@
/**
* AddonToggles — optional upgrades as simple toggles (spec §8).
* Plain-language value statements, never fear-based. "Recommended" badge when the
* customer's answers or selected package trigger it. Price/total recompute instantly.
*/
import React from 'react';
import { Plus, Check, Sparkles } from 'lucide-react';
import { formatUSD, isAddonRecommended } from '../engine/pricing';
const AddonToggles = ({ addons, selectedIds, onToggle, packageType, triggers }) => {
return (
<div className="space-y-2.5">
{addons.map((addon) => {
const on = selectedIds.includes(addon.id);
const recommended = isAddonRecommended(addon, packageType, triggers);
return (
<button
key={addon.id}
type="button"
onClick={() => onToggle(addon.id)}
aria-pressed={on}
className={`w-full text-left rounded-2xl border p-4 flex items-start gap-3 transition-all
focus:outline-none focus:ring-2 focus:ring-amber-500
${on
? 'border-amber-500 bg-amber-50 dark:bg-amber-500/10'
: 'border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 hover:border-amber-400'}`}
>
<span className={`mt-0.5 shrink-0 w-6 h-6 rounded-lg flex items-center justify-center border
${on ? 'bg-amber-500 border-amber-500 text-white' : 'border-zinc-300 dark:border-white/20 text-transparent'}`}>
{on ? <Check size={15} /> : <Plus size={15} className="text-zinc-400" />}
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-zinc-900 dark:text-white">{addon.name}</span>
{recommended && (
<span className="inline-flex items-center gap-1 text-[10px] font-bold uppercase tracking-wide text-amber-600 dark:text-amber-400 bg-amber-100 dark:bg-amber-500/15 px-1.5 py-0.5 rounded-full">
<Sparkles size={10} /> Recommended
</span>
)}
</div>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-0.5">{addon.value}</p>
</div>
<span className="text-sm font-semibold text-zinc-700 dark:text-zinc-200 shrink-0">
+{formatUSD(addon.price)}
</span>
</button>
);
})}
</div>
);
};
export default AddonToggles;
@@ -0,0 +1,51 @@
/**
* ChoiceCards — single-select card group (spec §5: cards with 23 answer choices).
* Used by the generic 'cards' step type. Manufacturer-neutral, plain language.
*/
import React from 'react';
import * as Icons from 'lucide-react';
const ChoiceCards = ({ choices = [], value, onChange }) => {
return (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
{choices.map((choice) => {
const selected = value === choice.value;
const Icon = choice.icon ? Icons[choice.icon] : null;
return (
<button
key={choice.value}
type="button"
onClick={() => onChange(choice.value)}
aria-pressed={selected}
className={`
group relative text-left rounded-2xl border p-4 transition-all duration-200
focus:outline-none focus:ring-2 focus:ring-amber-500
${selected
? 'border-amber-500 bg-amber-50 dark:bg-amber-500/10 shadow-md shadow-amber-500/10'
: 'border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 hover:border-amber-400 hover:-translate-y-0.5'
}
`}
>
{Icon && (
<Icon
size={22}
className={`mb-2 ${selected ? 'text-amber-500' : 'text-zinc-400 group-hover:text-amber-500'}`}
/>
)}
<div className={`font-semibold ${selected ? 'text-amber-700 dark:text-amber-300' : 'text-zinc-900 dark:text-white'}`}>
{choice.label}
</div>
{choice.desc && (
<div className="text-sm text-zinc-500 dark:text-zinc-400 mt-0.5">{choice.desc}</div>
)}
{selected && (
<Icons.CheckCircle2 size={18} className="absolute top-3 right-3 text-amber-500" />
)}
</button>
);
})}
</div>
);
};
export default ChoiceCards;
@@ -0,0 +1,86 @@
/**
* GoodBetterBestCards — the three-option presentation (spec §7).
* Rules honored:
* - ALWAYS show all three options (never hide alternatives).
* - Manufacturer-neutral copy (generic categories only).
* - A `highlightedType` may be passed (Phase 4 recommendation) to badge one card,
* but every option stays visible and selectable.
*/
import React from 'react';
import { Check, Shield, Star } from 'lucide-react';
import { pricePackage, formatUSD } from '../engine/pricing';
const ACCENT = {
zinc: { ring: 'ring-zinc-300 dark:ring-zinc-600', text: 'text-zinc-600 dark:text-zinc-300', chip: 'bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-300' },
blue: { ring: 'ring-blue-400', text: 'text-blue-600 dark:text-blue-400', chip: 'bg-blue-50 dark:bg-blue-500/10 text-blue-600 dark:text-blue-400' },
amber: { ring: 'ring-amber-400', text: 'text-amber-600 dark:text-amber-400', chip: 'bg-amber-50 dark:bg-amber-500/10 text-amber-600 dark:text-amber-400' },
};
const TIER_LABEL = { GOOD: 'Good', BETTER: 'Better', BEST: 'Best' };
const GoodBetterBestCards = ({ packages, pricing, selectedType, onSelect, highlightedType }) => {
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{packages.map((pkg) => {
const a = ACCENT[pkg.accent] || ACCENT.zinc;
const price = pricePackage(pkg, pricing);
const selected = selectedType === pkg.type;
const highlighted = highlightedType === pkg.type;
return (
<button
key={pkg.type}
type="button"
onClick={() => onSelect(pkg.type)}
aria-pressed={selected}
className={`relative text-left rounded-2xl border bg-white dark:bg-zinc-900 p-5 transition-all duration-200
focus:outline-none flex flex-col
${selected
? `border-transparent ring-2 ${a.ring} shadow-lg`
: 'border-zinc-200 dark:border-white/10 hover:-translate-y-0.5 hover:shadow-md'}`}
>
{highlighted && (
<span className="absolute -top-2.5 left-1/2 -translate-x-1/2 inline-flex items-center gap-1 rounded-full bg-gradient-to-r from-amber-400 to-orange-500 text-white text-[11px] font-bold px-2.5 py-0.5 shadow">
<Star size={11} /> Recommended
</span>
)}
<div className="flex items-center justify-between">
<span className={`text-xs font-bold uppercase tracking-wider ${a.text}`}>{TIER_LABEL[pkg.type]}</span>
{selected && <Check size={18} className={a.text} />}
</div>
<h3 className="font-bold text-zinc-900 dark:text-white mt-1">{pkg.name}</h3>
<p className="text-xs text-zinc-500 dark:text-zinc-400 mt-0.5">{pkg.category}</p>
<div className="mt-3">
<div className="text-2xl font-bold text-zinc-900 dark:text-white">
{formatUSD(price.low)}{formatUSD(price.high)}
</div>
<div className="text-[11px] text-zinc-400">preliminary range · verified at inspection</div>
</div>
<div className="flex flex-wrap gap-1.5 mt-3">
<span className={`text-[11px] font-medium px-2 py-0.5 rounded-full ${a.chip}`}>{pkg.warrantyLabel}</span>
<span className="text-[11px] font-medium px-2 py-0.5 rounded-full bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-300 inline-flex items-center gap-1">
<Shield size={10} /> {pkg.hailResistance} hail resistance
</span>
</div>
<ul className="mt-3 space-y-1.5 flex-1">
{pkg.scope.map((s) => (
<li key={s} className="flex items-start gap-1.5 text-xs text-zinc-600 dark:text-zinc-300">
<Check size={13} className={`mt-0.5 shrink-0 ${a.text}`} /> {s}
</li>
))}
</ul>
<p className="text-[11px] text-zinc-400 mt-3 italic">{pkg.position}</p>
</button>
);
})}
</div>
);
};
export default GoodBetterBestCards;
@@ -0,0 +1,83 @@
/**
* HailRiskSnapshot — presentational hail-risk card (spec §6).
* Strictly historical framing + mandatory confidence/source/disclaimer.
* Never says "your home will get hit" — only area-level historical exposure.
*/
import React from 'react';
import { CloudHail, TrendingUp, ShieldQuestion, Clock, Info } from 'lucide-react';
const tierStyle = {
high: { text: 'text-red-600 dark:text-red-400', bg: 'bg-red-50 dark:bg-red-500/10', border: 'border-red-200 dark:border-red-500/20', label: 'Higher historical exposure' },
moderate: { text: 'text-orange-600 dark:text-orange-400', bg: 'bg-orange-50 dark:bg-orange-500/10', border: 'border-orange-200 dark:border-orange-500/20', label: 'Moderate historical exposure' },
low: { text: 'text-emerald-600 dark:text-emerald-400', bg: 'bg-emerald-50 dark:bg-emerald-500/10', border: 'border-emerald-200 dark:border-emerald-500/20', label: 'Lower historical exposure' },
};
const Metric = ({ icon: Icon, label, value, sub }) => (
<div className="rounded-xl border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 p-3.5">
<div className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-zinc-400">
{Icon && <Icon size={13} />} {label}
</div>
<div className="text-lg font-bold text-zinc-900 dark:text-white mt-1">{value}</div>
{sub && <div className="text-xs text-zinc-500 dark:text-zinc-400 mt-0.5">{sub}</div>}
</div>
);
const HailRiskSnapshot = ({ snapshot }) => {
if (!snapshot) return null;
const tier = tierStyle[snapshot.exposureTier] || tierStyle.low;
const p = snapshot.probability10yr;
return (
<div className="space-y-4">
{/* Exposure banner */}
<div className={`rounded-2xl border ${tier.border} ${tier.bg} p-4 flex items-start gap-3`}>
<CloudHail size={24} className={tier.text} />
<div>
<div className={`font-bold ${tier.text}`}>{tier.label}</div>
<p className="text-sm text-zinc-600 dark:text-zinc-300 mt-0.5">{snapshot.wording.probability}</p>
</div>
</div>
{/* Metrics */}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
<Metric
icon={TrendingUp}
label="10-yr probability"
value={snapshot.eventCount === 0 ? '—' : `${p.low}${p.high}%`}
sub={`Base ${p.base}%`}
/>
<Metric
icon={CloudHail}
label="Annualized rate"
value={`${snapshot.annualRate}/yr`}
sub={`${snapshot.eventCount} events / ${snapshot.windowYears} yrs`}
/>
<Metric
icon={ShieldQuestion}
label="Confidence"
value={snapshot.confidence}
sub={`~${snapshot.radiusMiles} mi radius`}
/>
</div>
{/* Source + freshness */}
<div className="rounded-xl bg-zinc-50 dark:bg-white/5 p-3.5 space-y-1.5 text-xs text-zinc-500 dark:text-zinc-400">
<div className="flex items-center gap-1.5">
<Info size={13} /> Source: {snapshot.source}
</div>
<div className="flex items-center gap-1.5">
<Clock size={13} /> {snapshot.wording.lastUpdated}
</div>
<div>{snapshot.wording.confidence}</div>
</div>
{/* Trust rule (spec §6): historical, not a forecast */}
<p className="text-xs text-zinc-400 leading-relaxed">
This is a historical hazard estimate for your <span className="font-medium">area</span> not a forecast or
guarantee for your specific home. Final assessment requires an on-site inspection.
</p>
</div>
);
};
export default HailRiskSnapshot;
@@ -0,0 +1,48 @@
/**
* PropertyMap — small Leaflet preview of the geocoded property + risk radius.
* Uses the same default-marker-icon fix the rest of the app uses (Maps.jsx).
*/
import React from 'react';
import { MapContainer, TileLayer, Marker, Circle } from 'react-leaflet';
import 'leaflet/dist/leaflet.css';
import L from 'leaflet';
import icon from 'leaflet/dist/images/marker-icon.png';
import iconShadow from 'leaflet/dist/images/marker-shadow.png';
const DefaultIcon = L.icon({
iconUrl: icon,
shadowUrl: iconShadow,
iconSize: [25, 41],
iconAnchor: [12, 41],
});
L.Marker.prototype.options.icon = DefaultIcon;
const PropertyMap = ({ lat, lon, radiusMiles = 20, height = 220 }) => {
if (lat == null || lon == null) return null;
const center = [lat, lon];
return (
<div className="rounded-2xl overflow-hidden border border-zinc-200 dark:border-white/10" style={{ height }}>
<MapContainer
key={`${lat},${lon}`}
center={center}
zoom={11}
scrollWheelZoom={false}
dragging={false}
doubleClickZoom={false}
zoomControl={false}
attributionControl={false}
style={{ height: '100%', width: '100%' }}
>
<TileLayer url="https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png" />
<Circle
center={center}
radius={radiusMiles * 1609.34}
pathOptions={{ color: '#fda913', fillColor: '#fda913', fillOpacity: 0.08, weight: 1.5 }}
/>
<Marker position={center} />
</MapContainer>
</div>
);
};
export default PropertyMap;
@@ -0,0 +1,128 @@
/**
* TenYearValueChart — 10-year value comparison panel (spec §10).
*
* Honors the full §10 brief:
* - shows ASSUMPTIONS per tier (discount %, service life, hail positioning, value view)
* - lets the customer TOGGLE discount scenarios (low / base / high)
* - separates upfront cost from lifecycle value (breakeven + lifecycle delta)
* - never claims a guaranteed discount; all savings labeled illustrative
*
* Savings/breakeven/lifecycle only render when a premium was provided and
* conservative mode is off; otherwise the panel falls back to assumptions only.
*/
import React, { useState } from 'react';
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Legend, Cell } from 'recharts';
const PKG_LABEL = { GOOD: 'Good', BETTER: 'Better', BEST: 'Best' };
const BAR_COLOR = { GOOD: '#a1a1aa', BETTER: '#3b82f6', BEST: '#fda913' };
const SCENARIOS = [
{ key: 'low', label: 'Low' },
{ key: 'base', label: 'Base' },
{ key: 'high', label: 'High' },
];
const usd = (n) => `$${Number(n).toLocaleString('en-US')}`;
const TenYearValueChart = ({ valueModel }) => {
const [scenario, setScenario] = useState('base');
const showSavings = valueModel.premiumKnown && !valueModel.conservativeMode;
const data = valueModel.rows.map((r) => ({
name: PKG_LABEL[r.type],
type: r.type,
Upfront: r.price.base,
'Est. 10-yr savings': r.savings ? r.savings[scenario] : 0,
}));
const fmtK = (n) => `$${(n / 1000).toFixed(0)}k`;
return (
<div className="rounded-2xl border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 p-4">
<div className="flex items-center justify-between gap-2 mb-3 flex-wrap">
<h4 className="font-semibold text-zinc-900 dark:text-white text-sm">10-year value comparison</h4>
{showSavings && (
<div className="flex items-center gap-1">
<span className="text-[11px] text-zinc-400 mr-1">Discount scenario</span>
{SCENARIOS.map((s) => (
<button
key={s.key}
type="button"
onClick={() => setScenario(s.key)}
className={`px-2.5 py-1 rounded-lg text-[11px] font-semibold transition-all
${scenario === s.key
? 'bg-amber-500 text-white shadow'
: 'bg-zinc-100 dark:bg-white/5 text-zinc-500 dark:text-zinc-400 hover:text-amber-600'}`}
>
{s.label}
</button>
))}
</div>
)}
</div>
{/* Upfront vs illustrative savings */}
<div style={{ width: '100%', height: 200 }}>
<ResponsiveContainer>
<BarChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
<XAxis dataKey="name" tick={{ fontSize: 12 }} stroke="#a1a1aa" />
<YAxis tickFormatter={fmtK} tick={{ fontSize: 11 }} stroke="#a1a1aa" width={44} />
<Tooltip
formatter={(v) => usd(v)}
contentStyle={{ borderRadius: 12, border: '1px solid #e4e4e7', fontSize: 12 }}
/>
<Legend wrapperStyle={{ fontSize: 12 }} />
<Bar dataKey="Upfront" radius={[4, 4, 0, 0]}>
{data.map((d) => <Cell key={d.type} fill={BAR_COLOR[d.type]} fillOpacity={0.85} />)}
</Bar>
{showSavings && (
<Bar dataKey="Est. 10-yr savings" radius={[4, 4, 0, 0]} fill="#22c55e" fillOpacity={0.8} />
)}
</BarChart>
</ResponsiveContainer>
</div>
{/* Assumptions + lifecycle metrics per tier */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2.5 mt-4">
{valueModel.rows.map((r) => (
<div key={r.type} className="rounded-xl border border-zinc-200 dark:border-white/10 p-3">
<div className="text-xs font-bold uppercase tracking-wide text-zinc-500 dark:text-zinc-400 mb-2">{PKG_LABEL[r.type]}</div>
<Metric label="Upfront (base)" value={usd(r.price.base)} />
<Metric label="Discount assumption" value={`${r.discountScenario[scenario] ?? 0}%`} />
<Metric label="Service life" value={`${r.serviceLifeYears}+ yrs`} />
<Metric label="Hail resistance" value={r.hailResistance} />
<Metric label="10-yr value view" value={r.valueView} muted />
{showSavings && (
<>
<div className="border-t border-zinc-100 dark:border-white/5 my-2" />
<Metric label="Est. 10-yr savings" value={r.savings ? usd(r.savings[scenario]) : '—'} accent />
{/* Breakeven only when pricing AND discount known (spec §10 display rule). */}
<Metric label="Upgrade breakeven" value={r.breakeven[scenario] != null ? `${r.breakeven[scenario]} yrs` : '—'} />
<Metric label="Lifecycle value delta" value={usd(r.lifecycleDelta[scenario])} hint="model estimate" />
</>
)}
</div>
))}
</div>
<p className="text-[11px] text-zinc-400 mt-3 leading-relaxed">
{showSavings
? 'Savings scenarios are illustrative model estimates — not guaranteed. They depend on carrier approval, policy terms, state rules, and documentation. Lifecycle value delta is a model estimate, not a promise.'
: valueModel.conservativeMode
? 'Savings scenarios are hidden. Figures show upfront cost and durability assumptions only.'
: 'Add your insurance premium earlier in the wizard to see illustrative 10-year savings, breakeven, and lifecycle value scenarios.'}
</p>
</div>
);
};
const Metric = ({ label, value, accent, muted, hint }) => (
<div className="flex items-baseline justify-between gap-2 py-0.5">
<span className="text-[11px] text-zinc-400">{label}</span>
<span className={`text-xs font-semibold text-right ${accent ? 'text-emerald-600 dark:text-emerald-400' : muted ? 'text-zinc-500 dark:text-zinc-400' : 'text-zinc-800 dark:text-zinc-100'}`}>
{value}
{hint && <span className="block text-[9px] font-normal text-zinc-400 italic">{hint}</span>}
</span>
</div>
);
export default TenYearValueChart;
@@ -0,0 +1,26 @@
/**
* WizardProgress — slim progress bar + step counter (spec §4: "keep the customer moving").
*/
import React from 'react';
const WizardProgress = ({ stepIndex, totalSteps }) => {
const pct = Math.round(((stepIndex + 1) / totalSteps) * 100);
return (
<div className="w-full">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-semibold tracking-wider uppercase text-zinc-400">
Step {stepIndex + 1} of {totalSteps}
</span>
<span className="text-xs font-semibold text-amber-500">{pct}%</span>
</div>
<div className="h-1.5 w-full rounded-full bg-zinc-200 dark:bg-zinc-800 overflow-hidden">
<div
className="h-full rounded-full bg-gradient-to-r from-amber-400 to-orange-500 transition-all duration-500 ease-out"
style={{ width: `${pct}%` }}
/>
</div>
</div>
);
};
export default WizardProgress;
@@ -0,0 +1,193 @@
/**
* WizardShell — the one-question-per-screen engine (spec §4, §5, §17 EPIC A2).
*
* Responsibilities:
* - render the current step (custom component | cards | slider)
* - gate Continue on required answers; allow explicit Skip where permitted
* - Back/Continue navigation, completion on the final step
* - mobile-first single-CTA layout (spec §4: "one primary CTA per screen")
*/
import React from 'react';
import { ArrowLeft, ArrowRight, RotateCcw } from 'lucide-react';
import { useEstimateWizard } from '../EstimateWizardContext';
import WizardProgress from './WizardProgress';
import ChoiceCards from './ChoiceCards';
import StartStep from './steps/StartStep';
import AddressStep from './steps/AddressStep';
import PremiumStep from './steps/PremiumStep';
import HailRiskStep from './steps/HailRiskStep';
import OptionsStep from './steps/OptionsStep';
import AddonsStep from './steps/AddonsStep';
import RecommendationStep from './steps/RecommendationStep';
import ReportStep from './steps/ReportStep';
// Step registry — every step now has a real component.
const STEP_COMPONENTS = {
start: StartStep,
address: AddressStep,
premium: PremiumStep,
hailRisk: HailRiskStep,
options: OptionsStep,
addons: AddonsStep,
recommendation: RecommendationStep,
report: ReportStep,
};
// Validation per step: returns true when the user may advance.
function canAdvance(step, inputs, session) {
if (step.id === 'start') return inputs.consent && inputs.name?.trim() && inputs.address?.trim();
if (step.id === 'address') return !!inputs.address?.trim();
if (step.id === 'options') return !!session.selectedPackage;
if (step.type === 'cards' && step.required) return inputs[step.inputKey] != null;
if (step.type === 'slider' && step.required) return inputs[step.inputKey] != null;
return true;
}
const SliderStep = ({ step }) => {
const { inputs, setInput } = useEstimateWizard();
const val = inputs[step.inputKey] ?? step.min;
return (
<div className="space-y-6 pt-2">
<div className="text-center">
<span className="text-5xl font-bold text-amber-500">{val}</span>
<span className="text-lg text-zinc-400 ml-1">
{val >= step.max ? `+ ${step.unit}` : step.unit}
</span>
</div>
<input
type="range"
min={step.min}
max={step.max}
step={step.step}
value={val}
onChange={(e) => setInput(step.inputKey, Number(e.target.value))}
className="w-full accent-amber-500"
/>
<div className="flex justify-between text-xs text-zinc-400">
<span>{step.min} {step.unit}</span>
<span>{Math.round((step.min + step.max) / 2)} {step.unit}</span>
<span>{step.maxLabel}</span>
</div>
</div>
);
};
const WizardShell = () => {
const {
currentStep: step, stepIndex, totalSteps, inputs,
setInput, next, back, complete, reset, resumed, session,
} = useEstimateWizard();
const isLast = stepIndex === totalSteps - 1;
const advance = canAdvance(step, inputs, session);
const renderBody = () => {
if (step.type === 'cards') {
return (
<ChoiceCards
choices={step.choices}
value={inputs[step.inputKey]}
onChange={(v) => setInput(step.inputKey, v)}
/>
);
}
if (step.type === 'slider') return <SliderStep step={step} />;
const Comp = STEP_COMPONENTS[step.id];
return Comp ? <Comp /> : null;
};
const handleContinue = () => {
if (isLast) { complete(); return; }
next();
};
return (
<div className="min-h-[100dvh] w-full bg-zinc-50 dark:bg-[#09090b] flex flex-col">
{/* Top bar */}
<header className="w-full border-b border-zinc-200 dark:border-white/5 bg-white/70 dark:bg-zinc-900/60 backdrop-blur-md">
<div className="max-w-2xl mx-auto px-4 py-3 sm:py-4">
<div className="flex items-center justify-between gap-3 mb-3">
<span className="font-bold tracking-tight text-zinc-900 dark:text-white truncate">
LynkedUp<span className="text-amber-500">Pro</span>
<span className="ml-2 text-xs font-medium text-zinc-400 hidden xs:inline">Roof Options</span>
</span>
<button
onClick={reset}
title="Start over"
className="text-xs text-zinc-400 hover:text-amber-500 flex items-center gap-1"
>
<RotateCcw size={13} /> Start over
</button>
</div>
<WizardProgress stepIndex={stepIndex} totalSteps={totalSteps} />
</div>
</header>
{/* Resume hint */}
{resumed && session.status !== 'completed' && stepIndex === 0 && (
<div className="max-w-2xl mx-auto w-full px-4 pt-3">
<div className="rounded-xl bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/20 px-3 py-2 text-xs text-amber-700 dark:text-amber-300">
Welcome back we saved your progress. You can continue or start over.
</div>
</div>
)}
{/* Body */}
<main className="flex-1 w-full">
<div className="max-w-2xl mx-auto px-4 py-6 sm:py-8">
{/* key forces remount per step → re-triggers the enter animation */}
<div key={step.id} className="animate-in fade-in slide-in-from-right-4 duration-300">
<h1 className="text-2xl md:text-3xl font-bold text-zinc-900 dark:text-white tracking-tight">
{step.title}
</h1>
{step.subtitle && (
<p className="text-zinc-500 dark:text-zinc-400 mt-1.5 mb-6">{step.subtitle}</p>
)}
<div className="mt-6">{renderBody()}</div>
</div>
</div>
</main>
{/* Footer nav — one primary CTA. Respects the mobile safe-area inset so the
CTA clears the iPhone home indicator. */}
<footer
className="sticky bottom-0 w-full border-t border-zinc-200 dark:border-white/5 bg-white/80 dark:bg-zinc-900/70 backdrop-blur-md"
style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}
>
<div className="max-w-2xl mx-auto px-4 py-3 flex items-center gap-2 sm:gap-3">
{stepIndex > 0 && (
<button
onClick={back}
className="shrink-0 rounded-xl border border-zinc-200 dark:border-white/10 px-3 sm:px-4 py-2.5 text-sm font-medium text-zinc-600 dark:text-zinc-300 hover:border-amber-400 flex items-center gap-1.5"
>
<ArrowLeft size={16} /> <span className="hidden xs:inline">Back</span>
</button>
)}
{step.skippable && !isLast && (
<button
onClick={next}
className="shrink-0 text-sm font-medium text-zinc-400 hover:text-amber-500 px-1.5 sm:px-2 py-2.5"
>
Skip
</button>
)}
<button
onClick={handleContinue}
disabled={!advance}
className={`ml-auto min-w-0 rounded-xl px-5 sm:px-6 py-2.5 text-sm font-semibold flex items-center justify-center gap-1.5 whitespace-nowrap transition-all
${advance
? 'bg-gradient-to-r from-amber-400 to-orange-500 text-white shadow-lg shadow-amber-500/25 hover:shadow-amber-500/40'
: 'bg-zinc-200 dark:bg-zinc-800 text-zinc-400 cursor-not-allowed'}`}
>
{isLast ? 'Finish' : stepIndex === 0 ? 'Start estimate' : 'Continue'}
{!isLast && <ArrowRight size={16} className="shrink-0" />}
</button>
</div>
</footer>
</div>
);
};
export default WizardShell;
@@ -0,0 +1,80 @@
/**
* AddonsStep — Screen 7b "Add-ons" (spec §8).
* Add-ons pre-select from the customer's answers + chosen package (once), then the
* customer can toggle freely. Running total recomputes instantly.
*/
import React, { useEffect, useMemo } from 'react';
import { useEstimateWizard } from '../../EstimateWizardContext';
import AddonToggles from '../AddonToggles';
import {
pricePackage, addonsTotal, deriveAddonTriggers, defaultSelectedAddons, formatUSD,
} from '../../engine/pricing';
const AddonsStep = () => {
const { inputs, session, patchSession, tenantConfig, logEvent } = useEstimateWizard();
const { addons, packages, pricing } = tenantConfig;
const packageType = session.selectedPackage || 'BETTER';
const triggers = useMemo(() => deriveAddonTriggers(inputs, session.hailSnapshot), [inputs, session.hailSnapshot]);
// Pre-select recommended add-ons once (spec §8 default-selected rules).
useEffect(() => {
if (session.addonsInitialized) return;
patchSession({
selectedAddons: defaultSelectedAddons(addons, packageType, triggers),
addonsInitialized: true,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [session.addonsInitialized]);
const selectedIds = session.selectedAddons || [];
const handleToggle = (id) => {
const next = selectedIds.includes(id)
? selectedIds.filter((x) => x !== id)
: [...selectedIds, id];
patchSession({ selectedAddons: next });
logEvent('addon_toggled', { id, on: !selectedIds.includes(id) });
};
const pkg = packages.find((p) => p.type === packageType);
const base = pkg ? pricePackage(pkg, pricing).base : 0;
const addonsSum = addonsTotal(addons, selectedIds);
const total = base + addonsSum;
return (
<div className="space-y-4">
<AddonToggles
addons={addons}
selectedIds={selectedIds}
onToggle={handleToggle}
packageType={packageType}
triggers={triggers}
/>
{/* Running total for the selected package. Not sticky — the wizard's
footer CTA is already pinned, and two stacked sticky bars overlap on
mobile. */}
<div className="rounded-2xl border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 p-4 shadow-sm">
<div className="flex items-center justify-between text-sm text-zinc-500 dark:text-zinc-400">
<span>{pkg?.name} (base estimate)</span>
<span>{formatUSD(base)}</span>
</div>
<div className="flex items-center justify-between text-sm text-zinc-500 dark:text-zinc-400 mt-1">
<span>Selected add-ons ({selectedIds.length})</span>
<span>+{formatUSD(addonsSum)}</span>
</div>
<div className="h-px bg-zinc-200 dark:bg-white/10 my-2.5" />
<div className="flex items-center justify-between font-bold text-zinc-900 dark:text-white">
<span>Estimated total</span>
<span>~{formatUSD(total)}</span>
</div>
<p className="text-[11px] text-zinc-400 mt-1.5">
Preliminary verified at inspection. Add-ons can be revised with your rep.
</p>
</div>
</div>
);
};
export default AddonsStep;
@@ -0,0 +1,83 @@
/**
* AddressStep — Screen 2 "Property validation" (spec §4).
* Geocodes the address (module-local Nominatim geocoder) and shows a Leaflet map
* preview, populating property_profiles (spec §12). Hail history is pulled in the
* next step from the existing /api/storm-history endpoint.
*/
import React, { useState } from 'react';
import { MapPin, Loader2, CheckCircle2, Search } from 'lucide-react';
import { useEstimateWizard } from '../../EstimateWizardContext';
import { geocodeAddress } from '../../api/geocode';
import PropertyMap from '../PropertyMap';
const AddressStep = () => {
const { inputs, setInput, session, patchSession, logEvent } = useEstimateWizard();
const [locating, setLocating] = useState(false);
const property = session.property;
const handleLocate = async () => {
setLocating(true);
try {
const prop = await geocodeAddress(inputs.address);
patchSession({ property: prop, hailSnapshot: null }); // re-locating invalidates a stale snapshot
logEvent('address_geocoded', { confidence: prop.confidence, fallback: prop.fallback });
} finally {
setLocating(false);
}
};
return (
<div className="space-y-4">
<label className="block">
<span className="text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-1.5 flex items-center gap-1.5">
<MapPin size={15} className="text-amber-500" /> Confirm your property address
</span>
<div className="flex gap-2">
<input
className="flex-1 rounded-xl border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 px-3.5 py-2.5 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-amber-500"
placeholder="Street, city, state, ZIP"
value={inputs.address}
onChange={(e) => { setInput('address', e.target.value); if (property) patchSession({ property: null, hailSnapshot: null }); }}
autoComplete="street-address"
/>
<button
type="button"
onClick={handleLocate}
disabled={locating || !inputs.address?.trim()}
className="rounded-xl bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 px-4 py-2.5 text-sm font-semibold flex items-center gap-1.5 disabled:opacity-40"
>
{locating ? <Loader2 size={16} className="animate-spin" /> : <Search size={16} />}
Locate
</button>
</div>
</label>
{property ? (
<>
<PropertyMap lat={property.lat} lon={property.lon} radiusMiles={20} />
<div className="flex items-start gap-2 text-sm">
<CheckCircle2 size={16} className="text-emerald-500 mt-0.5 shrink-0" />
<span className="text-zinc-600 dark:text-zinc-300">
{property.fallback
? 'We placed an approximate pin for your area. Your rep will confirm the exact location.'
: <>Located: <span className="font-medium">{property.displayName}</span></>}
</span>
</div>
</>
) : (
<div className="rounded-2xl border border-dashed border-zinc-300 dark:border-white/10 bg-zinc-50 dark:bg-white/5 p-6 text-center">
<MapPin size={26} className="text-zinc-400 mx-auto mb-2" />
<p className="text-sm text-zinc-500 dark:text-zinc-400">
Tap <span className="font-medium">Locate</span> to confirm your home on the map and pull local storm history.
</p>
</div>
)}
<p className="text-xs text-zinc-400">
Final roof measurements are validated during your inspection nothing here is binding.
</p>
</div>
);
};
export default AddressStep;
@@ -0,0 +1,83 @@
/**
* HailRiskStep — Screen 6 "Hail risk" (spec §6).
* Runs geocode → /api/storm-history → Poisson model, freezes the snapshot into
* the session (spec §12), and renders the historical-only risk card + map.
*/
import React, { useEffect } from 'react';
import { Loader2, AlertCircle } from 'lucide-react';
import { useEstimateWizard } from '../../EstimateWizardContext';
import { useWizardHailRisk } from '../../hooks/useWizardHailRisk';
import PropertyMap from '../PropertyMap';
import HailRiskSnapshot from '../HailRiskSnapshot';
const HailRiskStep = () => {
const { inputs, session, patchSession, tenantConfig, logEvent } = useEstimateWizard();
// Reuse a previously frozen snapshot if the user navigates back/forward.
const alreadyHave = !!session.hailSnapshot;
const { loading, error, property, snapshot } = useWizardHailRisk({
address: inputs.address,
property: session.property,
enabled: !alreadyHave,
stormSettings: tenantConfig.stormSettings,
});
// Freeze property + snapshot into the session once resolved.
useEffect(() => {
if (alreadyHave) return;
if (snapshot && property) {
patchSession({ property, hailSnapshot: snapshot });
logEvent('hail_snapshot_generated', {
exposureTier: snapshot.exposureTier,
p10Base: snapshot.probability10yr.base,
confidence: snapshot.confidence,
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [snapshot, property, alreadyHave]);
const shownProperty = session.property || property;
const shownSnapshot = session.hailSnapshot || snapshot;
if (loading && !shownSnapshot) {
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Loader2 size={28} className="text-amber-500 animate-spin mb-3" />
<p className="text-sm text-zinc-500 dark:text-zinc-400">
Checking historical storm reports for your area
</p>
</div>
);
}
if (error && !shownSnapshot) {
return (
<div className="rounded-2xl border border-red-200 dark:border-red-500/20 bg-red-50 dark:bg-red-500/10 p-4 flex items-start gap-3">
<AlertCircle size={20} className="text-red-500 shrink-0" />
<div>
<p className="font-semibold text-red-700 dark:text-red-300">Storm history unavailable</p>
<p className="text-sm text-red-600 dark:text-red-400 mt-0.5">
We couldnt load storm data right now your rep will review local hail history during your visit.
You can still continue.
</p>
</div>
</div>
);
}
return (
<div className="space-y-4">
{shownProperty && (
<PropertyMap
lat={shownProperty.lat}
lon={shownProperty.lon}
radiusMiles={shownSnapshot?.radiusMiles ?? 20}
/>
)}
<HailRiskSnapshot snapshot={shownSnapshot} />
</div>
);
};
export default HailRiskStep;
@@ -0,0 +1,37 @@
/**
* OptionsStep — Screen 7 "Build options" (spec §7).
* Shows all three Good/Better/Best options; customer selects one. The AI highlight
* (Phase 4) will badge a recommended option without hiding the others.
*/
import React from 'react';
import { Info } from 'lucide-react';
import { useEstimateWizard } from '../../EstimateWizardContext';
import GoodBetterBestCards from '../GoodBetterBestCards';
const OptionsStep = () => {
const { session, patchSession, tenantConfig, logEvent } = useEstimateWizard();
const handleSelect = (type) => {
patchSession({ selectedPackage: type });
logEvent('package_selected', { package: type });
};
return (
<div className="space-y-4">
<GoodBetterBestCards
packages={tenantConfig.packages}
pricing={tenantConfig.pricing}
selectedType={session.selectedPackage}
highlightedType={session.recommendation?.selectedPackage}
onSelect={handleSelect}
/>
<div className="rounded-xl bg-zinc-50 dark:bg-white/5 p-3.5 flex items-start gap-2 text-xs text-zinc-500 dark:text-zinc-400">
<Info size={14} className="mt-0.5 shrink-0" />
These are preliminary ranges based on an assumed roof size. Final pricing requires a roof
measurement and inspection your rep will confirm everything. This is not a binding quote.
</div>
</div>
);
};
export default OptionsStep;
@@ -0,0 +1,31 @@
/**
* PlaceholderStep — temporary stand-in for steps delivered in later phases.
* Keeps the full §4 journey navigable end-to-end during Phase 1 so the flow,
* autosave, and progress can be demoed. Each phase replaces its entry in the
* WizardShell step registry with the real component.
*/
import React from 'react';
import { Hammer } from 'lucide-react';
const PHASE_BY_STEP = {
hailRisk: 'Phase 2 — Storm/Hail Risk Intelligence (§6)',
options: 'Phase 3 — Good / Better / Best options (§7)',
addons: 'Phase 3 — Add-ons & upgrades (§8)',
recommendation: 'Phase 4 — AI recommendation + 10-year value (§9, §10)',
report: 'Phase 5 — Customer report, CRM handoff (§11)',
};
const PlaceholderStep = ({ stepId }) => (
<div className="rounded-2xl border border-dashed border-zinc-300 dark:border-white/10 bg-zinc-50 dark:bg-white/5 p-8 flex flex-col items-center justify-center text-center">
<Hammer size={30} className="text-amber-500 mb-3" />
<p className="font-semibold text-zinc-700 dark:text-zinc-200">Coming next</p>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
{PHASE_BY_STEP[stepId] || 'This screen is delivered in a later phase.'}
</p>
<p className="text-xs text-zinc-400 mt-3">
The journey, autosave, and progress are fully wired Continue to walk the full flow.
</p>
</div>
);
export default PlaceholderStep;
@@ -0,0 +1,57 @@
/**
* PremiumStep — annual insurance premium (spec §5).
* Optional: amount / "I don't know" / skip. Enables the 10-year savings scenario
* (Phase 4). Never required — skipping must not block the wizard (spec §18).
*/
import React from 'react';
import { DollarSign } from 'lucide-react';
import { useEstimateWizard } from '../../EstimateWizardContext';
const PremiumStep = () => {
const { inputs, setInput } = useEstimateWizard();
const unknown = inputs.premiumUnknown;
return (
<div className="space-y-4">
<div className={`transition-opacity ${unknown ? 'opacity-40 pointer-events-none' : ''}`}>
<span className="text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-1.5 flex items-center gap-1.5">
<DollarSign size={15} className="text-amber-500" /> Approximate annual premium
</span>
<div className="relative">
<span className="absolute left-3.5 top-1/2 -translate-y-1/2 text-zinc-400">$</span>
<input
type="number"
min="0"
inputMode="numeric"
className="w-full rounded-xl border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 pl-7 pr-3.5 py-2.5 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-amber-500"
placeholder="e.g. 4,000"
value={inputs.premiumAmount ?? ''}
onChange={(e) => setInput('premiumAmount', e.target.value === '' ? null : Number(e.target.value))}
/>
</div>
</div>
<button
type="button"
onClick={() => {
const nextUnknown = !unknown;
setInput('premiumUnknown', nextUnknown);
if (nextUnknown) setInput('premiumAmount', null);
}}
className={`w-full rounded-xl border px-3 py-2.5 text-sm font-medium transition-all
${unknown
? 'border-amber-500 bg-amber-50 dark:bg-amber-500/10 text-amber-700 dark:text-amber-300'
: 'border-zinc-200 dark:border-white/10 text-zinc-600 dark:text-zinc-300 hover:border-amber-400'}`}
>
I dont know my premium
</button>
<p className="text-xs text-zinc-400">
This is only used to illustrate a potential savings scenario. Any savings must be confirmed with your
insurance carrier we never guarantee a discount.
</p>
</div>
);
};
export default PremiumStep;
@@ -0,0 +1,192 @@
/**
* RecommendationStep — Screen 8 "AI recommendation" (spec §9, §10).
*
* Flow:
* 1. Deterministic scoring engine picks the highlight + reasons (logged).
* 2. 10-year value model computes the savings/lifecycle comparison.
* 3. AI turns the reasons into plain-language copy (guardrailed); falls back to
* deterministic text if AI is unavailable or trips a banned-claim check.
* The recommendation record is frozen into the session (spec §12 recommendations).
*/
import React, { useEffect, useMemo, useState } from 'react';
import { Sparkles, Loader2, Check, ShieldCheck, Info } from 'lucide-react';
import { useEstimateWizard } from '../../EstimateWizardContext';
import { score } from '../../engine/scoringEngine';
import { computeTenYearValue } from '../../engine/valueModel';
import { generateExplanation } from '../../ai/explain';
import { formatUSD } from '../../engine/pricing';
import GoodBetterBestCards from '../GoodBetterBestCards';
import TenYearValueChart from '../TenYearValueChart';
const PKG_LABEL = { GOOD: 'Good', BETTER: 'Better', BEST: 'Best' };
/**
* Deterministic, manufacturer-neutral description for a package the customer
* clicked that is NOT the recommended highlight. Built purely from the tenant
* config package fields (+ the value model for the savings line) — no AI call,
* so it's instant and always on-message. Mirrors the guardrailed tone of the
* recommendation copy ("illustrative", "confirm with your carrier").
*/
function describePackage(pkg, valueModel) {
const label = PKG_LABEL[pkg.type];
const row = valueModel?.rows?.find((r) => r.type === pkg.type);
const paragraph =
`The ${label} option — ${pkg.name} — is our "${(pkg.position || '').toLowerCase()}" choice` +
`${pkg.fit ? `. It tends to fit best when: ${pkg.fit}` : '.'}`;
const bullets = [];
if (pkg.warrantyLabel) bullets.push(pkg.warrantyLabel);
if (pkg.serviceLifeYears) bullets.push(`~${pkg.serviceLifeYears}-year expected service life`);
if (pkg.hailResistance) bullets.push(`${pkg.hailResistance} hail resistance`);
if (valueModel?.premiumKnown && row?.savings) {
bullets.push(
`Illustrative 10-yr insurance savings ${formatUSD(row.savings.low)}${formatUSD(row.savings.high)} — confirm with your carrier`
);
} else if (pkg.insuranceDiscountPct > 0) {
bullets.push('May qualify for an insurance discount — confirm with your carrier');
}
return { paragraph, bullets };
}
const RecommendationStep = () => {
const { inputs, session, patchSession, tenantConfig, logEvent } = useEstimateWizard();
// Deterministic — recomputed from current answers, stable across renders.
const recommendation = useMemo(
() => score({ inputs, hailSnapshot: session.hailSnapshot, tenantConfig }),
[inputs, session.hailSnapshot, tenantConfig]
);
const value = useMemo(
() => computeTenYearValue({ inputs, tenantConfig }),
[inputs, tenantConfig]
);
const [explanation, setExplanation] = useState(session.recommendation?.explanation || null);
const [loadingAI, setLoadingAI] = useState(true);
// Freeze deterministic record + log it (reproducible audit, spec §18).
useEffect(() => {
patchSession({ recommendation: { ...recommendation, value } });
logEvent('recommendation_scored', {
modelVersion: recommendation.modelVersion,
selectedPackage: recommendation.selectedPackage,
scores: Object.fromEntries(Object.entries(recommendation.scores).map(([k, v]) => [k, v.total])),
uncertaintyFlags: recommendation.uncertaintyFlags,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [recommendation, value]);
// AI explanation (async, guardrailed).
useEffect(() => {
let cancelled = false;
setLoadingAI(true);
generateExplanation({ inputs, hailSnapshot: session.hailSnapshot, recommendation, valueModel: value, tenantConfig })
.then((res) => {
if (cancelled) return;
setExplanation(res);
patchSession({ recommendation: { ...recommendation, value, explanation: res } });
logEvent('ai_explanation_generated', { aiUsed: res.aiUsed, guardrailOk: res.guardrail?.ok });
});
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [recommendation, value]);
// Lock the highlighted option in as the customer's selection if they hadn't chosen.
useEffect(() => {
if (!session.selectedPackage) patchSession({ selectedPackage: recommendation.selectedPackage });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const highlight = recommendation.selectedPackage;
const selectedType = session.selectedPackage || highlight;
const isRecommendedSelected = selectedType === highlight;
const selectedPkg = tenantConfig.packages.find((p) => p.type === selectedType);
const selectedDesc = !isRecommendedSelected && selectedPkg ? describePackage(selectedPkg, value) : null;
return (
<div className="space-y-5">
<GoodBetterBestCards
packages={tenantConfig.packages}
pricing={tenantConfig.pricing}
selectedType={selectedType}
highlightedType={highlight}
onSelect={(t) => { patchSession({ selectedPackage: t }); logEvent('package_selected', { package: t, after: 'recommendation' }); }}
/>
{/* Explanation — describes the RECOMMENDED package (AI-assisted) while it's
selected, and switches to a deterministic per-package description when the
customer clicks one of the other options. */}
{isRecommendedSelected ? (
<div className="rounded-2xl border border-amber-200 dark:border-amber-500/20 bg-amber-50/60 dark:bg-amber-500/5 p-4">
<div className="flex items-center gap-2 mb-2">
<Sparkles size={16} className="text-amber-500" />
<span className="font-semibold text-zinc-900 dark:text-white">
Why we highlighted {PKG_LABEL[highlight]}
</span>
{explanation && (
<span className="ml-auto text-[10px] font-semibold uppercase tracking-wide text-emerald-600 dark:text-emerald-400 inline-flex items-center gap-1">
<ShieldCheck size={11} /> {explanation.aiUsed ? 'AI-assisted' : 'Reviewed'}
</span>
)}
</div>
{loadingAI && !explanation ? (
<div className="flex items-center gap-2 text-sm text-zinc-500 dark:text-zinc-400 py-2">
<Loader2 size={15} className="animate-spin" /> Preparing your explanation
</div>
) : (
<>
<p className="text-sm text-zinc-700 dark:text-zinc-200 leading-relaxed">
{explanation?.customerExplanation}
</p>
<ul className="mt-3 space-y-1.5">
{recommendation.reasons.map((r) => (
<li key={r} className="flex items-start gap-2 text-sm text-zinc-600 dark:text-zinc-300">
<Check size={14} className="text-amber-500 mt-0.5 shrink-0" /> {r}
</li>
))}
</ul>
</>
)}
</div>
) : (
<div className="rounded-2xl border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 p-4">
<div className="flex items-center gap-2 mb-2">
<Info size={16} className="text-blue-500" />
<span className="font-semibold text-zinc-900 dark:text-white">
About the {PKG_LABEL[selectedType]} option
</span>
<span className="ml-auto text-[10px] font-semibold uppercase tracking-wide text-amber-600 dark:text-amber-400">
{PKG_LABEL[highlight]} recommended
</span>
</div>
<p className="text-sm text-zinc-700 dark:text-zinc-200 leading-relaxed">
{selectedDesc.paragraph}
</p>
<ul className="mt-3 space-y-1.5">
{selectedDesc.bullets.map((b) => (
<li key={b} className="flex items-start gap-2 text-sm text-zinc-600 dark:text-zinc-300">
<Check size={14} className="text-blue-500 mt-0.5 shrink-0" /> {b}
</li>
))}
</ul>
<p className="mt-3 text-xs text-zinc-400">
We still suggest {PKG_LABEL[highlight]} based on your answers but every option stays available.
</p>
</div>
)}
<TenYearValueChart valueModel={value} />
<p className="text-xs text-zinc-400 leading-relaxed">
This recommendation considers your stated budget and goals all three options stay available.
Insurance savings are illustrative and must be confirmed with your carrier.
</p>
</div>
);
};
export default RecommendationStep;
@@ -0,0 +1,132 @@
/**
* ReportStep — Screen 9 "Report + next step" (spec §11, §13).
* Shows the report summary, lets the customer email/print/download/share, books
* the inspection, and fires the CRM + nurture handoff (once) with the immutable
* compliance record (spec §18 G1).
*/
import React, { useEffect, useRef, useState } from 'react';
import { Download, Printer, Mail, Share2, FileText, Loader2 } from 'lucide-react';
import { useEstimateWizard } from '../../EstimateWizardContext';
import { useMockStore } from '../../../../data/mockStore';
import { buildReportModel, downloadReportPdf, getReportBlobUrl } from '../../utils/wizardReportExport';
import { submitToCrm, buildLeadFromSession } from '../../api/crmHandoff';
import { formatUSD } from '../../engine/pricing';
const PKG_LABEL = { GOOD: 'Good', BETTER: 'Better', BEST: 'Best' };
const ActionButton = ({ icon: Icon, label, onClick, busy, primary }) => (
<button
type="button"
onClick={onClick}
disabled={busy}
className={`flex items-center justify-center gap-2 rounded-xl px-4 py-2.5 text-sm font-semibold transition-all disabled:opacity-50
${primary
? 'bg-gradient-to-r from-amber-400 to-orange-500 text-white shadow-lg shadow-amber-500/25'
: 'border border-zinc-200 dark:border-white/10 text-zinc-700 dark:text-zinc-200 hover:border-amber-400'}`}
>
{busy ? <Loader2 size={16} className="animate-spin" /> : Icon && <Icon size={16} />} {label}
</button>
);
const ReportStep = () => {
const { session, tenantConfig, patchSession, logEvent } = useEstimateWizard();
const { addWizardLead } = useMockStore();
const model = buildReportModel(session, tenantConfig);
const [busy, setBusy] = useState(null);
const firedRef = useRef(false);
// Fire CRM + nurture handoff once (spec §13 / §17 E2). The ref guard prevents a
// duplicate lead under React StrictMode's double-invoked mount effect.
useEffect(() => {
if (firedRef.current || session.report) return;
firedRef.current = true;
// Create a real lead in the global CRM mock store so the estimate shows up
// in the pipeline (addLead injects id / createdAt / status: 'New').
const lead = addWizardLead(buildLeadFromSession(session, tenantConfig, {
reportId: model.reportId,
estimatedTotal: model.totals.total,
}));
const reportMeta = {
reportId: model.reportId, version: 1, generatedAt: model.generatedAt,
deliveryStatus: 'created', crmLeadId: lead?.id || null, estimatedTotal: model.totals.total,
};
patchSession({ report: reportMeta });
submitToCrm(session, tenantConfig, reportMeta);
logEvent('report_generated', { reportId: model.reportId });
logEvent('crm_handoff', { trigger: 'wizard_completed', crmLeadId: lead?.id || null });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleDownload = async () => {
setBusy('download');
try { await downloadReportPdf(session, tenantConfig); logEvent('report_downloaded', { reportId: model.reportId }); }
finally { setBusy(null); }
};
const handlePrint = async () => {
setBusy('print');
try {
const { url } = await getReportBlobUrl(session, tenantConfig);
const w = window.open(url, '_blank');
if (w) w.addEventListener('load', () => w.print());
patchSession({ report: { ...session.report, printedAt: new Date().toISOString() } });
logEvent('report_printed', { reportId: model.reportId });
} finally { setBusy(null); }
};
const handleEmail = () => {
const subject = encodeURIComponent(`Your Roof Options Report (${model.reportId})`);
const body = encodeURIComponent(
`Hi ${model.contact.name || ''},\n\nHere is a summary of your roof options report.\n` +
`Recommended: ${PKG_LABEL[model.recommended]}\nEstimated total: ~${formatUSD(model.totals.total)}\n\n` +
`${tenantConfig.branding.company} · ${tenantConfig.branding.phone}\n\n` +
`Note: ${tenantConfig.disclaimer}`
);
window.location.href = `mailto:${model.contact.email || ''}?subject=${subject}&body=${body}`;
patchSession({ report: { ...session.report, emailedAt: new Date().toISOString(), deliveryStatus: 'emailed' } });
logEvent('report_emailed', { reportId: model.reportId });
};
const handleShare = async () => {
const summary = `My roof options report (${PKG_LABEL[model.recommended]} recommended) from ${tenantConfig.branding.company}.`;
try {
if (navigator.share) await navigator.share({ title: 'Roof Options Report', text: summary });
else { await navigator.clipboard?.writeText(summary); }
logEvent('report_shared', { reportId: model.reportId });
} catch { /* user cancelled */ }
};
return (
<div className="space-y-4">
{/* Report summary card */}
<div className="rounded-2xl border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 p-5">
<div className="flex items-center gap-2 mb-3">
<FileText size={18} className="text-amber-500" />
<span className="font-bold text-zinc-900 dark:text-white">Your Roof Options Report</span>
<span className="ml-auto text-[11px] text-zinc-400">{model.reportId}</span>
</div>
<div className="grid grid-cols-2 gap-3 text-sm">
<div><div className="text-zinc-400 text-xs">Prepared for</div><div className="font-medium text-zinc-800 dark:text-zinc-100">{model.contact.name || '—'}</div></div>
<div><div className="text-zinc-400 text-xs">Recommended</div><div className="font-medium text-amber-600 dark:text-amber-400">{PKG_LABEL[model.recommended]}</div></div>
<div><div className="text-zinc-400 text-xs">Hail exposure</div><div className="font-medium text-zinc-800 dark:text-zinc-100 capitalize">{model.hail?.exposureTier || '—'}</div></div>
<div><div className="text-zinc-400 text-xs">Estimated total</div><div className="font-medium text-zinc-800 dark:text-zinc-100">~{formatUSD(model.totals.total)}</div></div>
</div>
<p className="text-[11px] text-zinc-400 mt-3 leading-relaxed border-t border-zinc-100 dark:border-white/5 pt-2.5">
{tenantConfig.disclaimer}
</p>
</div>
{/* Delivery actions */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2.5">
<ActionButton icon={Download} label="Download" onClick={handleDownload} busy={busy === 'download'} />
<ActionButton icon={Printer} label="Print" onClick={handlePrint} busy={busy === 'print'} />
<ActionButton icon={Mail} label="Email" onClick={handleEmail} />
<ActionButton icon={Share2} label="Share" onClick={handleShare} />
</div>
</div>
);
};
export default ReportStep;
@@ -0,0 +1,114 @@
/**
* StartStep — Screen 1 "Simple Start" (spec §4 / §5).
* Captures name, property address, mobile, email, contact method + consent.
* Consent is required before proceeding (spec §15 Privacy / §3 compliance).
*/
import React from 'react';
import { User, MapPin, Phone, Mail, Wand2 } from 'lucide-react';
import { useEstimateWizard } from '../../EstimateWizardContext';
const Field = ({ icon: Icon, label, children }) => (
<label className="block">
<span className="text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-1.5 flex items-center gap-1.5">
{Icon && <Icon size={15} className="text-amber-500" />} {label}
</span>
{children}
</label>
);
const inputCls =
'w-full rounded-xl border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 px-3.5 py-2.5 ' +
'text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-amber-500';
const StartStep = () => {
const { inputs, setInput, prefillDemo } = useEstimateWizard();
return (
<div className="space-y-4">
<button
type="button"
onClick={prefillDemo}
className="w-full flex items-center justify-center gap-2 rounded-xl border border-dashed border-amber-300 dark:border-amber-500/30 bg-amber-50 dark:bg-amber-500/10 px-3 py-2.5 text-sm font-medium text-amber-700 dark:text-amber-300 hover:border-amber-400"
>
<Wand2 size={15} /> Try a demo address (auto-fills every answer)
</button>
<Field icon={User} label="Name">
<input
className={inputCls}
placeholder="First and last name"
value={inputs.name}
onChange={(e) => setInput('name', e.target.value)}
autoComplete="name"
/>
</Field>
<Field icon={MapPin} label="Property address">
<input
className={inputCls}
placeholder="Street, city, state, ZIP"
value={inputs.address}
onChange={(e) => setInput('address', e.target.value)}
autoComplete="street-address"
/>
</Field>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Field icon={Phone} label="Mobile number">
<input
className={inputCls}
placeholder="(555) 123-4567"
value={inputs.phone}
onChange={(e) => setInput('phone', e.target.value)}
autoComplete="tel"
inputMode="tel"
/>
</Field>
<Field icon={Mail} label="Email">
<input
className={inputCls}
placeholder="you@email.com"
value={inputs.email}
onChange={(e) => setInput('email', e.target.value)}
autoComplete="email"
inputMode="email"
/>
</Field>
</div>
<div>
<span className="text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-1.5 block">Best contact method?</span>
<div className="flex gap-2">
{['text', 'email', 'phone'].map((m) => (
<button
key={m}
type="button"
onClick={() => setInput('contactMethod', m)}
className={`flex-1 rounded-xl border px-3 py-2 text-sm font-medium capitalize transition-all
${inputs.contactMethod === m
? 'border-amber-500 bg-amber-50 dark:bg-amber-500/10 text-amber-700 dark:text-amber-300'
: 'border-zinc-200 dark:border-white/10 text-zinc-600 dark:text-zinc-300 hover:border-amber-400'}`}
>
{m}
</button>
))}
</div>
</div>
<label className="flex items-start gap-2.5 rounded-xl bg-zinc-50 dark:bg-white/5 p-3.5 cursor-pointer">
<input
type="checkbox"
className="mt-0.5 h-4 w-4 accent-amber-500"
checked={inputs.consent}
onChange={(e) => setInput('consent', e.target.checked)}
/>
<span className="text-xs text-zinc-600 dark:text-zinc-400 leading-relaxed">
I agree to be contacted about my roof estimate and understand my address and any insurance
details I provide are used to generate this educational report. This is not a binding quote.
</span>
</label>
</div>
);
};
export default StartStep;
@@ -0,0 +1,115 @@
/**
* questions.js — Wizard question architecture (spec §5).
*
* Every question must improve pricing accuracy, recommendation quality, or sales
* follow-up (spec §5: "Do not ask curiosity questions"). Each entry maps to a key
* stored on estimateInputs and is consumed by the scoring engine (Phase 4).
*
* The ordered STEP
* _FLOW (below) defines the one-question-per-screen journey
* (spec §4). Some "screens" are composite (Start collects contact fields) — these
* use a dedicated component instead of the generic question renderer.
*/
// Reusable answer choice sets
export const ISSUE_CHOICES = [
{ value: 'aging', label: 'Aging roof', desc: 'General wear, nearing end of life', icon: 'Clock', triggers: ['old_roof'] },
{ value: 'leak', label: 'Leak or active issue', desc: 'Water intrusion or visible damage', icon: 'Droplets', triggers: ['leak'] },
{ value: 'storm', label: 'Storm concern', desc: 'Possible hail or wind damage', icon: 'CloudLightning', triggers: [] },
];
export const OWNERSHIP_CHOICES = [
{ value: '1-5', label: '15 years', desc: 'May sell soon', score: 1 },
{ value: '6-15', label: '615 years', desc: 'Medium-term', score: 2 },
{ value: '16+', label: '16+ years / forever', desc: 'Long-term home', score: 3 },
];
export const BUDGET_CHOICES = [
{ value: 'lowest', label: 'Lowest upfront', desc: 'Minimize initial cost' },
{ value: 'balanced', label: 'Balanced', desc: 'Value for the money' },
{ value: 'longterm', label: 'Long-term protection', desc: 'Best lifecycle value' },
];
export const HOA_CHOICES = [
{ value: 'no', label: 'No' },
{ value: 'yes', label: 'Yes' },
{ value: 'unsure', label: 'Not sure' },
];
export const ATTIC_CHOICES = [
{ value: 'attic_hot', label: 'Hot attic', triggers: ['attic_hot'] },
{ value: 'energy_bills', label: 'High energy bills', triggers: ['energy_bills'] },
{ value: 'none', label: 'Not sure', triggers: [] },
];
export const PESTS_CHOICES = [
{ value: 'yes', label: 'Yes', triggers: ['pests'] },
{ value: 'no', label: 'No', triggers: [] },
{ value: 'unsure', label: 'Not sure', triggers: [] },
];
export const FINANCING_CHOICES = [
{ value: 'yes', label: 'Yes' },
{ value: 'maybe', label: 'Maybe' },
{ value: 'no', label: 'No' },
];
/**
* STEP_FLOW — ordered screens. `type` selects the renderer in WizardShell.
* - 'component' → custom step component (key matches steps/ registry)
* - 'cards' → single-select card group bound to `inputKey`
* - 'slider' → numeric slider bound to `inputKey`
* `required` gates the Continue button; `skippable` allows an explicit Skip.
*/
export const STEP_FLOW = [
{ id: 'start', type: 'component', title: 'Build your roofing estimate in 3 minutes',
subtitle: 'No pressure. Your report will show Good, Better, and Best options based on your home and goals.' },
{ id: 'address', type: 'component', title: 'What property is this for?',
subtitle: "We'll use this to pull local storm history. We validate measurements later during inspection." },
{ id: 'roofAge', type: 'slider', inputKey: 'roofAgeYears', title: 'How old is your roof?',
subtitle: 'Slide to estimate roof age. We will validate later during inspection.',
min: 1, max: 20, step: 1, unit: 'yr', maxLabel: '20+ yrs', required: true },
{ id: 'issue', type: 'cards', inputKey: 'issueType', title: 'What is happening right now?',
subtitle: 'This helps us tailor your options.', choices: ISSUE_CHOICES, required: true },
{ id: 'ownership', type: 'cards', inputKey: 'ownershipHorizon', title: 'How long do you plan to live here?',
subtitle: 'This decides whether lowest upfront or long-term value matters more.', choices: OWNERSHIP_CHOICES, required: true },
{ id: 'premium', type: 'component', title: 'Annual insurance premium?',
subtitle: 'Optional — it lets us estimate potential 10-year savings scenarios.' },
{ id: 'hailRisk', type: 'component', title: 'Your areas historical hail exposure',
subtitle: 'A historical risk estimate for your area — not a forecast.' },
{ id: 'budget', type: 'cards', inputKey: 'budgetPreference', title: 'What matters most for budget?',
subtitle: 'So we recommend the right option for you — not the most expensive one.', choices: BUDGET_CHOICES, required: true },
{ id: 'hoa', type: 'cards', inputKey: 'hoaConstraints', title: 'HOA or design constraints?',
subtitle: 'Some neighborhoods require approval for certain materials.', choices: HOA_CHOICES, skippable: true },
{ id: 'attic', type: 'cards', inputKey: 'atticConcern', title: 'Attic comfort or ventilation issues?',
subtitle: 'Helps us suggest ventilation upgrades if useful.', choices: ATTIC_CHOICES, skippable: true },
{ id: 'pests', type: 'cards', inputKey: 'pestConcern', title: 'Concerned about rodents or pests?',
subtitle: 'Helps us suggest critter-guard protection if useful.', choices: PESTS_CHOICES, skippable: true },
{ id: 'financing', type: 'cards', inputKey: 'financingInterest', title: 'Interested in financing?',
subtitle: 'We can prepare financing options for your appointment.', choices: FINANCING_CHOICES, skippable: true },
{ id: 'options', type: 'component', title: 'Your Good / Better / Best options',
subtitle: 'All three options, side by side. We highlight a best fit — you stay in control.' },
{ id: 'addons', type: 'component', title: 'Optional upgrades',
subtitle: 'Add or remove anything. Your rep can revise after inspection.' },
{ id: 'recommendation', type: 'component', title: 'Our recommendation',
subtitle: 'Heres the best fit for your home and goals, and exactly why.' },
{ id: 'report', type: 'component', title: 'Your roof options report',
subtitle: 'Email, print, or download — then book your free inspection.' },
];
export const TOTAL_STEPS = STEP_FLOW.length;
@@ -0,0 +1,274 @@
/**
* tenantConfig.js — Estimate Wizard tenant-configurable defaults.
*
* Spec §14 (Admin Controls) requires that pricing, discounts, warranty text,
* package scope, service-life, and scoring weights are NOT hard-coded — they are
* tenant-controlled. For the demo these defaults live here and can be overridden
* by the admin page (Phase 5). Nothing customer-facing should bypass this object.
*
* Manufacturer-neutral by design (spec §3, §15): packages are generic categories,
* internal SKU/vendor names are intentionally absent from customer-facing fields.
*/
// ── Good / Better / Best packages (spec §7) ──────────────────────────────────
export const DEFAULT_PACKAGES = [
{
type: 'GOOD',
name: 'Standard Architectural',
category: 'Architectural / laminate shingle system',
materialClass: 'standard',
warrantyLabel: '30-year material warranty category',
position: 'Lowest upfront investment',
fit: 'Shorter ownership horizon, limited budget, lower hail exposure, or preparing to sell soon.',
// price rule: per-square installed price (demo). Real pricing engine lives in admin.
pricePerSquare: 420,
serviceLifeYears: 28, // tenant-configurable (e.g. 2530)
insuranceDiscountPct: 0, // Good is not impact-rated → no assumed discount
hailResistance: 'Standard',
scope: ['Architectural laminate shingles', 'Code-required underlayment', 'Standard hip/ridge', 'Standard accessories'],
accent: 'zinc',
},
{
type: 'BETTER',
name: 'Class 4 Impact-Rated',
category: 'Class 4 impact-rated architectural shingle system',
materialClass: 'impact-rated',
warrantyLabel: 'Enhanced manufacturer warranty category',
position: 'Balanced protection and budget',
fit: 'Medium-to-long ownership, meaningful hail exposure, wants stronger protection without premium-system cost.',
pricePerSquare: 565,
serviceLifeYears: 35, // tenant-configurable (e.g. 3040)
insuranceDiscountPct: 15, // illustrative; "ask your carrier to confirm"
hailResistance: 'Higher',
scope: ['Class 4 impact-rated shingles', 'Upgraded underlayment', 'Standard hip/ridge', 'Improved hail resistance'],
accent: 'blue',
},
{
type: 'BEST',
name: 'Premium Long-Life System',
category: 'Composite / synthetic or stone-coated steel category',
materialClass: 'premium',
warrantyLabel: 'Premium long-life warranty category',
position: 'Highest long-term protection',
fit: 'Long ownership, high hail exposure, high premium, wants fewer future roof cycles and strongest curb appeal.',
pricePerSquare: 950,
serviceLifeYears: 50, // tenant-configurable (e.g. 50+)
insuranceDiscountPct: 20, // illustrative; "ask your carrier to confirm"
hailResistance: 'Highest',
scope: ['Composite/synthetic or stone-coated steel', 'Premium underlayment', 'Upgraded accessories', 'Highest durability positioning'],
accent: 'amber',
},
];
// ── Add-ons (spec §8) — plain-language, never fear-based ──────────────────────
export const DEFAULT_ADDONS = [
{
id: 'hip-ridge',
name: 'High-profile hip & ridge',
value: 'Improves the finished look and strengthens the ridge-line appearance.',
price: 480,
compatibility: ['GOOD', 'BETTER', 'BEST'],
recommendedOn: ['BEST'],
triggerKeys: ['aesthetics'],
},
{
id: 'solar-vents',
name: 'Solar attic vents',
value: 'Helps move hot attic air and may improve comfort and roof-system health.',
price: 650,
compatibility: ['GOOD', 'BETTER', 'BEST'],
recommendedOn: [],
triggerKeys: ['attic_hot', 'energy_bills'],
},
{
id: 'gutter-guards',
name: 'Gutter guards',
value: 'Reduces gutter debris buildup and maintenance frequency.',
price: 900,
compatibility: ['GOOD', 'BETTER', 'BEST'],
recommendedOn: [],
triggerKeys: ['canopy'],
},
{
id: 'critter-guards',
name: 'Critter guards',
value: 'Helps block common rodent and small-animal roofline entry points.',
price: 350,
compatibility: ['GOOD', 'BETTER', 'BEST'],
recommendedOn: [],
triggerKeys: ['pests'],
},
{
id: 'upgraded-flashing',
name: 'Upgraded flashing / penetrations',
value: 'Improves protection around common leak points.',
price: 520,
compatibility: ['GOOD', 'BETTER', 'BEST'],
recommendedOn: ['BEST'],
triggerKeys: ['leak', 'old_roof', 'complex'],
},
{
id: 'enhanced-underlayment',
name: 'Enhanced underlayment / ice-water zones',
value: 'Adds protection in vulnerable valleys, eaves, and penetrations.',
price: 700,
compatibility: ['GOOD', 'BETTER', 'BEST'],
recommendedOn: [],
triggerKeys: ['complex', 'leak'],
},
];
// ── Scoring weights (spec §9) — deterministic, tenant-adjustable ──────────────
export const DEFAULT_SCORING_WEIGHTS = {
ownershipHorizon: 0.25,
hailRisk: 0.25,
insuranceSavings: 0.20,
budgetPreference: 0.15,
roofAge: 0.10,
aesthetics: 0.05,
};
// ── Storm-risk settings (spec §6, §14) ────────────────────────────────────────
export const DEFAULT_STORM_SETTINGS = {
windowYears: 20, // 10 fallback / 30 for smoother trends
radiusMiles: 10,
minHailSizeInches: 0.75,
dedupeWindowHours: 6,
};
// ── Pricing engine knobs (spec §14) ───────────────────────────────────────────
export const DEFAULT_PRICING = {
wasteFactor: 1.05,
minJobPrice: 6500,
pitchFactor: 1.0, // multiplied per pitch/stories/complexity in admin
taxPlaceholderPct: 0,
permitPlaceholder: 0,
// No measurement source in the wizard (spec §3) → assume an average roof size
// so the customer sees a PRELIMINARY range. Verified during inspection.
assumedSquares: 25, // ~2,500 sq ft of roof surface
rangeSpread: 0.08, // ±8% band shown to the customer (low/base/high)
};
// ── Insurance discount scenarios (spec §10/§14) — low/base/high ───────────────
export const DEFAULT_DISCOUNT_SCENARIOS = {
GOOD: { low: 0, base: 0, high: 0 },
BETTER: { low: 10, base: 15, high: 18 },
BEST: { low: 15, base: 18, high: 22 },
};
// ── Report branding (spec §14) ────────────────────────────────────────────────
export const DEFAULT_BRANDING = {
company: 'LynkedUp Pro',
phone: '866-259-6533',
email: 'estimates@lynkeduppro.com',
serviceArea: 'Texas & surrounding regions',
primaryColor: '#fda913',
legalFooter: 'Preliminary estimate. Not a binding quote.',
};
// ── Legal / disclaimer (spec §15) — verbatim required template ────────────────
export const DISCLAIMER_TEMPLATE =
'This report is an educational, preliminary estimate based on the information provided and available ' +
'property/storm data. Final pricing requires property measurement and contractor inspection. Insurance ' +
'premium savings are illustrative only and must be confirmed with your insurance carrier or licensed ' +
'insurance professional. Historical storm information is not a forecast or guarantee.';
// Schema version of the config shape. The *published* tenant version is this plus
// a revision suffix (e.g. tenant-config-v1.r4) that bumps on every admin save, so
// each report records exactly which package content/pricing produced it (§14, §18).
export const TENANT_CONFIG_VERSION = 'tenant-config-v1';
export const DISCLAIMER_VERSION = 'disclaimer-v1';
// Admin-editable overrides (spec §14). Held in memory only — like the rest of the
// CRM demo store, nothing is written to localStorage, so this resets on a full page
// reload. The wizard merges these over the defaults so pricing/branding/feature
// flags are tenant-controlled, not code. Module-scoped so the value survives route
// changes within a session (admin → wizard → leads) without persistence.
let configOverrides = {};
// Same-tab change signal so an open wizard refreshes when the admin publishes,
// without a hard reload (the in-memory store has no `storage` event to lean on).
export const CONFIG_CHANGE_EVENT = 'lup-estimate-wizard-config-changed';
function notifyConfigChanged() {
try { window.dispatchEvent(new Event(CONFIG_CHANGE_EVENT)); } catch { /* SSR/no window */ }
}
function defaults() {
return {
version: TENANT_CONFIG_VERSION,
disclaimerVersion: DISCLAIMER_VERSION,
// Feature flags (spec §18): kill-switch, AI toggle, conservative copy mode.
featureEnabled: true,
aiExplanationsEnabled: true,
conservativeMode: false,
packages: DEFAULT_PACKAGES,
addons: DEFAULT_ADDONS,
scoringWeights: DEFAULT_SCORING_WEIGHTS,
stormSettings: DEFAULT_STORM_SETTINGS,
pricing: DEFAULT_PRICING,
discountScenarios: DEFAULT_DISCOUNT_SCENARIOS,
branding: DEFAULT_BRANDING,
disclaimer: DISCLAIMER_TEMPLATE,
};
}
export function loadConfigOverrides() {
return configOverrides;
}
export function saveConfigOverrides(overrides) {
configOverrides = overrides || {};
notifyConfigChanged();
}
export function resetConfigOverrides() {
configOverrides = {};
notifyConfigChanged();
}
/**
* Publish a new tenant config version (spec §14: "versioned"). Bumps the revision,
* stamps versionedAt, and appends to a capped version history so admins can see and
* audit every change. Returns the saved overrides (with the new version metadata).
*/
export function publishConfigOverrides(overrides) {
const prev = loadConfigOverrides();
const revision = (Number(prev.revision) || 0) + 1;
const version = `${TENANT_CONFIG_VERSION}.r${revision}`;
const versionedAt = new Date().toISOString();
const history = Array.isArray(prev.versionHistory) ? prev.versionHistory : [];
const saved = {
...overrides,
revision,
version,
versionedAt,
versionHistory: [{ revision, version, versionedAt }, ...history].slice(0, 25),
};
saveConfigOverrides(saved);
return saved;
}
export function getTenantConfig() {
const base = defaults();
const o = loadConfigOverrides();
// Shallow-merge top-level objects so partial admin edits don't drop defaults.
// `...o` lets a published version/versionedAt/revision override the base schema
// version, so getTenantConfig().version reflects the tenant's edits.
return {
...base,
...o,
pricing: { ...base.pricing, ...(o.pricing || {}) },
branding: { ...base.branding, ...(o.branding || {}) },
scoringWeights: { ...base.scoringWeights, ...(o.scoringWeights || {}) },
stormSettings: { ...base.stormSettings, ...(o.stormSettings || {}) },
discountScenarios: { ...base.discountScenarios, ...(o.discountScenarios || {}) },
// Packages: merge admin price/label edits by type, keep default scope/copy.
packages: base.packages.map((p) => {
const ov = (o.packages || []).find((x) => x.type === p.type);
return ov ? { ...p, ...ov } : p;
}),
};
}
@@ -0,0 +1,104 @@
/**
* hailProbability.js — conservative historical hail-risk model (spec §6).
*
* IMPORTANT framing (spec §6): the output is a HISTORICAL hazard estimate for the
* surrounding area, NOT a property-level forecast or guarantee. The model computes
* a RANGE (confidence band), not one overconfident number, and stores every
* assumption so the report can be defended.
*
* lambda (λ) = deduped hail events / years
* P(at least one hail event over N years) = 1 - e^(-λN)
*
* Pure function — no I/O. Fed by /api/storm-history (existing storm module, used
* read-only). Returns the hail_risk_snapshot shape (spec §12).
*/
function clamp(n, lo, hi) { return Math.max(lo, Math.min(hi, n)); }
function pct(n) { return Math.round(n * 100); }
/**
* @param {object} input
* eventCount {number} deduped hail events observed in the window
* windowYears {number} historical window analyzed
* radiusMiles {number} search radius around the property
* geocodeConfidence {'high'|'moderate'|'low'}
* maxHailInches {number|null}
* lastEventDate {string|null} ISO date of most recent hail event
* source {string} human-readable data source label
* generatedAt {string} ISO timestamp the snapshot was frozen
*/
export function computeHailRisk(input) {
const {
eventCount = 0,
windowYears = 5,
radiusMiles = 20,
geocodeConfidence = 'moderate',
maxHailInches = null,
lastEventDate = null,
source = 'NOAA / IEM Local Storm Reports',
generatedAt = new Date().toISOString(),
} = input || {};
const years = Math.max(1, windowYears);
const count = Math.max(0, eventCount);
// Annualized rate (events/year).
const lambda = count / years;
// Poisson confidence band using event-count standard error (√count).
const se = Math.sqrt(count);
const lambdaLow = Math.max(0, (count - se) / years);
const lambdaHigh = (count + se) / years;
const prob = (lam, n) => 1 - Math.exp(-lam * n);
const p10Base = prob(lambda, 10);
const p10Low = prob(lambdaLow, 10);
const p10High = prob(lambdaHigh, 10);
// Never present a 100% historical probability — that reads as a certainty/guarantee,
// which this model explicitly is not (spec §6). High-activity areas push p10High to
// ~1.0, so cap the DISPLAYED band just under certainty. Underlying p10* values still
// drive the exposure tier and scoring unchanged.
const MAX_DISPLAY_PROB = 0.95;
const dispLow = Math.min(p10Low, MAX_DISPLAY_PROB);
const dispBase = Math.min(p10Base, MAX_DISPLAY_PROB);
const dispHigh = Math.min(p10High, MAX_DISPLAY_PROB);
// Confidence score (0..1): sample size + window length + geocode precision.
const sampleScore = clamp(count / 8, 0, 1); // ~8+ events → strong
const windowScore = clamp(years / 10, 0, 1); // 10y window → full
const geoScore = geocodeConfidence === 'high' ? 1 : geocodeConfidence === 'moderate' ? 0.6 : 0.3;
const confidenceValue = +(0.4 * sampleScore + 0.3 * windowScore + 0.3 * geoScore).toFixed(2);
const confidenceLabel = confidenceValue >= 0.66 ? 'High' : confidenceValue >= 0.4 ? 'Moderate' : 'Low';
// Coarse exposure tier used downstream by the scoring engine (Phase 4).
const exposureTier = p10Base >= 0.66 ? 'high' : p10Base >= 0.33 ? 'moderate' : 'low';
return {
// hail_risk_snapshot fields (spec §12)
source,
windowYears: years,
radiusMiles,
eventCount: count,
annualRate: +lambda.toFixed(3),
maxHailInches,
lastEventDate,
probability10yr: { low: pct(dispLow), base: pct(dispBase), high: pct(dispHigh) },
confidence: confidenceLabel,
confidenceValue,
exposureTier,
generatedAt,
// Customer-facing wording (spec §6) — historical, never a forecast.
wording: {
annualRate: count === 0
? 'No qualifying hail events were found in the available reports for this area.'
: `This area has averaged about ${lambda.toFixed(1)} observed hail event(s) per year in the available dataset.`,
probability: count === 0
? 'There is not enough historical hail activity nearby to estimate a meaningful probability.'
: `Based on historical reports, the chance of at least one hail event in the next 10 years is estimated at ${pct(dispLow)}${pct(dispHigh)}%, assuming future patterns resemble the past.`,
confidence: `Confidence: ${confidenceLabel}. This is based on county-level and nearby reports within ~${radiusMiles} miles, not a roof-specific inspection.`,
lastUpdated: `Storm data reflects reports through ${new Date(generatedAt).toLocaleDateString()}.`,
},
};
}
@@ -0,0 +1,83 @@
/**
* pricing.js — preliminary package + add-on pricing (spec §7, §8, §10, §14).
*
* Pure functions. All inputs come from tenantConfig (nothing hard-coded per §14).
* Because the wizard has no verified roof measurement (spec §3), every price is a
* PRELIMINARY RANGE (low/base/high) and must be labeled as such — final pricing
* requires inspection.
*/
const round = (n, to = 50) => Math.round(n / to) * to;
/**
* Price a single package for an assumed roof size.
* @returns {{ low:number, base:number, high:number }}
*/
export function pricePackage(pkg, pricing) {
const squares = pricing.assumedSquares ?? 25;
const waste = pricing.wasteFactor ?? 1.05;
const pitch = pricing.pitchFactor ?? 1.0;
const raw = pkg.pricePerSquare * squares * waste * pitch;
const base = Math.max(pricing.minJobPrice ?? 0, raw);
const spread = pricing.rangeSpread ?? 0.08;
return {
low: round(base * (1 - spread)),
base: round(base),
high: round(base * (1 + spread)),
};
}
/** Price every package (keyed by type). */
export function priceAllPackages(packages, pricing) {
const out = {};
packages.forEach((p) => { out[p.type] = pricePackage(p, pricing); });
return out;
}
/** Total price of the selected add-ons (flat, demo). */
export function addonsTotal(addons, selectedIds) {
return addons
.filter((a) => selectedIds.includes(a.id))
.reduce((sum, a) => sum + (a.price || 0), 0);
}
/**
* Derive add-on trigger keys from the customer's wizard answers + hail snapshot.
* Drives default-selected add-ons and AI "recommend when…" hints (spec §8).
*/
export function deriveAddonTriggers(inputs, hailSnapshot) {
const t = new Set();
if (inputs.issueType === 'leak') t.add('leak');
if (inputs.issueType === 'aging' || (inputs.roofAgeYears ?? 0) >= 15) t.add('old_roof');
if (inputs.atticConcern === 'attic_hot') t.add('attic_hot');
if (inputs.atticConcern === 'energy_bills') t.add('energy_bills');
if (inputs.pestConcern === 'yes') t.add('pests');
if (inputs.budgetPreference === 'longterm') t.add('aesthetics');
if (hailSnapshot?.exposureTier === 'high') t.add('complex');
return t;
}
/**
* Which add-ons should be pre-selected for a given package + answers (spec §8).
* An add-on defaults ON if its triggers fire, or it's recommended on this package.
*/
export function defaultSelectedAddons(addons, packageType, triggers) {
return addons
.filter((a) =>
(a.recommendedOn || []).includes(packageType) ||
(a.triggerKeys || []).some((k) => triggers.has(k))
)
.map((a) => a.id);
}
/** Is an add-on "recommended" for display badge purposes? */
export function isAddonRecommended(addon, packageType, triggers) {
return (addon.recommendedOn || []).includes(packageType) ||
(addon.triggerKeys || []).some((k) => triggers.has(k));
}
export function formatUSD(n) {
return `$${Math.round(n).toLocaleString('en-US')}`;
}
@@ -0,0 +1,163 @@
/**
* scoringEngine.js — deterministic Good/Better/Best recommendation (spec §9).
*
* Contract (spec §9, §18):
* - Deterministic & reproducible: same inputs + same weights ⇒ same scores.
* - Logged: returns the full per-factor breakdown so it can be audited.
* - AI may EXPLAIN the result but must never silently change it.
* - Never always recommends Best — budget preference + uncertainty penalties guard
* against over-recommendation (spec §15 fairness).
*
* Pure function. No I/O, no randomness, no Date.
*/
const TYPES = ['GOOD', 'BETTER', 'BEST'];
export const SCORING_MODEL_VERSION = 'scoring-v1';
// ── Per-factor sub-scores (0..1) by package type ─────────────────────────────
function ownershipFactor(horizon) {
switch (horizon) {
case '1-5': return { GOOD: 1.0, BETTER: 0.6, BEST: 0.3 };
case '6-15': return { GOOD: 0.5, BETTER: 1.0, BEST: 0.7 };
case '16+': return { GOOD: 0.3, BETTER: 0.7, BEST: 1.0 };
default: return { GOOD: 0.6, BETTER: 0.7, BEST: 0.6 };
}
}
function hailFactor(exposureTier) {
switch (exposureTier) {
case 'low': return { GOOD: 1.0, BETTER: 0.7, BEST: 0.5 };
case 'moderate': return { GOOD: 0.5, BETTER: 1.0, BEST: 0.8 };
case 'high': return { GOOD: 0.2, BETTER: 0.8, BEST: 1.0 };
default: return { GOOD: 0.6, BETTER: 0.7, BEST: 0.6 }; // unknown → neutral
}
}
function budgetFactor(pref) {
switch (pref) {
case 'lowest': return { GOOD: 1.0, BETTER: 0.5, BEST: 0.2 };
case 'balanced': return { GOOD: 0.6, BETTER: 1.0, BEST: 0.6 };
case 'longterm': return { GOOD: 0.3, BETTER: 0.7, BEST: 1.0 };
default: return { GOOD: 0.7, BETTER: 0.8, BEST: 0.6 };
}
}
function roofAgeFactor(years, issueType) {
const urgent = issueType === 'leak';
if (years >= 15 || urgent) return { GOOD: 0.5, BETTER: 0.8, BEST: 1.0 };
if (years >= 8) return { GOOD: 0.7, BETTER: 1.0, BEST: 0.8 };
return { GOOD: 1.0, BETTER: 0.8, BEST: 0.6 };
}
// Insurance-savings factor scales with premium × each package's discount.
function insuranceFactor(premium, packages) {
if (!premium || premium <= 0) {
return { factors: { GOOD: 0.5, BETTER: 0.5, BEST: 0.5 }, known: false };
}
const savings = {};
packages.forEach((p) => { savings[p.type] = premium * ((p.insuranceDiscountPct || 0) / 100); });
const max = Math.max(...Object.values(savings), 1);
const factors = {};
TYPES.forEach((t) => { factors[t] = +(savings[t] / max).toFixed(3); });
return { factors, known: true, savings };
}
function aestheticsFactor(inputs) {
const premiumLook = inputs.budgetPreference === 'longterm' || inputs.hoaConstraints === 'yes';
return premiumLook
? { GOOD: 0.5, BETTER: 0.8, BEST: 1.0 }
: { GOOD: 0.7, BETTER: 0.9, BEST: 1.0 };
}
// ── Reason synthesis (deterministic, feeds AI + fallback copy) ────────────────
function buildReasons(type, ctx) {
const { inputs, hailSnapshot } = ctx;
const r = [];
if (type === 'BEST') {
if (inputs.ownershipHorizon === '16+') r.push('You plan to stay in the home long-term (16+ years), so a longer-life system spreads its cost over more years.');
if (hailSnapshot?.exposureTier === 'high') r.push('Your area shows higher historical hail exposure, where the most durable system adds the most protection.');
if (inputs.premiumAmount > 0) r.push('Your insurance premium creates a potential savings scenario if your carrier recognizes qualifying impact-resistant materials.');
r.push('It carries the strongest lifecycle value case and fewest expected future roof cycles.');
} else if (type === 'BETTER') {
r.push('It improves hail resistance with a Class 4 impact-rated system at a balanced cost.');
if (hailSnapshot?.exposureTier !== 'low') r.push('Given your areas historical hail activity, the added impact resistance is meaningful.');
if (inputs.premiumAmount > 0) r.push('It may qualify for insurance premium consideration where available — confirm eligibility with your carrier.');
r.push('It balances stronger protection against premium-system cost.');
} else {
if (inputs.budgetPreference === 'lowest') r.push('You prioritized the lowest upfront investment.');
if (inputs.ownershipHorizon === '1-5') r.push('A shorter ownership horizon makes lowest upfront cost a rational choice.');
if (hailSnapshot?.exposureTier === 'low') r.push('Your area shows lower historical hail exposure.');
r.push('It uses a quality architectural shingle system that meets code with standard accessories.');
}
return r.slice(0, 4);
}
/**
* score() — main entry.
* @param {object} args { inputs, hailSnapshot, tenantConfig }
* @returns recommendation record (spec §12 recommendations)
*/
export function score({ inputs, hailSnapshot, tenantConfig }) {
const weights = tenantConfig.scoringWeights;
const packages = tenantConfig.packages;
const fOwn = ownershipFactor(inputs.ownershipHorizon);
const fHail = hailFactor(hailSnapshot?.exposureTier);
const fBudget = budgetFactor(inputs.budgetPreference);
const fAge = roofAgeFactor(inputs.roofAgeYears ?? 10, inputs.issueType);
const fIns = insuranceFactor(inputs.premiumAmount, packages);
const fAes = aestheticsFactor(inputs);
const uncertaintyFlags = [];
if (!inputs.premiumAmount || inputs.premiumUnknown) uncertaintyFlags.push('premium_unknown');
if (!hailSnapshot || hailSnapshot.exposureTier == null) uncertaintyFlags.push('hail_unknown');
if (inputs.hoaConstraints === 'unsure') uncertaintyFlags.push('hoa_unsure');
const scores = {};
TYPES.forEach((t) => {
const factors = {
ownershipHorizon: fOwn[t],
hailRisk: fHail[t],
insuranceSavings: fIns.factors[t],
budgetPreference: fBudget[t],
roofAge: fAge[t],
aesthetics: fAes[t],
};
let total =
factors.ownershipHorizon * weights.ownershipHorizon +
factors.hailRisk * weights.hailRisk +
factors.insuranceSavings * weights.insuranceSavings +
factors.budgetPreference * weights.budgetPreference +
factors.roofAge * weights.roofAge +
factors.aesthetics * weights.aesthetics;
// Uncertainty penalty — applied to BEST so missing data never auto-upsells.
if (t === 'BEST') total -= 0.04 * uncertaintyFlags.length;
scores[t] = { total: +total.toFixed(4), factors };
});
// Pick the top-scoring package.
let selectedPackage = TYPES.reduce((best, t) => (scores[t].total > scores[best].total ? t : best), 'GOOD');
// Budget guard (spec §7/§15): a strong "lowest upfront" preference must not be
// overridden to BEST unless exposure is high AND the homeowner is long-term.
let budgetGuardApplied = false;
if (inputs.budgetPreference === 'lowest' && selectedPackage === 'BEST') {
const justified = hailSnapshot?.exposureTier === 'high' && inputs.ownershipHorizon === '16+';
if (!justified) { selectedPackage = 'BETTER'; budgetGuardApplied = true; }
}
return {
modelVersion: SCORING_MODEL_VERSION,
weights,
scores,
selectedPackage,
reasons: buildReasons(selectedPackage, { inputs, hailSnapshot }),
uncertaintyFlags,
budgetGuardApplied,
insuranceKnown: fIns.known,
};
}
@@ -0,0 +1,92 @@
/**
* valueModel.js — 10-year value comparison (spec §10).
*
* Makes premium options understandable WITHOUT pretending savings are guaranteed:
* - 10-yr premium savings scenario = annual premium × assumed discount × 10
* - computes low/base/high scenarios so the UI can TOGGLE between them (carrier
* discount is never certain)
* - upgrade breakeven only when pricing AND discount are known
* - lifecycle value delta is labeled a model estimate, not a promise
* - per-tier "10-year value view" positioning (Upfront only / Savings + protection /
* Savings + longer lifecycle + protection)
*
* Pure function. Honors conservativeMode (spec §18) by suppressing savings claims.
*/
import { pricePackage } from './pricing';
const YEARS = 10;
const SCEN_KEYS = ['low', 'base', 'high'];
// 10-year value view positioning per tier (spec §10 comparison table).
const VALUE_VIEW = {
GOOD: 'Upfront only',
BETTER: 'Savings + protection',
BEST: 'Savings + longer lifecycle + protection',
};
export function computeTenYearValue({ inputs, tenantConfig }) {
const { packages, pricing, discountScenarios, conservativeMode } = tenantConfig;
const premium = (!inputs.premiumUnknown && inputs.premiumAmount > 0) ? inputs.premiumAmount : null;
const showSavings = !!premium && !conservativeMode;
const goodPkg = packages.find((p) => p.type === 'GOOD');
const goodPrice = pricePackage(goodPkg, pricing).base;
const goodLife = goodPkg?.serviceLifeYears || 28;
const rows = packages.map((pkg) => {
const price = pricePackage(pkg, pricing);
const scen = discountScenarios[pkg.type] || { low: 0, base: 0, high: 0 };
// Upgrade cost vs the Good baseline.
const upgradeCost = Math.max(0, price.base - goodPrice);
// Avoided future roof-cycle reserve proxy: extra service life beyond Good,
// valued at Good's price prorated (labeled a model estimate downstream).
const extraLife = Math.max(0, (pkg.serviceLifeYears || goodLife) - goodLife);
const avoidedReserve = Math.round((extraLife / goodLife) * goodPrice * 0.5);
// Per-scenario savings / annual / breakeven / lifecycle delta so the UI can
// toggle low/base/high (spec §10: "allow toggling discount scenarios").
const savings = {}, annualSavings = {}, breakeven = {}, lifecycleDelta = {};
SCEN_KEYS.forEach((k) => {
const pct = scen[k] || 0;
const annual = showSavings ? premium * (pct / 100) : 0;
const tenYr = Math.round(annual * YEARS);
annualSavings[k] = Math.round(annual);
savings[k] = tenYr;
// Breakeven (years) — only when both pricing and a positive discount exist.
breakeven[k] = (upgradeCost > 0 && annual > 0) ? +(upgradeCost / annual).toFixed(1) : null;
// Lifecycle value delta = savings + avoided reserve upgrade cost.
lifecycleDelta[k] = Math.round(tenYr + avoidedReserve - upgradeCost);
});
return {
type: pkg.type,
name: pkg.name,
price,
// Assumptions surfaced to the customer (spec §10 "show assumptions").
discountScenario: scen, // { low, base, high } discount % assumption
discountPct: scen.base,
serviceLifeYears: pkg.serviceLifeYears,
hailResistance: pkg.hailResistance,
valueView: VALUE_VIEW[pkg.type] || '',
upgradeCost,
avoidedReserve,
// Per-scenario outputs (null savings when not shown, for back-compat callers).
savings: showSavings ? savings : null,
annualSavings,
breakeven,
lifecycleDelta,
// Back-compat scalar (base scenario).
breakevenYears: breakeven.base,
};
});
return {
years: YEARS,
premiumKnown: !!premium,
conservativeMode: !!conservativeMode,
scenarios: SCEN_KEYS,
rows,
};
}
@@ -0,0 +1,87 @@
/**
* useWizardHailRisk — produces a frozen hail-risk snapshot for the wizard.
*
* Pipeline (all read-only against existing services):
* 1. geocode the address (module-local geocode.js → Nominatim)
* 2. GET /api/storm-history (EXISTING storm module endpoint — not modified)
* 3. run computeHailRisk() (module-local Poisson model, spec §6)
*
* Returns { loading, error, property, snapshot }. The caller freezes `snapshot`
* into the session (spec §12 hail_risk_snapshots) so the report stays immutable.
*/
import { useState, useEffect } from 'react';
import { geocodeAddress } from '../api/geocode';
import { computeHailRisk } from '../engine/hailProbability';
export function useWizardHailRisk({ address, property, enabled = true, stormSettings = {} }) {
const [loading, setLoading] = useState(enabled);
const [error, setError] = useState(null);
const [resolvedProperty, setResolvedProperty] = useState(property || null);
const [snapshot, setSnapshot] = useState(null);
const radiusMiles = stormSettings.radiusMiles ?? 20;
// storm-history caps months at 60 (5 years); window honesty is stored in the snapshot.
const months = 60;
const windowYears = months / 12;
useEffect(() => {
if (!enabled) return;
let cancelled = false;
setLoading(true);
setError(null);
(async () => {
try {
// 1. Resolve coordinates (reuse stored property if already geocoded).
let prop = property;
if (!prop || prop.lat == null || prop.lon == null) {
prop = await geocodeAddress(address);
}
if (cancelled) return;
setResolvedProperty(prop);
// 2. Existing keyless storm endpoint (read-only).
let hailCount = 0;
let maxHail = null;
let lastEventDate = null;
let source = 'NOAA / IEM Local Storm Reports';
try {
const res = await fetch(
`/api/storm-history?lat=${prop.lat}&lng=${prop.lon}&months=${months}&radiusMi=${Math.min(radiusMiles, 50)}`
);
if (res.ok) {
const data = await res.json();
hailCount = data?.summary?.byType?.hail ?? 0;
maxHail = data?.summary?.maxHail ?? null;
lastEventDate = data?.summary?.lastEvent?.date ?? null;
}
} catch {
// Leave defaults (count 0) — computeHailRisk handles the empty case honestly.
}
if (cancelled) return;
// 3. Conservative Poisson model.
const snap = computeHailRisk({
eventCount: hailCount,
windowYears,
radiusMiles,
geocodeConfidence: prop.confidence,
maxHailInches: maxHail,
lastEventDate,
source,
generatedAt: new Date().toISOString(),
});
if (!cancelled) setSnapshot(snap);
} catch (e) {
if (!cancelled) setError(e.message || 'Could not load storm history.');
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [address, enabled, radiusMiles]);
return { loading, error, property: resolvedProperty, snapshot };
}
@@ -0,0 +1,247 @@
/**
* wizardReportExport.js — branded customer report PDF (spec §11, §12 customer_reports).
*
* Sections (spec §11): cover, property summary, storm/hail snapshot, Good/Better/Best
* options, AI recommendation, 10-year comparison, selected add-ons, disclaimers
* (verbatim §15 template), next steps. Mirrors the app's jsPDF pattern (estimateExport.js).
*
* The same computed inputs drive the on-screen preview and the PDF so they match
* exactly (spec §18: "report generated by email and print must match the saved version").
*/
import { pricePackage, addonsTotal, formatUSD } from '../engine/pricing';
import { computeTenYearValue } from '../engine/valueModel';
const PKG_LABEL = { GOOD: 'Good', BETTER: 'Better', BEST: 'Best' };
function reportId(sessionId) {
return `RPT-${(sessionId || '').replace(/[^a-zA-Z0-9]/g, '').slice(-8).toUpperCase() || 'PREVIEW'}`;
}
/** Builds the structured report model — single source of truth for PDF + preview. */
export function buildReportModel(session, tenantConfig) {
const i = session.inputs;
const value = session.recommendation?.value || computeTenYearValue({ inputs: i, tenantConfig });
const recommended = session.recommendation?.selectedPackage || session.selectedPackage;
const selected = session.selectedPackage || recommended;
const selectedPkg = tenantConfig.packages.find((p) => p.type === selected);
const base = selectedPkg ? pricePackage(selectedPkg, tenantConfig.pricing).base : 0;
const addonsSum = addonsTotal(tenantConfig.addons, session.selectedAddons || []);
return {
reportId: reportId(session.id),
generatedAt: new Date().toISOString(),
branding: tenantConfig.branding,
disclaimer: tenantConfig.disclaimer,
contact: { name: i.name, address: i.address, phone: i.phone, email: i.email },
property: session.property,
roof: { ageYears: i.roofAgeYears, ownership: i.ownershipHorizon, issue: i.issueType, premium: i.premiumUnknown ? null : i.premiumAmount },
hail: session.hailSnapshot,
packages: tenantConfig.packages.map((p) => ({
type: p.type, name: p.name, category: p.category, warranty: p.warrantyLabel,
hailResistance: p.hailResistance, price: pricePackage(p, tenantConfig.pricing),
})),
recommended,
selected,
explanation: session.recommendation?.explanation || null,
reasons: session.recommendation?.reasons || [],
value,
addons: (tenantConfig.addons || []).filter((a) => (session.selectedAddons || []).includes(a.id)),
totals: { base, addonsSum, total: base + addonsSum },
};
}
export async function generateReportPdf(session, tenantConfig) {
const m = buildReportModel(session, tenantConfig);
const { default: jsPDF } = await import('jspdf');
const { default: autoTable } = await import('jspdf-autotable');
const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' });
const W = doc.internal.pageSize.getWidth();
const MARGIN = 14;
const accent = tenantConfig.branding.primaryColor || '#fda913';
let y = 0;
const text = (s, x, yy, opts) => doc.text(String(s ?? ''), x, yy, opts);
const ensure = (needed) => { if (y + needed > 285) { doc.addPage(); y = 18; } };
const heading = (label) => {
ensure(14);
doc.setFont('helvetica', 'bold'); doc.setFontSize(11);
doc.setTextColor(20); text(label, MARGIN, y); y += 2;
doc.setDrawColor(accent); doc.setLineWidth(0.6); doc.line(MARGIN, y, W - MARGIN, y); y += 6;
};
// ── Cover band ──
doc.setFillColor(17, 17, 19); doc.rect(0, 0, W, 34, 'F');
doc.setTextColor(255); doc.setFont('helvetica', 'bold'); doc.setFontSize(18);
text(m.branding.company, MARGIN, 16);
doc.setFont('helvetica', 'normal'); doc.setFontSize(10);
text('Your Roof Options Report', MARGIN, 24);
doc.setFontSize(8);
text(`${m.reportId} · ${new Date(m.generatedAt).toLocaleDateString()}`, MARGIN, 30);
y = 44;
doc.setTextColor(40); doc.setFont('helvetica', 'bold'); doc.setFontSize(12);
text(`Prepared for ${m.contact.name || 'Homeowner'}`, MARGIN, y); y += 6;
doc.setFont('helvetica', 'normal'); doc.setFontSize(9); doc.setTextColor(90);
text(m.contact.address || (m.property?.displayName ?? ''), MARGIN, y); y += 10;
// ── Property summary ──
heading('Property summary');
autoTable(doc, {
startY: y, theme: 'plain', margin: { left: MARGIN, right: MARGIN },
styles: { fontSize: 9, cellPadding: 1.5 },
body: [
['Roof age (estimated)', `${m.roof.ageYears ?? '—'} years`],
['Planned ownership', m.roof.ownership ? `${m.roof.ownership} years` : '—'],
['Current concern', m.roof.issue || '—'],
['Annual insurance premium', m.roof.premium ? formatUSD(m.roof.premium) : 'Not provided'],
],
columnStyles: { 0: { fontStyle: 'bold', cellWidth: 60, textColor: 60 }, 1: { textColor: 30 } },
});
y = doc.lastAutoTable.finalY + 8;
// ── Storm / hail snapshot ──
heading('Storm / hail risk snapshot (historical)');
doc.setFont('helvetica', 'normal'); doc.setFontSize(9); doc.setTextColor(60);
if (m.hail) {
const p = m.hail.probability10yr;
const lines = [
m.hail.wording?.probability || '',
`Annualized rate: ${m.hail.annualRate}/yr · 10-yr range: ${m.hail.eventCount === 0 ? '—' : `${p.low}${p.high}%`} · Confidence: ${m.hail.confidence}`,
`Source: ${m.hail.source} · ~${m.hail.radiusMiles} mi · ${m.hail.wording?.lastUpdated || ''}`,
];
lines.forEach((l) => { ensure(6); doc.splitTextToSize(l, W - MARGIN * 2).forEach((ln) => { ensure(5); text(ln, MARGIN, y); y += 4.6; }); });
} else {
text('Storm history will be reviewed by your rep during inspection.', MARGIN, y); y += 5;
}
y += 4;
// ── Good / Better / Best ──
heading('Your Good / Better / Best options');
autoTable(doc, {
startY: y, margin: { left: MARGIN, right: MARGIN },
head: [['Option', 'System', 'Warranty', 'Hail', 'Estimated range']],
body: m.packages.map((p) => [
PKG_LABEL[p.type] + (p.type === m.recommended ? ' ★' : ''),
p.name, p.warranty, p.hailResistance, `${formatUSD(p.price.low)}${formatUSD(p.price.high)}`,
]),
styles: { fontSize: 8.5, cellPadding: 2 },
headStyles: { fillColor: [24, 24, 27], textColor: 255 },
alternateRowStyles: { fillColor: [248, 248, 248] },
});
y = doc.lastAutoTable.finalY + 8;
// ── AI recommendation ──
heading(`Recommendation: ${PKG_LABEL[m.recommended] || '—'}`);
doc.setFont('helvetica', 'normal'); doc.setFontSize(9); doc.setTextColor(50);
if (m.explanation?.customerExplanation) {
doc.splitTextToSize(m.explanation.customerExplanation, W - MARGIN * 2).forEach((ln) => { ensure(5); text(ln, MARGIN, y); y += 4.8; });
y += 2;
}
m.reasons.forEach((r) => {
ensure(6);
doc.setTextColor(accent); text('•', MARGIN, y); doc.setTextColor(60);
doc.splitTextToSize(r, W - MARGIN * 2 - 4).forEach((ln, idx) => { ensure(5); text(ln, MARGIN + 4, y); if (idx >= 0) y += 4.6; });
});
y += 4;
// ── 10-year comparison ──
heading('10-year value comparison');
// Assumptions table (spec §10 "show assumptions").
autoTable(doc, {
startY: y, margin: { left: MARGIN, right: MARGIN },
head: [['Option', 'Upfront (base)', 'Discount assumption', 'Service life', 'Hail resistance', '10-yr value view']],
body: m.value.rows.map((r) => [
PKG_LABEL[r.type],
formatUSD(r.price.base),
`${r.discountScenario?.base ?? 0}% (if eligible)`,
`${r.serviceLifeYears}+ yrs`,
r.hailResistance,
r.valueView,
]),
styles: { fontSize: 8, cellPadding: 1.8 },
headStyles: { fillColor: [24, 24, 27], textColor: 255 },
});
y = doc.lastAutoTable.finalY + 4;
// Value outputs table — savings range, breakeven, lifecycle delta (model estimate).
autoTable(doc, {
startY: y, margin: { left: MARGIN, right: MARGIN },
head: [['Option', 'Est. 10-yr savings (lowhigh)', 'Upgrade breakeven', 'Lifecycle value delta (model est.)']],
body: m.value.rows.map((r) => [
PKG_LABEL[r.type],
r.savings ? `${formatUSD(r.savings.low)}${formatUSD(r.savings.high)}` : '—',
r.breakeven?.base != null ? `${r.breakeven.base} yrs` : '—',
r.savings ? formatUSD(r.lifecycleDelta?.base ?? 0) : '—',
]),
styles: { fontSize: 8, cellPadding: 1.8 },
headStyles: { fillColor: [24, 24, 27], textColor: 255 },
});
y = doc.lastAutoTable.finalY + 6;
doc.setFont('helvetica', 'italic'); doc.setFontSize(7.5); doc.setTextColor(120);
doc.splitTextToSize(
'Savings scenarios are illustrative model estimates, not guaranteed. They depend on carrier approval, policy '
+ 'terms, state rules, and documentation. Breakeven and lifecycle value delta are model estimates, not promises.',
W - MARGIN * 2,
).forEach((ln) => { ensure(5); text(ln, MARGIN, y); y += 4.2; });
y += 4;
// ── Selected add-ons ──
heading('Selected add-ons');
if (m.addons.length) {
autoTable(doc, {
startY: y, margin: { left: MARGIN, right: MARGIN },
body: m.addons.map((a) => [a.name, a.value, `+${formatUSD(a.price)}`]),
styles: { fontSize: 8.5, cellPadding: 2 },
columnStyles: { 1: { textColor: 90 }, 2: { halign: 'right', fontStyle: 'bold' } },
});
y = doc.lastAutoTable.finalY + 4;
} else {
doc.setFont('helvetica', 'normal'); doc.setFontSize(9); doc.setTextColor(90);
text('No add-ons selected.', MARGIN, y); y += 6;
}
ensure(8);
doc.setFont('helvetica', 'bold'); doc.setFontSize(10); doc.setTextColor(20);
text(`Estimated total (${PKG_LABEL[m.selected]} + add-ons): ~${formatUSD(m.totals.total)}`, MARGIN, y); y += 10;
// ── Disclaimers (verbatim) ──
heading('Important disclaimers');
doc.setFont('helvetica', 'normal'); doc.setFontSize(8); doc.setTextColor(90);
doc.splitTextToSize(m.disclaimer, W - MARGIN * 2).forEach((ln) => { ensure(5); text(ln, MARGIN, y); y += 4.2; });
y += 6;
// ── Next steps ──
heading('Next steps');
doc.setFont('helvetica', 'normal'); doc.setFontSize(9); doc.setTextColor(50);
['Book your free inspection to verify measurements and finalize pricing.',
`Call or text us at ${m.branding.phone}.`,
'Have your insurance documents handy if a hail claim may be relevant.'].forEach((s) => {
ensure(6); doc.setTextColor(accent); text('→', MARGIN, y); doc.setTextColor(50); text(s, MARGIN + 5, y); y += 5.5;
});
// Footer on every page
const pages = doc.internal.getNumberOfPages();
for (let pg = 1; pg <= pages; pg++) {
doc.setPage(pg);
doc.setFont('helvetica', 'normal'); doc.setFontSize(7); doc.setTextColor(150);
text(`${m.branding.company} · ${m.branding.phone} · ${m.reportId}`, MARGIN, 292);
text(`Page ${pg} of ${pages}`, W - MARGIN, 292, { align: 'right' });
}
const filename = `Roof-Options-${(m.contact.name || 'Report').replace(/\s+/g, '-')}-${m.reportId}.pdf`;
return { doc, filename, model: m };
}
export async function downloadReportPdf(session, tenantConfig) {
const { doc, filename, model } = await generateReportPdf(session, tenantConfig);
doc.save(filename);
return { filename, reportId: model.reportId };
}
export async function getReportBlobUrl(session, tenantConfig) {
const { doc, filename, model } = await generateReportPdf(session, tenantConfig);
const url = doc.output('bloburl');
return { url, filename, reportId: model.reportId };
}
+88
View File
@@ -0,0 +1,88 @@
# Payments Module — Stripe Hosted Checkout
A **completely independent** payments module using Stripe's official **hosted
Checkout** flow. No custom card forms, no card data ever touches this app —
the customer enters payment details only on Stripe's secure page.
> This is **real Stripe integration architecture**, not a simulation. It ships
> with credential **placeholders**; the moment real keys are added it goes live.
> Until then, the UI shows a clear "configure Stripe" notice.
## Flow
```
Customer → Pay Now
→ POST /api/payments/create-checkout-session (amount resolved server-side)
→ redirect to Stripe-hosted checkout page (session.url)
→ customer pays on stripe.com
→ Stripe redirects back:
success_url → /payments/success?session_id=… (verifies + records)
cancel_url → /payments/cancel
→ Stripe also POSTs /api/payments/webhook (authoritative record)
```
Statuses handled: **success**, **processing/pending** (async methods),
**failed**, and **cancelled**.
## Structure
```
api/payments/ # Vercel serverless functions (backend)
├── create-checkout-session.js # creates the Checkout Session, returns session.url
├── session-status.js # verifies a session for the return page
├── webhook.js # signature-verified Stripe event receiver
└── transactions.js # serves webhook-written history (Vercel KV)
src/modules/payments/ # frontend (self-contained)
├── config/stripeConfig.js # publishable key + isConfigured flag
├── services/checkoutService.js # startCheckout() + getSessionStatus()
├── context/PaymentProvider.jsx # history (server KV local), invoices
├── data/invoices.js # sample invoices (display only)
├── components/ # InvoiceList, TransactionHistory, ConfigNotice
├── pages/ # PaymentsPage, PaymentSuccess, PaymentCancelled
└── index.js
```
## Routes (registered in `src/App.jsx`)
| Path | Access | Purpose |
|---|---|---|
| `/portal/payments` | CUSTOMER | Invoice list + history |
| `/payments/success` | public | Stripe success return (verifies session) |
| `/payments/cancel` | public | Stripe cancel return |
The return pages are **public** on purpose: the full-page round-trip to Stripe
loses the app's in-memory auth session, so the return pages must render without
it. (With persisted/JWT auth in production this is seamless.)
## Configuration
Add to your environment (see repo `.env.example`):
```
VITE_STRIPE_PUBLISHABLE_KEY=pk_test_… # frontend (safe to expose)
STRIPE_SECRET_KEY=sk_test_… # backend only — never expose
STRIPE_WEBHOOK_SECRET=whsec_… # backend only — webhook signature
```
In Vercel: add `STRIPE_SECRET_KEY` + `STRIPE_WEBHOOK_SECRET` as Project env vars,
and `VITE_STRIPE_PUBLISHABLE_KEY` as a build-time var.
### Stripe Dashboard → Webhooks
- Endpoint: `https://<your-app>/api/payments/webhook`
- Events: `checkout.session.completed`, `checkout.session.async_payment_succeeded`,
`checkout.session.async_payment_failed`, `checkout.session.expired`
### Persistence
The webhook writes records to **Vercel KV** when connected (same optional pattern
as `api/hail-webhook.js`); without KV it logs and the frontend falls back to its
local record of the customer's own completed payments.
## Local testing
```
npm i # installs stripe + @stripe/stripe-js
# put test keys in .env, then:
vercel dev # runs the /api functions for real
# webhook:
stripe listen --forward-to localhost:3000/api/payments/webhook
```
`npm run dev` also bridges `create-checkout-session` and `session-status` via the
existing Vite dev-API plugin (needs test keys in `.env`).
## Dependencies added
- `@stripe/stripe-js` (frontend redirect fallback)
- `stripe` (backend Node SDK)
@@ -0,0 +1,142 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { FileText, Clock, Lock, Loader2, CheckCircle2 } from 'lucide-react';
import { toast } from 'sonner';
import { SpotlightCard } from '../../../components/SpotlightCard';
import { formatAmount } from '../data/invoices';
import { stripeConfig } from '../config/stripeConfig';
import { startCheckout } from '../services/checkoutService';
/**
* Lists outstanding invoices with a "Pay Now" button that redirects the
* customer to Stripe's hosted Checkout page. No card details are collected here.
*/
const InvoiceList = ({ invoices, paidIds = [], customerEmail }) => {
const navigate = useNavigate();
const [redirectingId, setRedirectingId] = useState(null);
const isPaid = (inv) => inv.status === 'paid' || paidIds.includes(inv.id);
const due = invoices.filter((i) => !isPaid(i));
const paid = invoices.filter(isPaid);
const handlePay = async (invoice) => {
if (redirectingId) return;
setRedirectingId(invoice.id);
// No Stripe keys → use the simulated hosted-checkout page so the demo
// works without credentials. With keys, redirect to real Stripe.
if (!stripeConfig.isConfigured) {
navigate(`/payments/demo-checkout?invoice=${encodeURIComponent(invoice.id)}`);
return;
}
try {
// On success this navigates away to Stripe; control won't return.
await startCheckout({
invoiceId: invoice.id,
customerEmail,
fallback: { title: invoice.title, amount: invoice.amount, currency: invoice.currency },
});
} catch (err) {
toast.error('Unable to start checkout', { description: err.message });
setRedirectingId(null);
}
};
return (
<div className="space-y-8">
<section>
<h3 className="text-lg font-bold mb-4 flex items-center text-zinc-800 dark:text-zinc-200">
<Clock className="mr-2 text-blue-500" size={20} /> Outstanding Invoices
</h3>
{due.length > 0 ? (
<div className="grid gap-4">
{due.map((inv) => {
const busy = redirectingId === inv.id;
return (
<SpotlightCard key={inv.id} className="p-5 md:p-6">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div className="flex items-start gap-4 min-w-0">
<div className="w-11 h-11 rounded-xl bg-blue-50 dark:bg-blue-900/20 flex items-center justify-center text-blue-600 dark:text-blue-400 border border-blue-100 dark:border-blue-500/20 shrink-0">
<FileText size={20} />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<h4 className="font-bold truncate">{inv.title}</h4>
<span className="text-[10px] font-mono text-zinc-400 shrink-0">
{inv.id}
</span>
</div>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1 line-clamp-2">
{inv.description}
</p>
<p className="text-xs text-zinc-400 mt-1">
Due {new Date(inv.dueDate).toLocaleDateString()} · {inv.property}
</p>
</div>
</div>
<div className="flex items-center justify-between md:flex-col md:items-end gap-3 md:gap-2 shrink-0">
<span className="text-xl font-black text-zinc-900 dark:text-white">
{formatAmount(inv.amount, inv.currency, stripeConfig.locale)}
</span>
<button
onClick={() => handlePay(inv)}
disabled={busy}
className="flex items-center gap-1.5 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-bold shadow-lg shadow-blue-500/20 transition-all disabled:opacity-60 disabled:cursor-not-allowed"
>
{busy ? (
<><Loader2 className="animate-spin" size={15} /> Redirecting</>
) : (
<><Lock size={14} /> Pay Now</>
)}
</button>
</div>
</div>
</SpotlightCard>
);
})}
</div>
) : (
<div className="p-8 text-center border border-dashed border-zinc-300 dark:border-zinc-700 rounded-xl text-zinc-500">
You're all caught up no outstanding invoices. 🎉
</div>
)}
</section>
{paid.length > 0 && (
<section>
<h3 className="text-lg font-bold mb-4 flex items-center text-zinc-800 dark:text-zinc-200">
<CheckCircle2 className="mr-2 text-emerald-500" size={20} /> Paid
</h3>
<div className="grid gap-3">
{paid.map((inv) => (
<div
key={inv.id}
className="flex items-center justify-between p-4 rounded-xl bg-zinc-50 dark:bg-zinc-900/40 border border-zinc-200 dark:border-white/5 opacity-80"
>
<div className="flex items-center gap-3 min-w-0">
<CheckCircle2 size={18} className="text-emerald-500 shrink-0" />
<div className="min-w-0">
<p className="text-sm font-medium truncate">{inv.title}</p>
<p className="text-[11px] font-mono text-zinc-400">{inv.id}</p>
</div>
</div>
<span className="text-sm font-bold text-zinc-500 dark:text-zinc-400 shrink-0">
{formatAmount(inv.amount, inv.currency, stripeConfig.locale)}
</span>
</div>
))}
</div>
</section>
)}
<p className="flex items-center justify-center gap-1.5 text-[11px] text-zinc-400 pt-2">
<Lock size={12} /> Payments are processed securely by Stripe. We never see or store your card details.
</p>
</div>
);
};
export default InvoiceList;
@@ -0,0 +1,156 @@
import React, { useMemo } from 'react';
import { AlertCircle, CalendarClock, FileText, Lock, Loader2 } from 'lucide-react';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import { SpotlightCard } from '../../../components/SpotlightCard';
import { formatAmount } from '../data/invoices';
import { stripeConfig } from '../config/stripeConfig';
import { startCheckout } from '../services/checkoutService';
/**
* Outstanding tab — summarizes everything the customer still owes: a headline
* total-due figure plus the list of unpaid invoices, with overdue ones flagged.
* Reads the same invoice/paid data as the "Make a Payment" tab so the two views
* never disagree.
*/
const OutstandingPanel = ({ invoices, paidIds = [], customerEmail }) => {
const navigate = useNavigate();
const [redirectingId, setRedirectingId] = useState(null);
const due = useMemo(
() =>
invoices
.filter((i) => !(i.status === 'paid' || paidIds.includes(i.id)))
.sort((a, b) => new Date(a.dueDate) - new Date(b.dueDate)),
[invoices, paidIds]
);
// Total owed, assuming all unpaid invoices share a currency (they do here).
const totalCents = due.reduce((sum, i) => sum + (i.amount || 0), 0);
const currency = due[0]?.currency || 'usd';
// Midnight today, so an invoice due today is not counted as overdue.
const today = new Date();
today.setHours(0, 0, 0, 0);
const isOverdue = (inv) => new Date(inv.dueDate) < today;
const overdueCount = due.filter(isOverdue).length;
const handlePay = async (invoice) => {
if (redirectingId) return;
setRedirectingId(invoice.id);
if (!stripeConfig.isConfigured) {
navigate(`/payments/demo-checkout?invoice=${encodeURIComponent(invoice.id)}`);
return;
}
try {
await startCheckout({
invoiceId: invoice.id,
customerEmail,
fallback: { title: invoice.title, amount: invoice.amount, currency: invoice.currency },
});
} catch (err) {
toast.error('Unable to start checkout', { description: err.message });
setRedirectingId(null);
}
};
if (due.length === 0) {
return (
<div className="p-10 text-center border border-dashed border-zinc-300 dark:border-zinc-700 rounded-xl text-zinc-500">
You have no outstanding balance everything is paid up. 🎉
</div>
);
}
return (
<div className="space-y-6">
{/* Total due summary */}
<SpotlightCard className="p-6 md:p-8">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<p className="text-xs font-bold uppercase tracking-wider text-zinc-500 dark:text-zinc-400">
Total Outstanding
</p>
<p className="text-4xl md:text-5xl font-black text-zinc-900 dark:text-white mt-1">
{formatAmount(totalCents, currency, stripeConfig.locale)}
</p>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-2">
Across {due.length} invoice{due.length === 1 ? '' : 's'}
{overdueCount > 0 && (
<span className="text-red-500 dark:text-red-400 font-semibold">
{' '}· {overdueCount} overdue
</span>
)}
</p>
</div>
<div className="w-16 h-16 rounded-full bg-amber-50 dark:bg-amber-900/20 flex items-center justify-center text-amber-500 border border-amber-100 dark:border-amber-500/20 shrink-0">
<CalendarClock size={30} />
</div>
</div>
</SpotlightCard>
{/* Outstanding invoice list */}
<div className="grid gap-4">
{due.map((inv) => {
const busy = redirectingId === inv.id;
const overdue = isOverdue(inv);
return (
<SpotlightCard key={inv.id} className="p-5 md:p-6">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div className="flex items-start gap-4 min-w-0">
<div className="w-11 h-11 rounded-xl bg-blue-50 dark:bg-blue-900/20 flex items-center justify-center text-blue-600 dark:text-blue-400 border border-blue-100 dark:border-blue-500/20 shrink-0">
<FileText size={20} />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<h4 className="font-bold truncate">{inv.title}</h4>
<span className="text-[10px] font-mono text-zinc-400 shrink-0">
{inv.id}
</span>
</div>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1 line-clamp-2">
{inv.description}
</p>
<p
className={`text-xs mt-1 flex items-center gap-1 ${
overdue
? 'text-red-500 dark:text-red-400 font-semibold'
: 'text-zinc-400'
}`}
>
{overdue && <AlertCircle size={12} />}
{overdue ? 'Overdue · ' : 'Due '}
{new Date(inv.dueDate).toLocaleDateString()}
</p>
</div>
</div>
<div className="flex items-center justify-between md:flex-col md:items-end gap-3 md:gap-2 shrink-0">
<span className="text-xl font-black text-zinc-900 dark:text-white">
{formatAmount(inv.amount, inv.currency, stripeConfig.locale)}
</span>
<button
onClick={() => handlePay(inv)}
disabled={busy}
className="flex items-center gap-1.5 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-bold shadow-lg shadow-blue-500/20 transition-all disabled:opacity-60 disabled:cursor-not-allowed"
>
{busy ? (
<><Loader2 className="animate-spin" size={15} /> Redirecting</>
) : (
<><Lock size={14} /> Pay Now</>
)}
</button>
</div>
</div>
</SpotlightCard>
);
})}
</div>
</div>
);
};
export default OutstandingPanel;
@@ -0,0 +1,33 @@
import React from 'react';
import { FlaskConical } from 'lucide-react';
/**
* Shown when no real Stripe publishable key is configured. Payments run through
* the simulated checkout (no real charge); adding keys switches to live Stripe.
*/
const StripeConfigNotice = () => (
<div className="rounded-2xl border border-amber-300/60 dark:border-amber-500/30 bg-amber-50 dark:bg-amber-500/10 p-4 md:p-5">
<div className="flex items-start gap-3">
<div className="w-9 h-9 rounded-xl bg-amber-400/20 text-amber-600 dark:text-amber-300 flex items-center justify-center shrink-0">
<FlaskConical size={18} />
</div>
<div className="min-w-0">
<p className="text-sm font-bold text-amber-800 dark:text-amber-200">
Demo mode simulated Stripe Checkout
</p>
<p className="text-xs text-amber-700/80 dark:text-amber-200/70 mt-1 leading-relaxed">
No Stripe keys are configured, so "Pay Now" runs a simulated checkout
(no real charge). Add the keys below to switch to live Stripe-hosted
Checkout no code changes needed:
</p>
<ul className="mt-2 space-y-1 text-[11px] font-mono text-amber-800 dark:text-amber-200/90">
<li>VITE_STRIPE_PUBLISHABLE_KEY <span className="opacity-60">(frontend)</span></li>
<li>STRIPE_SECRET_KEY <span className="opacity-60">(backend)</span></li>
<li>STRIPE_WEBHOOK_SECRET <span className="opacity-60">(backend / webhook)</span></li>
</ul>
</div>
</div>
</div>
);
export default StripeConfigNotice;
@@ -0,0 +1,73 @@
import React from 'react';
import { Receipt, CreditCard, XCircle } from 'lucide-react';
import { SpotlightCard } from '../../../components/SpotlightCard';
import { stripeConfig } from '../config/stripeConfig';
import { formatAmount } from '../data/invoices';
const STATUS_STYLES = {
paid: {
label: 'Paid',
chip: 'bg-emerald-100 dark:bg-emerald-500/20 text-emerald-700 dark:text-emerald-300',
icon: CreditCard,
iconWrap: 'bg-emerald-50 dark:bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-100 dark:border-emerald-500/20',
},
failed: {
label: 'Failed',
chip: 'bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-300',
icon: XCircle,
iconWrap: 'bg-red-50 dark:bg-red-500/10 text-red-600 dark:text-red-400 border-red-100 dark:border-red-500/20',
},
};
/**
* Lists payment records (merged server + local history). Read-only.
*/
const TransactionHistory = ({ transactions }) => {
if (!transactions.length) {
return (
<div className="p-10 text-center border border-dashed border-zinc-300 dark:border-zinc-700 rounded-xl text-zinc-500">
<Receipt size={28} className="mx-auto mb-3 opacity-50" />
No transactions yet. Completed payments will appear here.
</div>
);
}
return (
<div className="grid gap-3">
{transactions.map((t) => {
const style = STATUS_STYLES[t.status] || STATUS_STYLES.paid;
const Icon = style.icon;
return (
<SpotlightCard key={t.id} className="p-5">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-4 min-w-0">
<div className={`w-11 h-11 rounded-xl flex items-center justify-center border shrink-0 ${style.iconWrap}`}>
<Icon size={18} />
</div>
<div className="min-w-0">
<h4 className="font-bold truncate">{t.invoiceId || 'Payment'}</h4>
<p className="text-xs text-zinc-500 dark:text-zinc-400 mt-0.5">
{t.customerEmail ? `${t.customerEmail} · ` : ''}
{t.createdAt ? new Date(t.createdAt).toLocaleString() : ''}
</p>
<p className="text-[10px] font-mono text-zinc-400 mt-0.5 truncate">{t.id}</p>
</div>
</div>
<div className="text-right shrink-0">
<span className="block font-black text-zinc-900 dark:text-white">
{formatAmount(t.amount, t.currency || stripeConfig.currency, stripeConfig.locale)}
</span>
<span className={`inline-block mt-1 px-2.5 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wide ${style.chip}`}>
{style.label}
</span>
</div>
</div>
</SpotlightCard>
);
})}
</div>
);
};
export default TransactionHistory;
@@ -0,0 +1,39 @@
/**
* Stripe configuration (frontend) for the Payments module.
*
* Only the PUBLISHABLE key lives on the frontend — it is safe to expose. The
* Secret Key and Webhook Secret are backend-only (see /api/payments/*).
*
* Set in `.env` (see .env.example):
* VITE_STRIPE_PUBLISHABLE_KEY=pk_test_… or pk_live_…
*
* Until a real key is provided, `isConfigured` is false and the UI shows a
* clear "configure Stripe" notice rather than attempting a broken redirect.
*/
const PLACEHOLDER = 'pk_test_REPLACE_WITH_YOUR_PUBLISHABLE_KEY';
const publishableKey = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY || PLACEHOLDER;
export const stripeConfig = {
publishableKey,
// Base path for the payment API endpoints (Vercel serverless functions).
apiBase: '/api/payments',
// True only when a real publishable key is present.
isConfigured:
typeof publishableKey === 'string' &&
publishableKey.startsWith('pk_') &&
publishableKey !== PLACEHOLDER,
currency: 'usd',
locale: 'en-US',
merchant: {
name: 'LynkedUp Pro Roofing',
supportEmail: 'billing@lynkeduppro.com',
},
};
export default stripeConfig;
@@ -0,0 +1,84 @@
/**
* PaymentProvider — module-local state for the customer Payments portal.
*
* Invoices and transactions are sourced from the shared paymentStore (localStorage)
* so the customer sees payment requests created on the owner side, and payments
* made here flow back to the owner's Payment Management page. The server-side
* (webhook-written) transaction history is merged in on top when available.
*/
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
import { stripeConfig } from '../config/stripeConfig';
import {
loadInvoices,
loadTransactions,
saveTransactions,
subscribe,
} from '../data/paymentStore';
const mergeById = (a, b) => {
const map = new Map();
[...a, ...b].forEach((t) => {
if (t && t.id) map.set(t.id, { ...map.get(t.id), ...t });
});
return Array.from(map.values()).sort(
(x, y) => new Date(y.createdAt || 0) - new Date(x.createdAt || 0)
);
};
const PaymentContext = createContext(null);
export const PaymentProvider = ({ children }) => {
const [invoices, setInvoices] = useState(loadInvoices);
const [transactions, setTransactions] = useState(loadTransactions);
// Re-read from the shared store whenever the owner adds an invoice, marks
// one paid, or a payment is recorded (same-tab and cross-tab).
useEffect(
() =>
subscribe(() => {
setInvoices(loadInvoices());
setTransactions((prev) => mergeById(prev, loadTransactions()));
}),
[]
);
// Pull the server-side (webhook-written) history and merge it in.
useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await fetch(`${stripeConfig.apiBase}/transactions`);
if (!res.ok) return;
const { transactions: server = [] } = await res.json();
if (cancelled || !server.length) return;
setTransactions((prev) => mergeById(prev, server));
} catch {
/* offline / endpoint unavailable — local history is fine. */
}
})();
return () => {
cancelled = true;
};
}, []);
/** Record a transaction confirmed on the success return page. */
const recordTransaction = useCallback((txn) => {
if (!txn?.id) return;
setTransactions((prev) => {
const next = mergeById(prev, [txn]);
saveTransactions(next); // persists to the shared store + notifies the owner side
return next;
});
}, []);
const value = { invoices, transactions, recordTransaction };
return <PaymentContext.Provider value={value}>{children}</PaymentContext.Provider>;
};
export const usePayments = () => {
const ctx = useContext(PaymentContext);
if (!ctx) throw new Error('usePayments must be used within a <PaymentProvider>.');
return ctx;
};
+81
View File
@@ -0,0 +1,81 @@
/**
* Sample invoices for the customer to pay (display only). Amounts are stored in
* cents to match Stripe's API. The SERVER independently re-resolves the amount
* from its own copy (see api/payments/create-checkout-session.js) so the client
* can never alter what is charged — these values are for rendering the list.
*
* In production this list would come from the CRM/billing backend.
*/
export const INVOICES = [
{
id: 'INV-2041',
title: 'Roof Repair — Leak Detection & Shingle Replacement',
description: 'Emergency leak repair on north-facing slope, 12 shingles replaced.',
property: '2612 Dunwick Dr, Plano, TX 75023',
dueDate: '2026-06-15',
amount: 184500,
currency: 'usd',
},
{
id: 'INV-2039',
title: 'Full Roof Inspection & Storm Damage Report',
description: 'Comprehensive drone inspection with insurance-ready documentation.',
property: '2612 Dunwick Dr, Plano, TX 75023',
dueDate: '2026-05-25',
amount: 32500,
currency: 'usd',
},
{
id: 'INV-2012',
title: 'Gutter Cleaning & Maintenance Plan (Q1)',
description: 'Quarterly gutter clearing and downspout flush.',
property: '2612 Dunwick Dr, Plano, TX 75023',
dueDate: '2026-03-18',
amount: 14900,
currency: 'usd',
},
{
id: 'INV-2055',
title: 'Siding Replacement — West Wall',
description: 'Storm-damaged vinyl siding replacement, 3 panels.',
property: '2612 Dunwick Dr, Plano, TX 75023',
dueDate: '2026-07-10',
amount: 95000,
currency: 'usd',
},
{
id: 'INV-2061',
title: 'Chimney Flashing Repair & Sealant',
description: 'Re-flashed chimney base and applied weatherproof sealant.',
property: '2612 Dunwick Dr, Plano, TX 75023',
dueDate: '2026-05-30',
amount: 42500,
currency: 'usd',
},
{
id: 'INV-2063',
title: 'Skylight Installation — Living Room',
description: 'Supply and install one energy-efficient skylight unit.',
property: '2612 Dunwick Dr, Plano, TX 75023',
dueDate: '2026-06-28',
amount: 128000,
currency: 'usd',
},
{
id: 'INV-2067',
title: 'Annual Roof Maintenance Plan',
description: 'Twice-yearly inspection and minor repair coverage.',
property: '2612 Dunwick Dr, Plano, TX 75023',
dueDate: '2026-08-01',
amount: 56000,
currency: 'usd',
},
];
/** Format an integer cent amount into a localized currency string. */
export const formatAmount = (cents, currency = 'usd', locale = 'en-US') =>
new Intl.NumberFormat(locale, {
style: 'currency',
currency: (currency || 'usd').toUpperCase(),
}).format((cents || 0) / 100);
+140
View File
@@ -0,0 +1,140 @@
/**
* Shared payment store — the single source of truth that lets the OWNER/ADMIN
* side and the CUSTOMER side talk to each other in this demo (no backend).
*
* Both invoices and transactions live in localStorage so a payment request the
* owner creates appears in the customer's Outstanding list, and a payment the
* customer makes (or the owner marks manually) shows back on the owner's
* Payment Management page. In production these reads/writes would hit the CRM
* billing API instead — the component surface would stay the same.
*/
import { INVOICES } from './invoices';
const INVOICES_KEY = 'lynkeduppro.payments.invoices';
const TXNS_KEY = 'lynkeduppro.payments.transactions';
const UPDATE_EVENT = 'payments:updated';
/**
* Default customer mobile number for the demo. The owner/admin "New Payment
* Request" form prefills this so a request lands with the current demo
* customer. In production this comes from a customer lookup instead.
*/
export const DEMO_CUSTOMER_PHONE = '214-555-2600';
/** Compare two phone numbers by digits only (ignores spaces, dashes, etc.). */
export const normalizePhone = (phone) => String(phone || '').replace(/\D/g, '');
/**
* Does this invoice belong to the given customer phone? Invoices without a
* `customerPhone` are treated as unassigned and visible to any customer (keeps
* the original seed invoices working).
*/
export const invoiceMatchesCustomer = (invoice, phone) =>
!invoice.customerPhone ||
normalizePhone(invoice.customerPhone) === normalizePhone(phone);
const read = (key, fallback) => {
try {
const raw = localStorage.getItem(key);
return raw ? JSON.parse(raw) : fallback;
} catch {
return fallback;
}
};
const write = (key, value) => {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch {
/* localStorage unavailable (private mode) — best effort only. */
}
};
/** Notify same-tab listeners (the native `storage` event only fires cross-tab). */
const emit = () => {
try {
window.dispatchEvent(new Event(UPDATE_EVENT));
} catch {
/* non-browser / SSR — nothing to notify. */
}
};
/** Subscribe to any invoice/transaction change. Returns an unsubscribe fn. */
export const subscribe = (callback) => {
window.addEventListener(UPDATE_EVENT, callback);
window.addEventListener('storage', callback);
return () => {
window.removeEventListener(UPDATE_EVENT, callback);
window.removeEventListener('storage', callback);
};
};
/* ------------------------------- Invoices -------------------------------- */
/** Load invoices, seeding from the static sample list on first run. */
export const loadInvoices = () => {
const stored = read(INVOICES_KEY, null);
if (Array.isArray(stored)) return stored;
write(INVOICES_KEY, INVOICES);
return INVOICES;
};
export const saveInvoices = (list) => {
write(INVOICES_KEY, list);
emit();
};
export const getInvoice = (id) => loadInvoices().find((i) => i.id === id) || null;
/** Append a new invoice (payment request) and return it. */
export const addInvoice = (invoice) => {
const next = [...loadInvoices(), invoice];
saveInvoices(next);
return invoice;
};
/** Next sequential invoice id, e.g. "INV-2068". */
export const nextInvoiceId = () => {
const max = loadInvoices().reduce((m, inv) => {
const n = parseInt(String(inv.id).replace(/\D/g, ''), 10);
return Number.isFinite(n) && n > m ? n : m;
}, 2000);
return `INV-${max + 1}`;
};
/* ----------------------------- Transactions ------------------------------ */
export const loadTransactions = () => read(TXNS_KEY, []);
export const saveTransactions = (list) => {
write(TXNS_KEY, list);
emit();
};
/** Has this invoice been paid (by the customer or marked by the owner)? */
export const isInvoicePaid = (invoiceId) =>
loadTransactions().some((t) => t.invoiceId === invoiceId && t.status === 'paid');
/**
* Mark an invoice paid by writing a `paid` transaction — used both by the
* customer success flow and by the owner's manual "Mark as Paid" action.
* No-ops if a paid transaction for this invoice already exists.
*/
export const markInvoicePaid = (invoiceId, { method = 'manual' } = {}) => {
const inv = getInvoice(invoiceId);
if (!inv || isInvoicePaid(invoiceId)) return null;
const txn = {
id: `${method}-${invoiceId}-${Date.now()}`,
invoiceId,
amount: inv.amount,
currency: inv.currency,
customerEmail: null,
status: 'paid',
createdAt: new Date().toISOString(),
method,
};
saveTransactions([txn, ...loadTransactions()]);
return txn;
};
+13
View File
@@ -0,0 +1,13 @@
/**
* Payments Module — public surface.
*
* Self-contained Stripe Checkout integration. Import from here so the rest of
* the app never reaches into the module internals.
*/
export { default as PaymentsPage } from './pages/PaymentsPage';
export { default as PaymentSuccess } from './pages/PaymentSuccess';
export { default as PaymentCancelled } from './pages/PaymentCancelled';
export { default as DemoCheckout } from './pages/DemoCheckout';
export { PaymentProvider, usePayments } from './context/PaymentProvider';
export { stripeConfig } from './config/stripeConfig';
+130
View File
@@ -0,0 +1,130 @@
import React, { useState } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { Lock, Loader2, ArrowLeft, CheckCircle2 } from 'lucide-react';
import { formatAmount } from '../data/invoices';
import { getInvoice } from '../data/paymentStore';
import { stripeConfig } from '../config/stripeConfig';
/**
* Demo-only stand-in for Stripe's hosted Checkout page. Used ONLY when no
* Stripe keys are configured, so the flow (Pay Now → checkout → success →
* history) works for a demo without credentials. It collects no real card data
* and makes no charge. When real keys are added, this page is never reached —
* `startCheckout` redirects to the genuine Stripe-hosted page instead.
*
* Mirrors Stripe Checkout's two-column hosted layout for visual familiarity.
*/
const DemoCheckout = () => {
const [params] = useSearchParams();
const navigate = useNavigate();
const invoiceId = params.get('invoice');
const invoice = getInvoice(invoiceId);
const [paying, setPaying] = useState(false);
if (!invoice) {
return (
<Shell>
<p className="text-center text-zinc-500">Invoice not found.</p>
<BackButton onClick={() => navigate('/portal/payments')} />
</Shell>
);
}
const amountLabel = formatAmount(invoice.amount, invoice.currency, stripeConfig.locale);
const handlePay = () => {
if (paying) return;
setPaying(true);
// Simulate Stripe's processing latency, then return to the success page.
setTimeout(() => {
navigate(
`/payments/success?demo=1&invoice=${encodeURIComponent(invoice.id)}` +
`&amount=${invoice.amount}&currency=${invoice.currency}`
);
}, 1600);
};
return (
<Shell>
<div className="grid md:grid-cols-2 gap-8">
{/* Left: order summary (Stripe-style) */}
<div className="md:pr-8 md:border-r border-zinc-200 dark:border-white/10">
<p className="text-sm text-zinc-500 dark:text-zinc-400">{stripeConfig.merchant.name}</p>
<p className="text-3xl font-black mt-1">{amountLabel}</p>
<div className="mt-6 flex items-start justify-between gap-4 py-3 border-t border-zinc-100 dark:border-white/5">
<div className="min-w-0">
<p className="font-semibold truncate">{invoice.title}</p>
<p className="text-xs text-zinc-400 mt-0.5">{invoice.id}</p>
</div>
<span className="font-semibold shrink-0">{amountLabel}</span>
</div>
<div className="flex items-center justify-between py-3 border-t border-zinc-100 dark:border-white/5 text-sm">
<span className="text-zinc-500">Total due</span>
<span className="font-bold">{amountLabel}</span>
</div>
</div>
{/* Right: payment (read-only demo — no real card entry) */}
<div>
<p className="text-sm font-bold uppercase tracking-widest text-zinc-400 mb-3">
Pay with card
</p>
<div className="rounded-xl border border-zinc-200 dark:border-white/10 divide-y divide-zinc-100 dark:divide-white/5 bg-zinc-50 dark:bg-zinc-900/40 text-sm">
<Row label="Card number" value="4242 4242 4242 4242" />
<div className="grid grid-cols-2 divide-x divide-zinc-100 dark:divide-white/5">
<Row label="Expiry" value="12 / 34" />
<Row label="CVC" value="123" />
</div>
</div>
<button
onClick={handlePay}
disabled={paying}
className="mt-5 w-full flex items-center justify-center gap-2 px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-xl font-bold shadow-lg shadow-blue-500/20 transition-all disabled:opacity-60 disabled:cursor-not-allowed"
>
{paying ? (
<><Loader2 className="animate-spin" size={18} /> Processing</>
) : (
<><Lock size={16} /> Pay {amountLabel}</>
)}
</button>
<p className="flex items-center justify-center gap-1.5 text-[11px] text-zinc-400 mt-3">
<Lock size={12} /> Powered by Stripe (simulated)
</p>
{!paying && <BackButton onClick={() => navigate('/payments/cancel?invoice=' + invoice.id)} />}
</div>
</div>
</Shell>
);
};
const Shell = ({ children }) => (
<div className="min-h-screen bg-zinc-100 dark:bg-[#09090b] text-zinc-900 dark:text-white flex items-center justify-center p-6">
<div className="w-full max-w-3xl bg-white dark:bg-zinc-950 rounded-3xl shadow-xl border border-zinc-200 dark:border-white/10 p-6 md:p-10">
{children}
</div>
</div>
);
const Row = ({ label, value }) => (
<div className="flex items-center justify-between px-4 py-3">
<span className="text-zinc-400 text-xs uppercase tracking-wide">{label}</span>
<span className="font-mono">{value}</span>
</div>
);
const BackButton = ({ onClick }) => (
<button
onClick={onClick}
className="mt-4 w-full flex items-center justify-center gap-1.5 text-sm font-medium text-zinc-500 hover:text-zinc-900 dark:hover:text-white transition-colors"
>
<ArrowLeft size={15} /> Cancel and go back
</button>
);
export default DemoCheckout;
@@ -0,0 +1,39 @@
import React from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { Ban, ArrowLeft } from 'lucide-react';
import { SpotlightCard } from '../../../components/SpotlightCard';
/**
* Stripe redirects here (cancel_url) when the customer backs out of the hosted
* Checkout page without paying. Public route — no charge was made.
*/
const PaymentCancelled = () => {
const [params] = useSearchParams();
const navigate = useNavigate();
const invoice = params.get('invoice');
return (
<div className="min-h-screen bg-zinc-50 dark:bg-[#09090b] text-zinc-900 dark:text-white flex items-center justify-center p-6">
<div className="w-full max-w-lg">
<SpotlightCard className="p-8 text-center">
<div className="w-16 h-16 mx-auto rounded-full bg-zinc-100 dark:bg-white/5 flex items-center justify-center mb-4">
<Ban size={34} className="text-zinc-400" />
</div>
<h2 className="text-2xl font-black">Payment Cancelled</h2>
<p className="text-zinc-500 dark:text-zinc-400 mt-1">
No charge was made{invoice ? ` for ${invoice}` : ''}. You can complete the
payment whenever you're ready.
</p>
<button
onClick={() => navigate('/portal/payments')}
className="mt-7 w-full flex items-center justify-center gap-1.5 px-5 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-xl font-bold shadow-lg shadow-blue-500/20 transition-all"
>
<ArrowLeft size={16} /> Back to Payments
</button>
</SpotlightCard>
</div>
</div>
);
};
export default PaymentCancelled;
@@ -0,0 +1,201 @@
import React, { useEffect, useRef, useState } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { CheckCircle2, XCircle, Loader2, Clock, ArrowLeft, Receipt } from 'lucide-react';
import { SpotlightCard } from '../../../components/SpotlightCard';
import { stripeConfig } from '../config/stripeConfig';
import { formatAmount } from '../data/invoices';
import { getSessionStatus } from '../services/checkoutService';
import { usePayments } from '../context/PaymentProvider';
/**
* Stripe redirects here (success_url) after the customer leaves the hosted
* Checkout page. We verify the session server-side, then show + record the
* result. This route is PUBLIC so it renders even though the in-memory auth
* session was lost during the full-page round-trip to Stripe.
*
* Handled states: verifying → paid | pending (async) | failed | error.
*/
const PaymentSuccess = () => {
const [params] = useSearchParams();
const navigate = useNavigate();
const { recordTransaction } = usePayments();
const sessionId = params.get('session_id');
const isDemo = params.get('demo') === '1';
// Initialize from the URL so the effect never needs a synchronous setState
// (which would trigger cascading renders).
const [state, setState] = useState(() =>
isDemo ? 'paid' : sessionId ? 'verifying' : 'error'
); // verifying | paid | pending | failed | error
const [details, setDetails] = useState(() =>
isDemo
? {
id: `cs_demo_${Date.now()}`,
invoiceId: params.get('invoice'),
amountTotal: Number(params.get('amount')) || 0,
currency: params.get('currency') || stripeConfig.currency,
customerEmail: null,
}
: null
);
const [errorMsg, setErrorMsg] = useState(() =>
sessionId || isDemo ? '' : 'Missing session reference.'
);
const recordedRef = useRef(false);
useEffect(() => {
// Demo mode: no server to verify against — record the simulated payment.
if (isDemo) {
if (!recordedRef.current && details) {
recordedRef.current = true;
recordTransaction({
id: details.id,
invoiceId: details.invoiceId,
amount: details.amountTotal,
currency: details.currency,
customerEmail: null,
status: 'paid',
createdAt: new Date().toISOString(),
demo: true,
});
}
return;
}
if (!sessionId) return;
let cancelled = false;
(async () => {
try {
const data = await getSessionStatus(sessionId);
if (cancelled) return;
setDetails(data);
const paid = data.paymentStatus === 'paid' || data.status === 'complete';
if (paid) {
setState('paid');
if (!recordedRef.current) {
recordedRef.current = true;
recordTransaction({
id: data.id,
invoiceId: data.invoiceId,
amount: data.amountTotal,
currency: data.currency,
customerEmail: data.customerEmail,
status: 'paid',
createdAt: new Date().toISOString(),
});
}
} else if (data.status === 'open') {
// Payment still processing (e.g. async method).
setState('pending');
} else {
setState('failed');
}
} catch (err) {
if (cancelled) return;
setState('error');
setErrorMsg(err.message);
}
})();
return () => {
cancelled = true;
};
}, [sessionId, isDemo, details, recordTransaction]);
return (
<div className="min-h-screen bg-zinc-50 dark:bg-[#09090b] text-zinc-900 dark:text-white flex items-center justify-center p-6">
<div className="w-full max-w-lg">
<SpotlightCard className="p-8 text-center">
{state === 'verifying' && (
<Body
icon={<Loader2 size={36} className="animate-spin text-blue-500" />}
title="Confirming your payment…"
subtitle="Verifying with Stripe, one moment."
/>
)}
{state === 'pending' && (
<Body
icon={<Clock size={36} className="text-amber-500" />}
title="Payment processing"
subtitle="Your payment is being processed. We'll update your history once it settles."
/>
)}
{state === 'paid' && details && (
<>
<Body
icon={<CheckCircle2 size={36} className="text-emerald-500" />}
title="Payment Successful"
subtitle={`${formatAmount(details.amountTotal, details.currency, stripeConfig.locale)} paid to ${stripeConfig.merchant.name}.`}
/>
<Receipt2 details={details} />
</>
)}
{state === 'failed' && (
<Body
icon={<XCircle size={36} className="text-red-500" />}
title="Payment not completed"
subtitle="This payment didn't go through. You can try again from your invoices."
/>
)}
{state === 'error' && (
<Body
icon={<XCircle size={36} className="text-red-500" />}
title="Couldn't verify payment"
subtitle={errorMsg || 'Please check your transaction history in a moment.'}
/>
)}
<button
onClick={() => navigate('/portal/payments')}
className="mt-7 w-full flex items-center justify-center gap-1.5 px-5 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-xl font-bold shadow-lg shadow-blue-500/20 transition-all"
>
<ArrowLeft size={16} /> Back to Payments
</button>
</SpotlightCard>
</div>
</div>
);
};
const Body = ({ icon, title, subtitle }) => (
<>
<div className="w-16 h-16 mx-auto rounded-full bg-zinc-100 dark:bg-white/5 flex items-center justify-center mb-4">
{icon}
</div>
<h2 className="text-2xl font-black">{title}</h2>
<p className="text-zinc-500 dark:text-zinc-400 mt-1">{subtitle}</p>
</>
);
const Receipt2 = ({ details }) => {
const rows = [
{ label: 'Invoice', value: details.invoiceId || '—' },
{ label: 'Email', value: details.customerEmail || '—' },
{ label: 'Session', value: details.id, mono: true },
];
return (
<div className="mt-6 text-left rounded-2xl border border-zinc-200 dark:border-white/10 divide-y divide-zinc-100 dark:divide-white/5 overflow-hidden">
<div className="flex items-center gap-2 px-4 py-3 bg-zinc-50 dark:bg-zinc-900/40 text-xs font-bold uppercase tracking-widest text-zinc-400">
<Receipt size={14} /> Receipt
</div>
{rows.map((r) => (
<div key={r.label} className="flex items-start justify-between gap-4 px-4 py-3">
<span className="text-xs font-bold uppercase tracking-wide text-zinc-400 shrink-0">
{r.label}
</span>
<span className={`text-sm text-zinc-800 dark:text-zinc-200 text-right break-all ${r.mono ? 'font-mono text-xs' : 'font-medium'}`}>
{r.value}
</span>
</div>
))}
</div>
);
};
export default PaymentSuccess;
@@ -0,0 +1,92 @@
import React, { useState } from 'react';
import { CreditCard } from 'lucide-react';
import { useAuth } from '../../../context/AuthContext';
import { usePayments } from '../context/PaymentProvider';
import { invoiceMatchesCustomer } from '../data/paymentStore';
import InvoiceList from '../components/InvoiceList';
import OutstandingPanel from '../components/OutstandingPanel';
import TransactionHistory from '../components/TransactionHistory';
const TabButton = ({ id, label, activeTab, onSelect }) => (
<button
onClick={() => onSelect(id)}
className={`px-4 md:px-6 py-3 text-xs md:text-sm font-bold uppercase tracking-wider border-b-2 transition-all whitespace-nowrap ${
activeTab === id
? 'border-blue-500 text-blue-600 dark:text-blue-400'
: 'border-transparent text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300'
}`}
>
{label}
</button>
);
/**
* Payments module entry point (customer portal). Lists invoices to pay via
* Stripe-hosted Checkout and shows transaction history. Shares no state with
* the rest of the app beyond reading the logged-in user's email.
*/
const PaymentsPage = () => {
const { user } = useAuth();
const { invoices, transactions } = usePayments();
const [activeTab, setActiveTab] = useState('pay');
// Only show invoices addressed to this customer's mobile number (plus any
// unassigned seed invoices). Keeps one customer from seeing another's bills.
const myInvoices = invoices.filter((inv) => invoiceMatchesCustomer(inv, user?.phone));
// Invoices marked paid by the (server) transaction history.
const paidIds = transactions
.filter((t) => t.status === 'paid' && t.invoiceId)
.map((t) => t.invoiceId);
return (
<div className="min-h-screen bg-zinc-50 dark:bg-[#09090b] text-zinc-900 dark:text-white p-8 pt-24 transition-colors duration-300">
<div className="max-w-4xl mx-auto space-y-8">
{/* Header */}
<div className="flex items-center space-x-4 mb-8">
<div className="w-16 h-16 rounded-full bg-gradient-to-br from-blue-500 to-indigo-600 flex items-center justify-center text-white shadow-lg">
<CreditCard size={28} />
</div>
<div>
<h1 className="text-3xl font-black text-transparent bg-clip-text bg-gradient-to-r from-zinc-900 to-zinc-600 dark:from-white dark:to-white/60">
Payments
</h1>
<p className="text-zinc-500 dark:text-zinc-400">
Pay invoices securely via Stripe and review past transactions.
</p>
</div>
</div>
{/* Tabs */}
<div className="flex space-x-1 border-b border-zinc-200 dark:border-zinc-800 mb-6 overflow-x-auto scrollbar-hide">
<TabButton id="pay" label="Make a Payment" activeTab={activeTab} onSelect={setActiveTab} />
<TabButton id="outstanding" label="Outstanding" activeTab={activeTab} onSelect={setActiveTab} />
<TabButton id="history" label="Transaction History" activeTab={activeTab} onSelect={setActiveTab} />
</div>
{/* Content */}
<div className="animate-in fade-in slide-in-from-bottom-4 duration-500">
{activeTab === 'pay' && (
<InvoiceList
invoices={myInvoices}
paidIds={paidIds}
customerEmail={user?.email || user?.username}
/>
)}
{activeTab === 'outstanding' && (
<OutstandingPanel
invoices={myInvoices}
paidIds={paidIds}
customerEmail={user?.email || user?.username}
/>
)}
{activeTab === 'history' && (
<TransactionHistory transactions={transactions} />
)}
</div>
</div>
</div>
);
};
export default PaymentsPage;
@@ -0,0 +1,76 @@
/**
* Checkout service — the only place the frontend talks to Stripe / the payment
* API. The UI calls these functions; it never handles card data.
*
* `startCheckout` follows Stripe's recommended hosted-checkout flow:
* 1. Ask our backend to create a Checkout Session (amount resolved server-side).
* 2. Redirect the browser to the Stripe-hosted `session.url`.
* 3. Stripe handles payment, then redirects back to success_url / cancel_url.
*
* A Stripe.js `redirectToCheckout({ sessionId })` fallback (which uses the
* publishable key) is kept for older API behavior, but `session.url` is the
* current default and avoids loading Stripe.js for the redirect.
*/
import { loadStripe } from '@stripe/stripe-js';
import { stripeConfig } from '../config/stripeConfig';
let stripePromise;
const getStripe = () => {
if (!stripePromise) stripePromise = loadStripe(stripeConfig.publishableKey);
return stripePromise;
};
/**
* Create a session for an invoice and redirect to Stripe's hosted page.
* Throws (with a friendly message) if the backend isn't configured/reachable.
*/
export async function startCheckout({ invoiceId, customerEmail, fallback } = {}) {
if (!stripeConfig.isConfigured) {
const err = new Error(
'Stripe is not configured yet. Add your publishable key to enable payments.'
);
err.code = 'stripe_not_configured';
throw err;
}
const res = await fetch(`${stripeConfig.apiBase}/create-checkout-session`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
// `fallback` carries the invoice's amount/title for demo invoices the
// owner created in localStorage (the server's static map won't have them).
body: JSON.stringify({ invoiceId, customerEmail, fallback }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data.error || 'Could not start checkout. Please try again.');
}
// Preferred: redirect straight to the Stripe-hosted Checkout URL.
if (data.url) {
window.location.assign(data.url);
return;
}
// Fallback: Stripe.js redirect using the session id + publishable key.
const stripe = await getStripe();
if (!stripe) throw new Error('Unable to initialize Stripe.');
const { error } = await stripe.redirectToCheckout({ sessionId: data.id });
if (error) throw new Error(error.message);
}
/**
* Fetch the verified status of a completed Checkout Session (used by the
* success return page). Returns the trimmed status object from the backend.
*/
export async function getSessionStatus(sessionId) {
const res = await fetch(
`${stripeConfig.apiBase}/session-status?session_id=${encodeURIComponent(sessionId)}`
);
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data.error || 'Could not verify payment status.');
}
return data;
}
@@ -0,0 +1,256 @@
/**
* PublicAdLanding — the homeowner-facing ad lead-capture page (spec §4 Lead Form
* Builder, §9.2 example campaign). Rendered chrome-less (no CRM sidebar) at the
* public /ads/lp route, simulating where a Meta/Google/Nextdoor ad click lands.
*
* On submit it builds a fully-shaped ad_lead (with consent proof + a fresh
* speed-to-lead SLA clock) and hands it to the session store; opening
* /marketing#inbox afterwards shows it live at the top of the Unified Lead Inbox,
* closing the spec's loop (§5). Front-end demo only — no real platform or backend.
*/
import React, { useMemo, useState } from 'react';
import { useSearchParams, useNavigate, useLocation } from 'react-router-dom';
import {
ShieldCheck, CheckCircle2, ArrowRight, ArrowLeft, Loader2, Megaphone, Lock, Phone,
} from 'lucide-react';
import { toast } from 'sonner';
import { BLUEPRINT_BY_ID, BLUEPRINTS } from './data/blueprints.js';
import { getPlatform } from './data/platforms.js';
import { addPublicLead } from './data/sessionStore.js';
const CONSENT_TEXT =
'By submitting, I agree LynkedUp Roofing may contact me by call, text, and email about my request. Msg/data rates may apply. Reply STOP to opt out.';
const PRIVACY_URL = 'https://lynkeduppro.com/privacy';
const ROLES = [
{ id: 'homeowner', label: 'Homeowner' },
{ id: 'tenant', label: 'Tenant' },
{ id: 'property_manager', label: 'Property manager' },
];
const SERVICE_OPTIONS = [
{ id: 'storm', label: 'Storm / hail damage' },
{ id: 'emergency', label: 'Active leak (emergency)' },
{ id: 'replacement', label: 'Roof replacement' },
{ id: 'commercial', label: 'Commercial / property' },
];
const WINDOWS = ['Morning (811am)', 'Midday (11am2pm)', 'Afternoon (25pm)', 'Evening (after 5pm)'];
// Blueprint copy can carry merge tokens (e.g. "{{City}}"). Fill from the ad's ?city=
// param, fall back to a natural phrase, and strip any unknown token so a homeowner
// never sees a raw {{…}} placeholder.
const MERGE_FALLBACKS = { city: 'your area' };
function fillTokens(text, vars = {}) {
return text.replace(/\{\{\s*(\w+)\s*\}\}/g, (_, key) => {
const k = key.toLowerCase();
return (vars[k] && String(vars[k]).trim()) || MERGE_FALLBACKS[k] || '';
});
}
// text-base (16px) on mobile prevents iOS Safari's zoom-on-focus; tightens to 14px ≥sm.
const inputCls =
'w-full rounded-xl border border-zinc-300 dark:border-white/10 bg-white dark:bg-zinc-900 px-3.5 py-2.5 text-base sm:text-sm text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-amber-500';
export default function PublicAdLanding() {
const [params] = useSearchParams();
const navigate = useNavigate();
const location = useLocation();
// Only show the "Back" control when this page was reached via in-app navigation
// (i.e. a preview opened from the Ad Engine) — a real ad click lands with no app history.
const cameFromApp = location.key !== 'default';
const blueprint = BLUEPRINT_BY_ID[params.get('c')] || BLUEPRINTS[0];
const platformId = getPlatform(params.get('src')) ? params.get('src') : 'meta';
const platform = getPlatform(platformId);
// Resolve merge tokens in the ad copy (e.g. {{City}}) for display.
const mergeVars = { city: params.get('city') || '' };
// A creative preview can pass its own headline (?hl) and media image (?img).
const headline = params.get('hl') || fillTokens(blueprint.adCopy, mergeVars).split('.')[0];
const offerText = fillTokens(blueprint.offer, mergeVars);
const heroImg = params.get('img') || '';
const [imgErr, setImgErr] = useState(false);
const [form, setForm] = useState({
platform: platformId,
serviceType: blueprint.serviceType === 'referral' || blueprint.serviceType === 'maintenance' ? 'storm' : blueprint.serviceType,
name: '', phone: '', email: '', address: '',
role: 'homeowner', activeLeak: false, window: WINDOWS[0], consent: false,
});
const [submitting, setSubmitting] = useState(false);
const [submitted, setSubmitted] = useState(null);
const set = (patch) => setForm(f => ({ ...f, ...patch }));
const isEmergencyLike = form.serviceType === 'emergency' || form.serviceType === 'storm';
const valid = useMemo(() =>
form.name.trim() && form.phone.trim() && form.email.trim() && form.address.trim() && form.consent,
[form]);
const submit = (e) => {
e.preventDefault();
if (!valid) { toast.error('Please complete the required fields and consent.'); return; }
setSubmitting(true);
// Simulate the platform → webhook → CRM ingest latency.
setTimeout(() => {
const lead = addPublicLead(form);
setSubmitting(false);
setSubmitted(lead);
}, 900);
};
if (submitted) {
return (
<div className="min-h-[100dvh] w-full bg-zinc-50 dark:bg-[#09090b] flex items-center justify-center px-4 py-10">
<div className="max-w-md text-center">
<CheckCircle2 size={56} className="text-emerald-500 mx-auto mb-4" />
<h1 className="text-2xl font-bold text-zinc-900 dark:text-white">
Thanks{submitted.name ? `, ${submitted.name.split(' ')[0]}` : ''}!
</h1>
<p className="text-zinc-500 dark:text-zinc-400 mt-2">
We received your request and our team has been notified. A rep will reach out shortly to confirm your free inspection window.
</p>
<a href="tel:+19725550100"
className="mt-6 w-full sm:w-auto inline-flex items-center justify-center gap-1.5 rounded-xl bg-gradient-to-r from-amber-400 to-orange-500 text-white font-semibold px-5 py-3 sm:py-2.5 shadow-lg shadow-amber-500/25">
<Phone size={16} /> Need it sooner? Call (972) 555-0100
</a>
<p className="text-[11px] text-zinc-400 mt-4 flex items-center justify-center gap-1">
<ShieldCheck size={12} /> Your information is kept private and used only to contact you about this request.
</p>
{cameFromApp && (
<button
onClick={() => navigate(-1)}
className="mt-6 inline-flex items-center gap-1.5 text-sm font-semibold text-zinc-600 dark:text-zinc-300 hover:text-zinc-900 dark:hover:text-white transition-colors"
>
<ArrowLeft size={16} /> Back to Ad Engine
</button>
)}
</div>
</div>
);
}
return (
<div className="min-h-[100dvh] w-full bg-zinc-50 dark:bg-[#09090b]">
{/* Back to Ad Engine — only when opened as a preview from inside the CRM */}
{cameFromApp && (
<div className="max-w-2xl mx-auto px-4 pt-4">
<button
onClick={() => navigate(-1)}
className="inline-flex items-center gap-1.5 text-sm font-semibold text-zinc-600 dark:text-zinc-300 hover:text-zinc-900 dark:hover:text-white transition-colors"
>
<ArrowLeft size={16} /> Back to Ad Engine
</button>
</div>
)}
{/* Sponsored banner — simulates the ad context */}
<div className="w-full bg-zinc-900 text-white/80 text-[11px] py-1.5 px-4 flex items-center justify-center gap-1.5">
<Megaphone size={12} />
<span>Sponsored · {platform?.name} ad</span>
</div>
<div className="max-w-2xl mx-auto px-4 py-6 sm:py-12">
{/* Hero */}
<div className="text-center mb-6 sm:mb-7">
<div className="inline-flex items-center gap-2 mb-3">
<div className="p-2 rounded-xl bg-gradient-to-tr from-amber-400 to-orange-600 text-white shadow-lg shadow-amber-500/25">
<Megaphone size={20} />
</div>
<span className="font-bold text-lg text-zinc-900 dark:text-white">LynkedUp<span className="text-amber-500">Pro</span></span>
</div>
<h1 className="text-balance text-2xl leading-snug sm:text-3xl font-bold text-zinc-900 dark:text-white">{headline}{/[.?!]$/.test(headline) ? '' : '.'}</h1>
<p className="text-zinc-500 dark:text-zinc-400 mt-2 text-sm text-balance">{offerText} · Free, no-obligation.</p>
</div>
{/* Creative media (when previewing a creative with an attached image) */}
{heroImg && !imgErr && (
<img src={heroImg} alt="" onError={() => setImgErr(true)}
className="w-full h-44 sm:h-56 object-cover rounded-2xl mb-6 shadow-lg" />
)}
{/* Form */}
<form onSubmit={submit} className="rounded-2xl border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 p-4 sm:p-6 space-y-4 shadow-xl">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<Field label="Full name" required>
<input className={inputCls} value={form.name} onChange={e => set({ name: e.target.value })} placeholder="Jane Homeowner" />
</Field>
<Field label="Phone" required>
<input className={inputCls} value={form.phone} onChange={e => set({ phone: e.target.value })} placeholder="(972) 555-0100" />
</Field>
<Field label="Email" required>
<input className={inputCls} type="email" value={form.email} onChange={e => set({ email: e.target.value })} placeholder="you@example.com" />
</Field>
<Field label="Property address" required>
<input className={inputCls} value={form.address} onChange={e => set({ address: e.target.value })} placeholder="123 Main St, Plano, TX" />
</Field>
</div>
<Field label="What do you need?">
<div className="grid grid-cols-2 gap-2">
{SERVICE_OPTIONS.map(s => (
<button type="button" key={s.id} onClick={() => set({ serviceType: s.id })}
className={`flex items-center justify-center text-center leading-tight min-h-[3rem] px-3 py-2 rounded-xl border-2 text-sm sm:text-xs font-semibold transition-all ${form.serviceType === s.id ? 'border-amber-500 bg-amber-500/5 text-amber-700 dark:text-amber-400' : 'border-zinc-200 dark:border-white/10 text-zinc-600 dark:text-zinc-300'}`}>
{s.label}
</button>
))}
</div>
</Field>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<Field label="You are the…">
<select className={inputCls} value={form.role} onChange={e => set({ role: e.target.value })}>
{ROLES.map(r => <option key={r.id} value={r.id}>{r.label}</option>)}
</select>
</Field>
<Field label="Preferred contact window">
<select className={inputCls} value={form.window} onChange={e => set({ window: e.target.value })}>
{WINDOWS.map(w => <option key={w} value={w}>{w}</option>)}
</select>
</Field>
</div>
{isEmergencyLike && (
<label className="flex items-center gap-3 rounded-xl bg-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-500/20 px-3.5 py-3 cursor-pointer">
<input type="checkbox" checked={form.activeLeak} onChange={e => set({ activeLeak: e.target.checked })} className="w-5 h-5 shrink-0 accent-red-500" />
<span className="text-sm text-zinc-700 dark:text-zinc-200">I have an <b>active leak</b> right now (we'll prioritize this)</span>
</label>
)}
{/* Consent */}
<label className="flex items-start gap-3 cursor-pointer">
<input type="checkbox" checked={form.consent} onChange={e => set({ consent: e.target.checked })} className="w-5 h-5 mt-0.5 shrink-0 accent-amber-500" />
<span className="text-[11px] text-zinc-500 dark:text-zinc-400 leading-relaxed">
{CONSENT_TEXT} See our <a href={PRIVACY_URL} className="text-amber-600 underline">privacy policy</a>.
</span>
</label>
<button type="submit" disabled={submitting}
className="w-full inline-flex items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-amber-400 to-orange-500 text-white font-semibold px-5 py-3 shadow-lg shadow-amber-500/25 disabled:opacity-60">
{submitting ? <><Loader2 size={18} className="animate-spin" /> Sending</> : <>Request my free inspection <ArrowRight size={16} /></>}
</button>
<p className="text-[10px] text-zinc-400 flex items-center justify-center gap-1">
<Lock size={11} /> Your info is used only to contact you about this request.
</p>
</form>
<p className="text-center text-[11px] text-zinc-400 mt-4 flex items-center justify-center gap-1">
<ShieldCheck size={12} /> Consent &amp; privacy compliant · TCPA / CAN-SPAM aware
</p>
</div>
</div>
);
}
function Field({ label, required, children }) {
return (
<label className="block">
<span className="text-xs font-semibold text-zinc-600 dark:text-zinc-300 mb-1.5 block">
{label}{required && <span className="text-red-500"> *</span>}
</span>
{children}
</label>
);
}
@@ -0,0 +1,626 @@
/**
* SocialAdsContext — self-contained client state for the Social Ads Engine module.
*
* This is the front-end stand-in for the spec's backend entities (§7). All data
* lives in React memory (seeded from data/seed.js); actions mutate local state and
* fire sonner toasts so the demo feels live without any backend, OAuth, or webhooks.
* Kept fully separate from the global MockStore — this is an isolated new module,
* exactly like the Estimate Wizard. A reload resets to the seed.
*/
import React, { createContext, useContext, useState, useCallback, useMemo, useEffect } from 'react';
import { toast } from 'sonner';
import {
accounts as seedAccounts,
campaigns as seedCampaigns,
creatives as seedCreatives,
leadForms as seedLeadForms,
mediaAssets as seedMedia,
leads as seedLeads,
nurtureRules as seedRules,
audiences as seedAudiences,
conversations as seedConversations,
reviews as seedReviews,
journeys as seedJourneys,
anomalies as seedAnomalies,
consentVersions as seedConsentVersions,
conversionEvents as seedConversions,
approvals as seedApprovals,
analyticsDaily as seedAnalytics,
} from './data/seed.js';
import { getPublicLeads, subscribeToPublicLeads } from './data/sessionStore.js';
import { previewSegment, summarizeFilters } from './data/contactPool.js';
const SocialAdsContext = createContext(null);
const nextId = (prefix) => `${prefix}_${Math.floor(performance.now()).toString(36)}`;
const nowIso = () => new Date().toISOString();
// Seed leads + any leads captured on the public landing page this session. The SLA
// clock start (slaStartTs) is materialized here from each seed lead's slaSecondsAgo so
// the speed-to-lead timers show a realistic mix on load and tick in real time.
function initLeads() {
const now = Date.now();
const seeded = seedLeads.map(l => ({
...l,
slaStartTs: l.slaStartTs ?? now - (l.slaSecondsAgo || 0) * 1000,
}));
return [...getPublicLeads(), ...seeded];
}
// Conversion-event mapping for the closed loop (spec §5, §10.1). Module-scoped so the
// advanceLead callback stays stable.
const STAGE_EVENT = {
appointment: { eventName: 'AppointmentScheduled', category: 'Schedule / QualifiedLead' },
showed: { eventName: 'AppointmentCompleted', category: 'Show / QualifiedAppointment' },
estimate: { eventName: 'EstimateSent', category: 'SubmitApplication' },
won: { eventName: 'JobWon', category: 'Purchase / ClosedWon' },
};
export function SocialAdsProvider({ children }) {
const [accounts, setAccounts] = useState(seedAccounts);
// Backfill default spend guardrails (spec §3/§4) on seed campaigns that predate them.
const [campaigns, setCampaigns] = useState(() => seedCampaigns.map(c => ({
...c,
guardrails: c.guardrails || {
weeklyCap: c.budgetDaily * 7, monthlyCap: c.budgetDaily * 30,
cplThreshold: 120, alertPct: 80,
pauseOnDailyCap: true, pauseOnCplBreach: false, anomalyDetection: true,
},
})));
const [creatives, setCreatives] = useState(seedCreatives);
const [mediaAssets, setMediaAssets] = useState(seedMedia);
const [leadForms, setLeadForms] = useState(seedLeadForms);
const [leads, setLeads] = useState(initLeads);
const [nurtureRules, setNurtureRules] = useState(seedRules);
const [audiences, setAudiences] = useState(seedAudiences);
const [conversations, setConversations] = useState(seedConversations);
const [reviews, setReviews] = useState(seedReviews);
const [journeys] = useState(seedJourneys);
const [anomalies, setAnomalies] = useState(seedAnomalies);
const [consentVersions, setConsentVersions] = useState(seedConsentVersions);
const [conversionEvents, setConversionEvents] = useState(seedConversions);
const [approvals, setApprovals] = useState(seedApprovals);
const [analyticsDaily] = useState(seedAnalytics);
// Live-ingest public leads submitted on /ads/lp (same tab or another, via the
// session store's broadcast) — they appear at the top of the inbox immediately,
// no CRM reload. Dedup by id guards against re-mount + broadcast double-adds.
useEffect(() => subscribeToPublicLeads((lead) => {
setLeads(prev => (prev.some(l => l.id === lead.id) ? prev : [lead, ...prev]));
if (lead.triage?.urgency === 'high') {
toast.error(`New urgent lead: ${lead.name} — speed-to-lead SLA running`);
} else {
toast.success(`New ad lead: ${lead.name}`);
}
}), []);
// ── Connected Accounts ────────────────────────────────────────────────────
const toggleAccount = useCallback((id) => {
setAccounts(prev => prev.map(a => {
if (a.id !== id) return a;
const connecting = a.status !== 'connected';
toast[connecting ? 'success' : 'message'](
connecting ? `Connected ${a.name}` : `Disconnected ${a.name}`,
);
return connecting
? { ...a, status: 'connected', tokenStatus: 'healthy', webhookStatus: 'verified', lastSyncAt: nowIso() }
: { ...a, status: 'disconnected', tokenStatus: 'none', webhookStatus: 'not_configured' };
}));
}, []);
// Reconnect / refresh a platform token (spec §11 token-expiration recovery).
// Restores a healthy token, re-verifies the webhook, and re-grants default scopes
// if the prior grant was revoked/empty.
const reconnectAccount = useCallback((id) => {
setAccounts(prev => prev.map(a => {
if (a.id !== id) return a;
const expiry = new Date(Date.now() + 90 * 864e5).toISOString().slice(0, 10);
return {
...a, status: 'connected', tokenStatus: 'healthy', webhookStatus: 'verified',
lastSyncAt: nowIso(), tokenExpiresAt: expiry,
scopes: a.scopes?.length ? a.scopes : ['ads_read', 'leads_retrieval'],
};
}));
toast.success('Reconnected — token refreshed and permissions re-granted');
}, []);
const testWebhook = useCallback((id) => {
const acc = accounts.find(a => a.id === id);
if (!acc) return;
if (acc.status !== 'connected') { toast.error('Connect the account before testing its webhook.'); return; }
toast.promise(new Promise(res => setTimeout(res, 900)), {
loading: `Sending test lead to ${acc.short || acc.name}`,
success: 'Test lead received and mapped to CRM fields ✓',
error: 'Webhook test failed',
});
}, [accounts]);
// ── Campaigns ─────────────────────────────────────────────────────────────
const createCampaign = useCallback((draft) => {
const camp = { id: nextId('camp'), status: 'draft', approvalStatus: 'pending', ...draft };
setCampaigns(prev => [camp, ...prev]);
// Route the draft into the Compliance approval queue — it cannot publish until a
// reviewer approves this record, which flips campaign.approvalStatus (spec §6/§8).
setApprovals(prev => [{
id: nextId('apr'), type: 'campaign', refId: camp.id,
title: `${camp.name} — publish approval`, status: 'pending',
flags: [], submittedAt: nowIso(), reviewer: null,
}, ...prev]);
toast.success(`Campaign draft "${camp.name}" created — sent to Compliance for approval`);
return camp;
}, []);
const setCampaignStatus = useCallback((id, status) => {
setCampaigns(prev => prev.map(c => c.id === id ? { ...c, status } : c));
toast.message(`Campaign ${status}`);
}, []);
const publishCampaign = useCallback((id) => {
const camp = campaigns.find(c => c.id === id);
if (!camp) return;
if (camp.approvalStatus !== 'approved') {
toast.error('Cannot publish — campaign and creatives must be approved first.');
return;
}
setCampaigns(prev => prev.map(c => c.id === id ? { ...c, status: 'active' } : c));
toast.success(`"${camp.name}" published`);
}, [campaigns]);
// Manual-publish fallback (spec §5/§11): platforms without a full-publish API are set
// up by hand from a generated checklist, then marked live here. Same approval gate.
const completeManualSetup = useCallback((id) => {
const camp = campaigns.find(c => c.id === id);
if (!camp) return;
if (camp.approvalStatus !== 'approved') {
toast.error('Approve the campaign in Compliance before marking setup complete.');
return;
}
setCampaigns(prev => prev.map(c => c.id === id ? { ...c, status: 'active', manualSetup: true } : c));
toast.success(`"${camp.name}" marked live — manual setup complete`);
}, [campaigns]);
// ── Creatives ─────────────────────────────────────────────────────────────
const addCreative = useCallback((creative) => {
const c = { id: nextId('cre'), approvalStatus: 'pending', versions: [], ...creative };
setCreatives(prev => [c, ...prev]);
return c;
}, []);
// Snapshot the current copy as a version before applying an edit (spec §7 copy versions).
const snapshotVersion = (c) => ({
v: (c.versions?.length || 0) + 1,
headline: c.headline, body: c.body, description: c.description || '',
mediaAssetId: c.mediaAssetId || null,
policyStatus: c.policyStatus, policyFlags: c.policyFlags || [],
savedAt: nowIso(),
});
const setCreativeApproval = useCallback((id, approvalStatus) => {
setCreatives(prev => prev.map(c => c.id === id ? { ...c, approvalStatus } : c));
toast[approvalStatus === 'approved' ? 'success' : 'message'](`Creative ${approvalStatus}`);
}, []);
// Manual create/edit (spec §8/B2). Editing copy re-runs the guardrail and resets the
// creative to pending approval, since the reviewed text has changed.
const updateCreative = useCallback((id, patch) => {
setCreatives(prev => prev.map(c => c.id === id
? { ...c, ...patch, versions: [...(c.versions || []), snapshotVersion(c)], approvalStatus: 'pending' }
: c));
toast.success('Creative updated — previous version saved to history');
}, []);
// Revert to a prior version: snapshot the current copy first, then restore the chosen
// version. Re-enters approval since the live copy changed (spec §7/§8).
const revertCreative = useCallback((id, v) => {
setCreatives(prev => prev.map(c => {
if (c.id !== id) return c;
const target = (c.versions || []).find(x => x.v === v);
if (!target) return c;
return {
...c,
headline: target.headline, body: target.body, description: target.description,
mediaAssetId: target.mediaAssetId, policyStatus: target.policyStatus, policyFlags: target.policyFlags,
versions: [...(c.versions || []), snapshotVersion(c)],
approvalStatus: 'pending',
};
}));
toast.success(`Reverted to v${v} — sent back for approval`);
}, []);
// Media library (spec §7 media_asset_id). Uploads add an in-memory object-URL preview.
const addMediaAsset = useCallback((asset) => {
const a = { id: nextId('med'), type: 'image', ...asset };
setMediaAssets(prev => [a, ...prev]);
toast.success(`Added “${a.name}” to the media library`);
return a;
}, []);
// ── Lead Form Mapper (editable builder, spec §7 lead_forms + field_mappings) ──
const updateLeadForm = useCallback((id, patch) => {
setLeadForms(prev => prev.map(f => f.id === id ? { ...f, ...patch } : f));
}, []);
const updateFormField = useCallback((formId, source, patch) => {
setLeadForms(prev => prev.map(f => f.id === formId
? { ...f, fields: f.fields.map(fl => fl.source === source ? { ...fl, ...patch } : fl) }
: f));
}, []);
const addFormField = useCallback((formId, field) => {
const newField = field
? { transform: 'none', required: false, validation: '', ...field }
: { source: nextId('field'), target: '', required: false, transform: 'none', validation: '' };
setLeadForms(prev => prev.map(f => f.id === formId
? { ...f, fields: [...f.fields, newField] }
: f));
}, []);
const removeFormField = useCallback((formId, source) => {
setLeadForms(prev => prev.map(f => f.id === formId
? { ...f, fields: f.fields.filter(fl => fl.source !== source) }
: f));
}, []);
const toggleFormStatus = useCallback((id) => {
setLeadForms(prev => prev.map(f => {
if (f.id !== id) return f;
const status = f.status === 'live' ? 'draft' : 'live';
toast[status === 'live' ? 'success' : 'message'](`Form set to ${status}`);
return { ...f, status };
}));
}, []);
const addLeadForm = useCallback((form = {}) => {
const f = {
id: nextId('form'), campaignId: null, platform: form.platform || 'meta',
platformFormId: `manual_${nextId('lf')}`, title: form.title || 'New lead form', status: 'draft',
consentText: 'By submitting, I agree LynkedUp Roofing may contact me by call, text, and email about my request. Msg/data rates may apply. Reply STOP to opt out.',
privacyPolicyUrl: 'https://lynkeduppro.com/privacy',
thankYou: 'We received your request. A rep will reach out shortly to confirm your appointment window.',
fields: [
{ source: 'full_name', target: 'contact.name', required: true, transform: 'titlecase', validation: '' },
{ source: 'phone_number', target: 'contact.phone', required: true, transform: 'e164', validation: 'phone' },
{ source: 'email', target: 'contact.email', required: true, transform: 'lowercase', validation: 'email' },
],
};
setLeadForms(prev => [f, ...prev]);
toast.success(`Created “${f.title}” (draft)`);
return f;
}, []);
// ── Leads (Unified Inbox) ─────────────────────────────────────────────────
const assignLead = useCallback((id, repId) => {
// Assigning is first permitted contact → record speed-to-lead (spec §10).
const speed = 120 + Math.floor(performance.now() % 180); // demo: ~25 min
setLeads(prev => prev.map(l => l.id === id
? { ...l, assignedTo: repId, status: 'assigned', firstContactSec: l.firstContactSec ?? speed }
: l));
toast.success('Lead assigned — nurture playbook started');
}, []);
// Advance a lead one step through the lifecycle, emitting a deduped conversion
// event back to the source platform. This is what makes the dashboard KPIs live.
const advanceLead = useCallback((id, toStage, { value = 0, premium = false } = {}) => {
const lead = leads.find(l => l.id === id);
if (!lead) return;
const map = STAGE_EVENT[toStage];
setLeads(prev => prev.map(l => l.id === id
? { ...l, stage: toStage, dealValue: value || l.dealValue, premium: premium || l.premium }
: l));
if (map) {
const ev = {
id: nextId('ce'),
platform: lead.platform,
eventName: map.eventName,
entityType: toStage === 'won' || toStage === 'estimate' ? 'estimate' : 'opportunity',
entityId: lead.id,
value: toStage === 'won' ? value : 0,
currency: 'USD',
dedupKey: `${lead.platform}:${lead.externalLeadId}:${toStage}`,
syncStatus: 'synced',
syncedAt: nowIso(),
};
setConversionEvents(prev => [ev, ...prev]);
}
const labels = { appointment: 'Appointment scheduled', showed: 'Appointment completed (showed)', estimate: 'Estimate sent', won: `Job won — $${value.toLocaleString()}`, lost: 'Marked lost' };
toast.success(`${labels[toStage] || toStage} · synced to ${lead.platform}`);
}, [leads]);
const mergeLead = useCallback((id) => {
setLeads(prev => prev.map(l => l.id === id ? { ...l, status: 'merged' } : l));
toast.message('Duplicate merged into primary contact');
}, []);
const setLeadStatus = useCallback((id, status) => {
setLeads(prev => prev.map(l => l.id === id ? { ...l, status } : l));
}, []);
// Manual import fallback (spec §11): when a platform webhook/token is down, leads
// can be uploaded by CSV instead of being stranded. Imported leads land in the
// Unified Lead Inbox; consent must be explicit per row or it's manual-only.
const importLeads = useCallback((platform, rows) => {
const base = nextId('lead');
const newLeads = rows.map((r, i) => ({
id: `${base}_${i}`, platform, campaignId: null, formId: null,
externalLeadId: `manual_${base}_${i}`, receivedAt: nowIso(), status: 'new', stage: 'lead',
name: r.name || 'Imported lead', phone: r.phone || '', email: r.email || '', address: r.address || '',
consent: /^(y|yes|true|1)$/i.test(r.consent || ''), role: 'homeowner', activeLeak: false,
triage: {
serviceType: r.service || 'replacement', urgency: 'medium', role: 'homeowner',
intent: 'inquiry', quality: 60, confidence: 0.7,
},
duplicateOf: null, assignedTo: null, dealValue: 0, premium: false,
firstContactSec: null, slaStartTs: Date.now(), manualImport: true,
}));
setLeads(prev => [...newLeads, ...prev]);
toast.success(`Imported ${newLeads.length} lead${newLeads.length > 1 ? 's' : ''} — now in the Lead Inbox`);
return newLeads.length;
}, []);
// Low-confidence AI triage (spec §5): instead of auto-nurturing on a shaky
// classification, the admin either creates a rep review task or sends one
// clarifying question. Either holds the lead from auto-nurture (reviewAction flag).
const flagLeadReview = useCallback((id, mode) => {
setLeads(prev => prev.map(l => l.id === id ? { ...l, reviewAction: mode } : l));
toast.success(mode === 'question'
? 'Sent 1 clarifying question — lead held from auto-nurture until answered'
: 'Review task created for a rep — lead held from auto-nurture');
}, []);
// ── Conversations / Comment-DM triage (spec §4, §6) ────────────────────────
// Route a triaged comment/DM to the right human/queue. Criticism is never
// auto-handled — complaints route to a person (spec §6).
const routeConversation = useCallback((id, routedTo) => {
setConversations(prev => prev.map(c => c.id === id ? { ...c, status: 'routed', routedTo } : c));
toast.success(`Routed to ${routedTo}`);
}, []);
// Promote a lead-intent comment/DM into the Unified Lead Inbox. A social comment
// carries no form consent, so the lead is consent=false → manual follow-up only.
const convertConversationToLead = useCallback((id) => {
const cv = conversations.find(c => c.id === id);
if (!cv) return;
const lead = {
id: nextId('lead'), platform: cv.platform, campaignId: null, formId: null,
externalLeadId: cv.id, receivedAt: nowIso(), status: 'new', stage: 'lead',
name: cv.author, phone: '', email: '', address: '',
consent: false, role: 'homeowner', activeLeak: /leak/i.test(cv.text),
triage: {
serviceType: /commercial|flat roof|propert/i.test(cv.text) ? 'commercial' : /leak|emergency/i.test(cv.text) ? 'emergency' : 'replacement',
urgency: /leak|emergency|asap|urgent/i.test(cv.text) ? 'high' : 'medium',
role: /propert|facilit|manage/i.test(cv.text) ? 'property_manager' : 'homeowner',
intent: 'inquiry', quality: Math.round((cv.classification.confidence || 0.7) * 80), confidence: cv.classification.confidence || 0.7,
},
duplicateOf: null, assignedTo: null, dealValue: 0, premium: false,
firstContactSec: null, slaStartTs: Date.now(), fromConversation: true,
};
setLeads(prev => [lead, ...prev]);
setConversations(prev => prev.map(c => c.id === id ? { ...c, status: 'converted' } : c));
toast.success('Converted to lead — now in the Unified Lead Inbox (manual follow-up: no form consent)');
}, [conversations]);
const dismissConversation = useCallback((id) => {
setConversations(prev => prev.map(c => c.id === id ? { ...c, status: 'dismissed' } : c));
toast.message('Marked as spam and dismissed');
}, []);
const replyConversation = useCallback((id) => {
setConversations(prev => prev.map(c => c.id === id && c.status === 'new' ? { ...c, status: 'routed', routedTo: 'Replied (approved template)' } : c));
toast.success('Reply sent using an approved template');
}, []);
// ── Ad-to-Nurture rules ───────────────────────────────────────────────────
const toggleRule = useCallback((id) => {
setNurtureRules(prev => prev.map(r => r.id === id ? { ...r, enabled: !r.enabled } : r));
}, []);
// Editable routing rules (spec §8). Merges nested when/then patches.
const updateRule = useCallback((id, patch) => {
setNurtureRules(prev => prev.map(r => r.id === id ? {
...r, ...patch,
when: patch.when ? { ...r.when, ...patch.when } : r.when,
then: patch.then ? { ...r.then, ...patch.then } : r.then,
} : r));
}, []);
const addRule = useCallback(() => {
setNurtureRules(prev => {
const maxP = Math.max(0, ...prev.filter(r => r.priority !== 99).map(r => r.priority));
const rule = {
id: nextId('rule'), priority: maxP + 1, enabled: true,
when: { platform: 'any', serviceType: 'storm', campaignId: 'any', formId: 'any' },
then: { playbook: 'New Nurture', rep: 'Auto-assign', speedToLead: 'Call < 5 min', education: 'None', scheduling: 'Send scheduling link' },
};
return [...prev, rule];
});
toast.success('Routing rule added');
}, []);
const removeRule = useCallback((id) => {
setNurtureRules(prev => prev.filter(r => r.id !== id));
toast.message('Routing rule removed');
}, []);
// Add a rule from an AI nurture suggestion (spec §6 — admin accepts the recommendation).
const addRuleFromSuggestion = useCallback((when, then) => {
setNurtureRules(prev => {
const maxP = Math.max(0, ...prev.filter(r => r.priority !== 99).map(r => r.priority));
const rule = {
id: nextId('rule'), priority: maxP + 1, enabled: true,
when: { platform: 'any', serviceType: 'any', campaignId: 'any', formId: 'any', ...when },
then: { ...then },
};
return [...prev, rule];
});
toast.success('AI-suggested rule added');
}, []);
// Reorder priority by swapping with the adjacent non-default rule.
const moveRule = useCallback((id, dir) => {
setNurtureRules(prev => {
const movable = [...prev].filter(r => r.priority !== 99).sort((a, b) => a.priority - b.priority);
const i = movable.findIndex(r => r.id === id);
const j = i + dir;
if (i < 0 || j < 0 || j >= movable.length) return prev;
const a = movable[i], b = movable[j];
return prev.map(r => r.id === a.id ? { ...r, priority: b.priority }
: r.id === b.id ? { ...r, priority: a.priority } : r);
});
}, []);
// ── Audiences (segment builder, spec §12 E1/E2 + §8 preview count) ─────────
// Counts are computed from the mock CRM pool through previewSegment, which always
// applies consent + suppression — so the stored figures honor opt-outs (spec §6).
const buildAudience = (def, id) => {
const p = previewSegment(def.filterDef);
return {
id: id || nextId('aud'),
name: def.name?.trim() || 'New segment',
segmentType: def.segmentType || 'crm_stage',
filters: summarizeFilters(def.filterDef),
filterDef: def.filterDef,
totalCount: p.matched, consentCount: p.consented,
suppressionCount: p.suppressed, eligibleCount: p.eligible,
frequencyCap: def.filterDef.frequencyCap || null,
lastCount: nowIso().slice(0, 10),
syncStatus: 'draft', lastSyncedAt: null, syncDestination: null,
};
};
const createAudience = useCallback((def) => {
const a = buildAudience(def);
setAudiences(prev => [a, ...prev]);
toast.success(`Created “${a.name}” — ${a.eligibleCount.toLocaleString()} eligible after consent + suppression`);
return a;
}, []);
const updateAudience = useCallback((id, def) => {
setAudiences(prev => prev.map(a => a.id === id ? { ...a, ...buildAudience(def, id) } : a));
toast.success('Segment updated');
}, []);
// Persist the platform sync result (spec §8 "export/sync status"; §12 E2). Only the
// eligible (consented, non-suppressed) contacts are pushed to the platform audience.
const syncAudience = useCallback((id, destination) => {
setAudiences(prev => prev.map(a => a.id === id
? { ...a, syncStatus: 'synced', syncDestination: destination, lastSyncedAt: nowIso() }
: a));
toast.success(`Synced to ${destination} — eligible contacts only (consent + suppression applied)`);
}, []);
const refreshAudience = useCallback((id) => {
toast.promise(new Promise(res => setTimeout(res, 700)), {
loading: 'Recomputing audience with consent + suppression filters…',
success: 'Audience count refreshed ✓',
error: 'Refresh failed',
});
setAudiences(prev => prev.map(a => a.id === id ? { ...a, lastCount: nowIso().slice(0, 10) } : a));
}, []);
// ── Compliance / approvals ────────────────────────────────────────────────
// Resolving a Compliance item also flips the underlying entity's approval state, so
// an approved 'campaign' record unblocks publish and a 'creative' record stays in
// sync with Creative Studio (spec §6/§8 — an unapproved campaign cannot publish).
const resolveApproval = useCallback((id, decision) => {
const appr = approvals.find(a => a.id === id);
setApprovals(prev => prev.map(a => a.id === id ? { ...a, status: decision, reviewer: 'fe@lynkeduppro.com' } : a));
if (appr?.refId && appr.type === 'campaign') {
setCampaigns(prev => prev.map(c => c.id === appr.refId ? { ...c, approvalStatus: decision } : c));
}
if (appr?.refId && appr.type === 'creative') {
setCreatives(prev => prev.map(c => c.id === appr.refId ? { ...c, approvalStatus: decision } : c));
}
toast[decision === 'approved' ? 'success' : 'message'](
appr?.type === 'campaign'
? (decision === 'approved' ? 'Campaign approved — ready to publish' : `Campaign approval ${decision}`)
: `Item ${decision}`,
);
}, [approvals]);
// ── Conversion events ─────────────────────────────────────────────────────
const retryConversion = useCallback((id) => {
setConversionEvents(prev => prev.map(e => e.id === id ? { ...e, syncStatus: 'synced', syncedAt: nowIso() } : e));
toast.success('Conversion event re-synced ✓');
}, []);
// ── Anomaly alerts (spec §11) ─────────────────────────────────────────────
// AI recommends pause/review; the tenant executes. Pausing here also stops the
// underlying campaign, closing the optimization loop.
const resolveAnomaly = useCallback((id, decision) => {
const an = anomalies.find(a => a.id === id);
setAnomalies(prev => prev.map(a => a.id === id ? { ...a, status: decision } : a));
if (decision === 'paused' && an) {
setCampaigns(prev => prev.map(c => c.id === an.campaignId ? { ...c, status: 'paused' } : c));
}
toast[decision === 'dismissed' ? 'message' : 'success'](
decision === 'paused' ? 'Campaign paused on anomaly' : decision === 'reviewed' ? 'Anomaly marked reviewed' : 'Alert dismissed',
);
}, [anomalies]);
// ── Consent text versions (spec §8 "consent text versions") ───────────────
// Approving a pending version supersedes the form's prior live version, so only
// one approved consent language is ever live per form (spec §9).
const approveConsentVersion = useCallback((id) => {
setConsentVersions(prev => {
const target = prev.find(v => v.id === id);
if (!target) return prev;
return prev.map(v => {
if (v.id === id) return { ...v, status: 'approved', reviewer: 'fe@lynkeduppro.com' };
if (v.formId === target.formId && v.status === 'approved') return { ...v, status: 'superseded' };
return v;
});
});
toast.success('Consent text approved — now the live version for this form');
}, []);
const blockConsentVersion = useCallback((id) => {
setConsentVersions(prev => prev.map(v => v.id === id ? { ...v, status: 'blocked' } : v));
toast.message('Consent text version blocked');
}, []);
// ── Review usage permission + FTC disclosure (spec §8, §11) ───────────────
// A review used in an ad needs written usage permission AND an FTC disclosure;
// until both are on file the review creative is blocked from publishing.
const setReviewPermission = useCallback((id, usagePermission) => {
setReviews(prev => prev.map(r => r.id === id ? { ...r, usagePermission } : r));
toast[usagePermission === 'denied' ? 'message' : 'success'](`Review usage permission ${usagePermission}`);
}, []);
const setReviewDisclosure = useCallback((id, disclosureStatus) => {
setReviews(prev => prev.map(r => r.id === id ? { ...r, disclosureStatus } : r));
toast.success(`FTC disclosure marked ${disclosureStatus}`);
}, []);
const value = useMemo(() => ({
accounts, campaigns, creatives, mediaAssets, leadForms, leads, nurtureRules,
audiences, conversations, reviews, journeys, anomalies, consentVersions, conversionEvents, approvals, analyticsDaily,
toggleAccount, testWebhook, reconnectAccount,
createCampaign, setCampaignStatus, publishCampaign, completeManualSetup,
addCreative, setCreativeApproval, updateCreative, revertCreative, addMediaAsset,
updateLeadForm, updateFormField, addFormField, removeFormField, toggleFormStatus, addLeadForm,
assignLead, mergeLead, setLeadStatus, advanceLead, flagLeadReview, importLeads,
routeConversation, convertConversationToLead, dismissConversation, replyConversation,
toggleRule, updateRule, addRule, removeRule, moveRule, addRuleFromSuggestion,
createAudience, updateAudience, syncAudience, refreshAudience, resolveApproval, retryConversion, resolveAnomaly,
approveConsentVersion, blockConsentVersion, setReviewPermission, setReviewDisclosure,
}), [
accounts, campaigns, creatives, mediaAssets, leadForms, leads, nurtureRules,
audiences, conversations, reviews, journeys, anomalies, consentVersions, conversionEvents, approvals, analyticsDaily,
toggleAccount, testWebhook, reconnectAccount, createCampaign, setCampaignStatus, publishCampaign, completeManualSetup,
addCreative, setCreativeApproval, updateCreative, revertCreative, addMediaAsset,
updateLeadForm, updateFormField, addFormField, removeFormField, toggleFormStatus, addLeadForm,
assignLead, mergeLead, setLeadStatus, advanceLead, flagLeadReview, importLeads,
routeConversation, convertConversationToLead, dismissConversation, replyConversation,
toggleRule, updateRule, addRule, removeRule, moveRule, addRuleFromSuggestion,
createAudience, updateAudience, syncAudience, refreshAudience, resolveApproval, retryConversion, resolveAnomaly,
approveConsentVersion, blockConsentVersion, setReviewPermission, setReviewDisclosure,
]);
return <SocialAdsContext.Provider value={value}>{children}</SocialAdsContext.Provider>;
}
export function useSocialAds() {
const ctx = useContext(SocialAdsContext);
if (!ctx) throw new Error('useSocialAds must be used within SocialAdsProvider');
return ctx;
}
@@ -0,0 +1,116 @@
/**
* SocialAdsEngine — entry shell for the Social Media + Advertisement Engine module.
*
* Renders inside the CRM Layout (sidebar + chrome) at /marketing. A single horizontal
* tab bar switches between the spec's 10 admin screens (§8); each screen is a
* self-contained component reading from SocialAdsProvider. URL hash keeps the active
* tab deep-linkable and refresh-stable without adding ten router entries.
*/
import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
LayoutDashboard, Plug, Megaphone, Wand2, ClipboardList,
GitBranch, Inbox, Users, BarChart3, ShieldCheck,
} from 'lucide-react';
import { SocialAdsProvider } from './SocialAdsContext.jsx';
import MarketingHubHome from './screens/MarketingHubHome.jsx';
import ConnectedAccounts from './screens/ConnectedAccounts.jsx';
import CampaignBuilder from './screens/CampaignBuilder.jsx';
import CreativeStudio from './screens/CreativeStudio.jsx';
import LeadFormMapper from './screens/LeadFormMapper.jsx';
import AdToNurtureRules from './screens/AdToNurtureRules.jsx';
import UnifiedLeadInbox from './screens/UnifiedLeadInbox.jsx';
import AudienceSegments from './screens/AudienceSegments.jsx';
import AttributionDashboard from './screens/AttributionDashboard.jsx';
import ComplianceCenter from './screens/ComplianceCenter.jsx';
const TABS = [
{ id: 'home', label: 'Marketing Hub', icon: LayoutDashboard, Component: MarketingHubHome },
{ id: 'accounts', label: 'Connected Accounts', icon: Plug, Component: ConnectedAccounts },
{ id: 'campaigns', label: 'Campaign Builder', icon: Megaphone, Component: CampaignBuilder },
{ id: 'creative', label: 'Creative Studio', icon: Wand2, Component: CreativeStudio },
{ id: 'forms', label: 'Lead Form Mapper', icon: ClipboardList, Component: LeadFormMapper },
{ id: 'rules', label: 'Ad-to-Nurture', icon: GitBranch, Component: AdToNurtureRules },
{ id: 'inbox', label: 'Lead Inbox', icon: Inbox, Component: UnifiedLeadInbox },
{ id: 'audiences', label: 'Audiences', icon: Users, Component: AudienceSegments },
{ id: 'attribution', label: 'Attribution', icon: BarChart3, Component: AttributionDashboard },
{ id: 'compliance', label: 'Compliance', icon: ShieldCheck, Component: ComplianceCenter },
];
export default function SocialAdsEngine() {
const [active, setActive] = useState(() => {
const hash = window.location.hash.replace('#', '');
return TABS.some(t => t.id === hash) ? hash : 'home';
});
useEffect(() => {
window.history.replaceState(null, '', `#${active}`);
}, [active]);
const ActiveComponent = TABS.find(t => t.id === active)?.Component || MarketingHubHome;
return (
<SocialAdsProvider>
<div className="min-h-full bg-zinc-50 dark:bg-[#09090b]">
{/* Header */}
<div className="border-b border-zinc-200 dark:border-white/5 bg-white/60 dark:bg-zinc-900/40 backdrop-blur-md sticky top-0 z-30">
<div className="max-w-content mx-auto px-4 sm:px-6 pt-5 pb-0">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 rounded-xl bg-gradient-to-tr from-amber-400 to-orange-600 text-white shadow-lg shadow-amber-500/25">
<Megaphone size={22} />
</div>
<div>
<h1 className="text-xl sm:text-2xl font-bold text-zinc-900 dark:text-white">
Social Ads <span className="text-amber-500">Engine</span>
</h1>
<p className="text-xs text-zinc-500 dark:text-zinc-400">
Closed-loop acquisition campaign lead nurture job attribution
</p>
</div>
</div>
{/* Tab bar */}
<div className="flex gap-1 overflow-x-auto custom-scrollbar -mb-px">
{TABS.map(t => {
const Icon = t.icon;
const isActive = t.id === active;
return (
<button
key={t.id}
onClick={() => setActive(t.id)}
className={`relative flex items-center gap-1.5 px-3 py-2.5 text-xs sm:text-sm font-semibold whitespace-nowrap transition-colors ${isActive
? 'text-amber-600 dark:text-amber-400'
: 'text-zinc-500 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-white'}`}
>
<Icon size={15} />
{t.label}
{isActive && (
<motion.div layoutId="adEngineTab" className="absolute left-0 right-0 -bottom-px h-0.5 bg-amber-500 rounded-full" />
)}
</button>
);
})}
</div>
</div>
</div>
{/* Active screen — `isolate` creates a stacking context so the cards'
internal z-indexes stay contained below the sticky header above. */}
<div className="max-w-content mx-auto px-4 sm:px-6 py-6 isolate">
<AnimatePresence mode="wait">
<motion.div
key={active}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.2 }}
>
<ActiveComponent onNavigate={setActive} />
</motion.div>
</AnimatePresence>
</div>
</div>
</SocialAdsProvider>
);
}
@@ -0,0 +1,70 @@
/**
* audienceAdvisor.js — mock "Audience suggestion" AI (spec §6).
*
* Recommends the four ready-made retargeting/prospecting segments the spec calls
* out: past-customer maintenance retargeting, unsold-estimate retargeting,
* storm-event neighborhoods, and a commercial property-manager list. Each carries a
* filterDef (fed through previewSegment so consent + suppression always apply),
* visible AI reasoning, and a confidence score. Front-end demo: static recipes, no
* real model. Guardrail (spec §6): no sensitive-attribute targeting — only CRM fields.
*/
import { History, FileX2, CloudHail, Building2 } from 'lucide-react';
export const AUDIENCE_SUGGESTIONS = [
{
key: 'maint_retarget',
name: 'Past customers — maintenance due',
segmentType: 'reactivation',
icon: History,
confidence: 0.9,
filterDef: { stage: 'won', source: 'any', service: 'maintenance', geo: [], lastJobWithinDays: 540, frequencyCap: 2 },
rationale: 'Closed jobs aging 1218 months are prime for a maintenance/tune-up retarget before warranty lapses.',
reasons: [
'Targets won deals with a completed job in the last 18 months',
'Maintenance service interest → seasonal tune-up offer',
'Low frequency cap (2/wk) keeps existing customers warm, not spammed',
],
},
{
key: 'unsold_retarget',
name: 'Unsold estimates — retarget',
segmentType: 'crm_stage',
icon: FileX2,
confidence: 0.88,
filterDef: { stage: 'estimate', source: 'any', service: 'any', geo: [], lastJobWithinDays: 180, frequencyCap: 3 },
rationale: 'Estimates that never closed are the cheapest pipeline to reactivate with a financing or limited-offer nudge.',
reasons: [
'Targets the estimate stage that has not advanced to won',
'Recency < 180 days → still in-market',
'Pair with a financing / good-better-best creative',
],
},
{
key: 'storm_geo',
name: 'Storm-event neighborhoods',
segmentType: 'storm_geo',
icon: CloudHail,
confidence: 0.92,
filterDef: { stage: 'any', source: 'any', service: 'storm', geo: ['75023', '75025'], lastJobWithinDays: null, frequencyCap: 4 },
rationale: 'Hail-affected ZIPs concentrate high-intent inspection demand right after an event.',
reasons: [
'Geo-scoped to storm-hit ZIPs (75023 / 75025)',
'Storm service intent → inspection + insurance education',
'Higher cap (4/wk) to capture a short demand window',
],
},
{
key: 'commercial_pm',
name: 'Commercial property managers',
segmentType: 'b2b',
icon: Building2,
confidence: 0.85,
filterDef: { stage: 'any', source: 'linkedin', service: 'commercial', geo: [], lastJobWithinDays: null, frequencyCap: 2 },
rationale: 'B2B facilities/property managers from LinkedIn map to the commercial bid track with a decision-maker offer.',
reasons: [
'LinkedIn-sourced commercial contacts',
'Commercial service intent → capability statement offer',
'Lower cap (2/wk) suits a considered B2B buying cycle',
],
},
];
@@ -0,0 +1,28 @@
/**
* bannedClaims.js — compliance guardrail for AI/admin ad copy (spec §6, §11).
*
* The Compliance reviewer "flags missing opt-out, risky guarantee, undisclosed
* incentive, deceptive scarcity, or unsupported insurance claim" and blocks
* publish/send until resolved. This is a lightweight client-side stand-in: a set
* of regex rules that scan copy and return policy flags. Demo only — not legal advice.
*/
const RULES = [
{ id: 'guarantee', test: /\b(guarantee[d]?|100%|risk[-\s]?free|always|never fail)\b/i, severity: 'block', message: 'Unsupported guarantee / absolute claim.' },
{ id: 'scarcity', test: /\b(limited (slots|time|spots)|act now|last chance|only \d+ (left|spots)|hurry)\b/i, severity: 'block', message: 'Deceptive scarcity / pressure language.' },
{ id: 'price_claim', test: /\b(prices? (jump|rising|going up)|\d+% (off|discount)|cheapest|lowest price)\b/i, severity: 'block', message: 'Unverified discount / price claim.' },
{ id: 'insurance', test: /\b(insurance will (pay|cover)|free roof|we handle your claim|guaranteed approval)\b/i, severity: 'block', message: 'Unsupported insurance claim.' },
{ id: 'fear', test: /\b(disaster|catastrophe|your family.s safety|before it.s too late|danger)\b/i, severity: 'warn', message: 'Fear/panic language — soften per platform tone rules.' },
];
/**
* Scan ad copy. Returns { status: 'clear'|'review'|'blocked', flags: [{id,severity,message}] }.
*/
export function scanCopy(...parts) {
const text = parts.filter(Boolean).join(' ');
const flags = RULES.filter(r => r.test.test(text)).map(({ id, severity, message }) => ({ id, severity, message }));
const status = flags.some(f => f.severity === 'block')
? 'blocked'
: flags.length > 0 ? 'review' : 'clear';
return { status, flags };
}
@@ -0,0 +1,104 @@
/**
* briefGenerator.js — mock "AI Campaign Brief Generator" (spec §6).
*
* The admin types a natural-language brief ("storm inspection campaign for hail-hit
* ZIPs in Plano") and the assistant drafts objective, audience notes, CTA, ad copy,
* recommended form fields, and the nurture path. Everything is composed from the
* tenant-approved blueprint library and run through the bannedClaims guardrail (via
* generateVariants), and nothing is created until the admin approves it (spec §6).
*
* Front-end demo: deterministic keyword matching, no real LLM call.
*/
import { BLUEPRINT_BY_ID } from '../data/blueprints.js';
import { generateVariants } from './creativeGenerator.js';
export const OBJECTIVE_LABEL = {
lead_generation: 'Lead generation',
retargeting: 'Retargeting',
engagement: 'Engagement',
reactivation: 'Reactivation',
};
// Keyword → blueprint intent. First match wins, so order encodes priority.
const INTENT_RULES = [
{ id: 'bp_emergency', re: /\b(emergency|leak|leaking|tarp|urgent|flood)\b/i },
{ id: 'bp_commercial', re: /\b(commercial|property manager|facilit|building|b2b|portfolio|hoa)\b/i },
{ id: 'bp_storm', re: /\b(storm|hail|wind|tornado|hurricane|damage)\b/i },
{ id: 'bp_unsold', re: /\b(unsold|retarget|re-?target|follow.?up|warm lead|stalled|estimate)\b/i },
{ id: 'bp_review', re: /\b(review|referral|testimonial|word of mouth)\b/i },
{ id: 'bp_seasonal', re: /\b(seasonal|maintenance|reactivat|annual|check.?up|past customer)\b/i },
{ id: 'bp_retail', re: /\b(replace|replacement|new roof|aging|old roof|upgrade)\b/i },
];
const CTA = {
storm: 'Request a free inspection',
replacement: 'Get a free quote',
emergency: 'Get urgent help now',
commercial: 'Request a site review',
referral: 'Share your experience',
maintenance: 'Book a seasonal check-up',
};
const SERVICE_LABEL = {
storm: 'recent storm or hail activity',
replacement: 'an aging or failing roof',
emergency: 'an active roof leak',
commercial: 'commercial roofing needs',
referral: 'a recently completed job',
maintenance: 'overdue roof maintenance',
};
function detectBlueprint(text) {
for (const rule of INTENT_RULES) if (rule.re.test(text)) return BLUEPRINT_BY_ID[rule.id];
return BLUEPRINT_BY_ID.bp_storm;
}
// Pull a 5-digit ZIP or a capitalized place name ("... in Plano ...") from the prompt.
function detectGeo(text) {
const zip = text.match(/\b\d{5}\b/);
if (zip) return zip[0];
const city = text.match(/\bin ([A-Z][a-zA-Z.-]+(?: [A-Z][a-zA-Z.-]+)?)/);
return city ? city[1].trim() : '';
}
/** Draft a full campaign brief from a free-text prompt. */
export function generateBrief(prompt) {
const text = (prompt || '').trim();
const blueprint = detectBlueprint(text);
const geo = detectGeo(text);
const serviceType = blueprint.serviceType;
const platform = blueprint.bestChannels[0];
const city = geo || 'your area';
const variants = generateVariants({ platformId: platform, serviceType, city, count: 3 });
// Prefer a policy-clear variant; fall back to the first draft.
const adCopy = variants.find(v => v.policy.status === 'clear') || variants[0];
const audienceNotes =
`Households with ${SERVICE_LABEL[serviceType] || 'roofing needs'}${geo ? ` in/around ${geo}` : ''}. ` +
`Exclude existing customers and anyone on the suppression list; only consented contacts sync.`;
return {
prompt: text,
blueprintId: blueprint.id,
blueprintName: blueprint.name,
serviceType,
platform,
geo,
objective: blueprint.objective,
objectiveLabel: OBJECTIVE_LABEL[blueprint.objective] || blueprint.objective,
offer: blueprint.offer,
cta: CTA[serviceType] || 'Request a free inspection',
audienceNotes,
adCopy, // { headline, body, description, charCounts, policy, ... }
formFields: blueprint.formQuestions,
nurturePath: blueprint.nurture,
};
}
export const BRIEF_EXAMPLES = [
'Storm inspection campaign for hail-hit ZIPs in Plano',
'Same-day emergency leak response in Dallas',
'Commercial roof bids for property managers',
'Retarget unsold estimates with financing',
];
@@ -0,0 +1,61 @@
/**
* commentTriage.js — mock "Comment / DM triage" (spec §6).
*
* Classifies an inbound social comment or direct message as one of five intents:
* lead, complaint, spam, referral, or support. Front-end demo: keyword scoring, no
* real model. Guardrail (spec §6): AI never auto-deletes criticism — complaints are
* always routed to a human; only clear spam is dismissable.
*/
import { MessageSquare, AlertOctagon, Ban, Gift, LifeBuoy } from 'lucide-react';
export const CONVO_TYPES = {
lead: { label: 'Lead', tone: 'blue', icon: MessageSquare, action: 'Create lead', route: 'Sales / nurture' },
complaint: { label: 'Complaint', tone: 'red', icon: AlertOctagon, action: 'Route to human', route: 'Customer success (human)', human: true },
referral: { label: 'Referral', tone: 'emerald', icon: Gift, action: 'Send promoter path', route: 'Referral nurture' },
support: { label: 'Support', tone: 'amber', icon: LifeBuoy, action: 'Route to support', route: 'Support queue' },
spam: { label: 'Spam', tone: 'zinc', icon: Ban, action: 'Dismiss', route: null },
};
const SIGNALS = [
{ type: 'complaint', words: ['terrible', 'worst', 'scam', 'rude', 'refund', 'disappointed', 'avoid', 'never again', 'damaged my', 'complaint', 'unhappy', 'ripoff', 'overcharged'] },
{ type: 'spam', words: ['http://', 'https://', 'crypto', 'bitcoin', 'click here', 'free money', 'make $', 'loan', 'dm me to earn', 'followers', 'promo code 🔥'] },
{ type: 'referral', words: ['recommend', 'referral', 'referred', 'my neighbor', 'told my', 'a friend', 'gave them your', 'highly recommend', 'sent you'] },
{ type: 'support', words: ['warranty', 'reschedule', 'invoice', 'when will', 'appointment time', 'already a customer', 'my existing', 'follow up on my', 'still waiting', 'status of'] },
{ type: 'lead', words: ['quote', 'estimate', 'how much', 'price', 'interested', 'my roof', 'need a', 'leak', 'inspection', 'replace', 'cost to', 'do you service'] },
];
/** Classify a comment/DM. Returns { type, confidence (0-1), rationale }. */
export function classifyComment(text = '') {
const t = text.toLowerCase();
const scores = {};
for (const sig of SIGNALS) {
scores[sig.type] = sig.words.reduce((n, w) => n + (t.includes(w) ? 1 : 0), 0);
}
let best = 'support', hits = 0;
for (const [type, n] of Object.entries(scores)) {
if (n > hits) { hits = n; best = type; }
}
// No keyword hit at all → low-confidence support catch-all (never auto-dismiss).
if (hits === 0) return { type: 'support', confidence: 0.45, rationale: 'No strong intent signal — routed to a human to review.' };
const confidence = Math.min(0.97, 0.6 + hits * 0.12);
return { type: best, confidence, rationale: RATIONALE[best] };
}
const RATIONALE = {
lead: 'Buying-intent language (quote / price / my roof) → treat as a new lead.',
complaint: 'Negative-sentiment language detected → never auto-handle; route to a human.',
referral: 'Word-of-mouth language (recommend / referred) → promoter path.',
support: 'Existing-customer / service-status language → support queue.',
spam: 'Promotional / link-spam signals → dismissable, not a real lead.',
};
/** Suggested approved-template reply per type (spec §6: approved templates, human for complex). */
export function suggestReply(type) {
switch (type) {
case 'lead': return 'Thanks for reaching out! Wed love to help with your roof. Whats the best number to text you a quick quote?';
case 'referral': return 'That means a lot — thank you for the referral! Send them our way and well take great care of them.';
case 'support': return 'Thanks for the message — pulling up your account now. A team member will follow up shortly with the details.';
case 'complaint': return '(Routed to a human — complaints are handled personally, not by template.)';
default: return '';
}
}
@@ -0,0 +1,105 @@
/**
* creativeGenerator.js — mock "AI Creative Assistant" (spec §4, §6).
*
* The real engine drafts headlines/body/descriptions from approved tenant knowledge
* with platform tone + character limits. For the front-end demo this returns
* deterministic, platform-aware variants from a template bank — every output is then
* run through bannedClaims.scanCopy() so the compliance guardrail is exercised.
*
* AI cannot invent offers or guarantee outcomes (spec §2 non-negotiables); all copy
* here is generic and approval-gated downstream.
*/
import { getPlatform } from '../data/platforms.js';
import { scanCopy } from './bannedClaims.js';
// Platform character ceilings (approximate, demo values).
export const CHAR_LIMITS = {
meta: { headline: 40, body: 125, description: 30 },
google: { headline: 30, body: 90, description: 90 },
linkedin: { headline: 70, body: 150, description: 70 },
reddit: { headline: 60, body: 140, description: 0 },
whatsapp: { headline: 0, body: 1024, description: 0 },
nextdoor: { headline: 50, body: 130, description: 0 },
};
// Tone-shaped template bank per service type. {{City}} is a tenant merge field.
const BANK = {
storm: [
{ headline: 'Hail in {{City}}? Document the damage.', body: 'A free roof inspection documents visible hail damage, ventilation concerns, and your repair or replacement options. Request an inspection window.', description: 'Free inspection • Local crew' },
{ headline: 'Storm rolled through {{City}}', body: 'Our local team checks for hail bruising, lifted shingles, and ventilation issues, then walks you through the options. Same-week appointments.', description: 'Same-week appointments' },
{ headline: 'Free post-storm roof check', body: 'Not sure if the storm hurt your roof? We will document what we find and explain your choices clearly. No obligation.', description: 'Clear, honest options' },
{ headline: 'Hail check for {{City}} homes', body: 'A quick, no-obligation inspection tells you whether the recent storm caused damage worth addressing. We document everything with photos.', description: 'Photo-documented' },
{ headline: 'Worried about storm damage?', body: 'Our local crew inspects, photographs, and explains your repair or replacement options in plain language. Book a convenient window.', description: 'Plain-language options' },
],
replacement: [
{ headline: 'Roof replacement quotes — {{City}}', body: 'Compare Good / Better / Best options with transparent pricing and financing. Free, no-obligation quote.', description: 'Financing available' },
{ headline: 'Time for a new roof?', body: 'See how Class 4 impact-rated options protect your home and how financing changes the monthly number.', description: 'Good / Better / Best' },
{ headline: 'Transparent roof pricing', body: 'Know your options before you decide. Request a free quote and a side-by-side comparison.', description: 'No-obligation quote' },
{ headline: 'Planning a new roof?', body: 'Compare materials, warranties, and financing side by side. We help you choose what fits your home and budget.', description: 'Side-by-side options' },
{ headline: 'Roof nearing the end?', body: 'Get a clear replacement quote with Good / Better / Best choices and financing details so you can plan ahead.', description: 'Plan-ahead quote' },
],
emergency: [
{ headline: 'Roof leaking right now?', body: 'Our emergency crew can tarp and stop the damage fast. Request urgent help and keep your phone handy.', description: 'Same-day response' },
{ headline: 'Active leak? Get help fast', body: 'Send a safe photo and your address — our dispatcher will call to coordinate emergency tarping.', description: 'Emergency dispatch' },
{ headline: 'Stop the leak today', body: 'Our emergency team responds quickly to tarp and protect your home. Tell us where the leak is and we will coordinate the next step.', description: 'Quick response' },
{ headline: 'Roof emergency? We can help', body: 'Send your address and a safe photo — a dispatcher will call to arrange emergency tarping and protect your interior.', description: 'Dispatcher will call' },
{ headline: 'Water coming in?', body: 'Do not wait for more damage. Our crew can tarp the area quickly and assess what needs to happen next.', description: 'Limit the damage' },
],
commercial: [
{ headline: 'Commercial roof assessment + bid', body: 'Request a site review and receive a decision-maker checklist before your meeting. Capability statement on request.', description: 'For property managers' },
{ headline: 'Need a commercial roof bid?', body: 'Professional site assessment, scope documentation, and a clear bid package for owners and facilities teams.', description: 'Owners & facilities' },
{ headline: 'Commercial roof, clear bid', body: 'We document the scope, photograph problem areas, and deliver a bid package your team can act on. Schedule a site review.', description: 'Scope + bid package' },
{ headline: 'Property managers: roof help', body: 'Site assessments, capability statements, and clear timelines for owners and facilities teams. Request a review.', description: 'Owners & facilities' },
{ headline: 'Bid-ready roof assessments', body: 'Get a documented assessment and a decision-maker checklist before your next meeting.', description: 'Decision-maker checklist' },
],
referral: [
{ headline: 'Loved your new roof?', body: 'Share your experience with neighbors and pass along a referral if you know someone who needs us.', description: 'Neighborly referrals' },
{ headline: 'Happy with your roof?', body: 'A quick review helps your neighbors find a contractor they can trust — and we appreciate it.', description: 'Help your neighbors' },
{ headline: 'Know someone who needs us?', body: 'Pass along a referral. We will take great care of them, just like we did for you.', description: 'Refer a neighbor' },
{ headline: 'Share your experience', body: 'Tell other homeowners how your project went. Your honest review means a lot.', description: 'Your honest review' },
{ headline: 'Spread the word', body: 'If we did right by you, a quick review or referral helps your community find reliable roofing.', description: 'Community trust' },
],
maintenance: [
{ headline: 'Roof check-up due in {{City}}', body: 'It has been a while since your last inspection. Book a seasonal check-up and keep your warranty on track.', description: 'Annual inspection' },
{ headline: 'Time for a roof check-up?', body: 'A seasonal inspection catches small issues early and helps keep your warranty in good standing.', description: 'Catch issues early' },
{ headline: 'Keep your roof on track', body: 'Book a maintenance visit to check for wear, sealant, and ventilation before the next season.', description: 'Seasonal visit' },
{ headline: 'Roof maintenance reminder', body: 'It has been a while — a quick inspection now can prevent bigger repairs later.', description: 'Prevent bigger repairs' },
{ headline: 'Protect your investment', body: 'Regular check-ups extend roof life and keep warranties valid. Schedule yours today.', description: 'Extend roof life' },
],
};
function fit(text, limit) {
if (!limit || text.length <= limit) return text;
return text.slice(0, Math.max(0, limit - 1)).trimEnd() + '…';
}
/**
* Generate N platform-aware creative variants for a service type.
* Returns [{ headline, body, description, charCounts, policy }].
*/
export function generateVariants({ platformId, serviceType, city = 'your area', count = 3 }) {
const platform = getPlatform(platformId);
const limits = CHAR_LIMITS[platformId] || CHAR_LIMITS.meta;
const bank = BANK[serviceType] || BANK.storm;
return bank.slice(0, count).map((tpl) => {
const merge = (s) => s.replaceAll('{{City}}', city);
const headline = fit(merge(tpl.headline), limits.headline);
const body = fit(merge(tpl.body), limits.body);
const description = fit(merge(tpl.description), limits.description);
return {
headline,
body,
description,
aiGenerated: true,
platform: platformId,
platformTone: platform?.copyGuidance || '',
charCounts: {
headline: { used: headline.length, limit: limits.headline },
body: { used: body.length, limit: limits.body },
description: { used: description.length, limit: limits.description },
},
policy: scanCopy(headline, body, description),
};
});
}
@@ -0,0 +1,43 @@
/**
* nurtureAdvisor.js — mock "Next best nurture selection" (spec §6).
*
* Given a sample lead (platform + service), it first checks the deterministic routing
* rules — those override AI (spec §6). If only the catch-all default would catch the
* lead, it recommends a tailored nurture path the admin can add as a rule.
* Front-end demo: deterministic mapping, no real model.
*/
const PATHS = {
storm: { playbook: 'Storm Inspection Nurture', rep: 'Field agent (territory)', speedToLead: 'Text < 5 min', education: 'Storm checklist + insurance', scheduling: 'Auto-offer next available slot' },
emergency: { playbook: 'Emergency Dispatch Nurture', rep: 'On-call dispatcher', speedToLead: 'WhatsApp + call < 10 min', education: 'Leak mitigation tips', scheduling: 'Human callback within SLA' },
commercial: { playbook: 'Commercial Bid Nurture', rep: 'Commercial estimator', speedToLead: 'Email < 1 hr', education: 'Capability statement', scheduling: 'Manual booking by rep' },
replacement: { playbook: 'Retail Replacement Nurture', rep: 'Inside sales', speedToLead: 'Call < 5 min', education: 'Good/Better/Best + financing', scheduling: 'Send scheduling link' },
referral: { playbook: 'Completion Promoter Path', rep: 'Customer success', speedToLead: 'Email/SMS same day', education: 'Review + referral ask', scheduling: 'No scheduling (info only)' },
maintenance: { playbook: 'Reactivation Nurture', rep: 'Inside sales', speedToLead: 'Email < 1 day', education: 'Seasonal maintenance value', scheduling: 'Send scheduling link' },
};
const RATIONALE = {
storm: 'Storm / hail intent → fast field inspection with insurance education.',
emergency: 'Leak / emergency intent → human dispatch within the 10-minute SLA.',
commercial: 'Commercial / facilities intent → estimator with a decision-maker track.',
replacement: 'Replacement intent → inside sales with financing education.',
referral: 'Referral / review intent → promoter path, no hard scheduling.',
maintenance: 'Maintenance / reactivation intent → seasonal value nurture.',
};
/** First enabled rule (by priority) whose conditions match the lead; may be the default. */
export function matchRule(rules, { platform, serviceType }) {
const enabled = [...rules].filter(r => r.enabled).sort((a, b) => a.priority - b.priority);
for (const r of enabled) {
const pOk = !r.when.platform || r.when.platform === 'any' || r.when.platform === platform;
const sOk = !r.when.serviceType || r.when.serviceType === 'any' || r.when.serviceType === serviceType;
if (pOk && sOk) return { rule: r, isDefault: r.priority === 99 };
}
return { rule: null, isDefault: false };
}
/** Recommend a nurture path for a service type. Returns { then, rationale }. */
export function recommendPath(serviceType) {
const t = PATHS[serviceType] ? serviceType : 'storm';
return { then: PATHS[t], rationale: RATIONALE[t] };
}
@@ -0,0 +1,63 @@
/**
* questionOptimizer.js — mock "AI Lead form question optimizer" (spec §6).
*
* Suggests 24 qualifying questions for a lead form based on its service type
* (owner/tenant, urgency, address, insurance status, etc). Guardrails (spec §6):
* - never request sensitive data (SSN, income, DOB, financial details);
* - don't over-friction the form — capped at 4 suggestions, and the UI warns when a
* form already has many fields.
* Front-end demo: deterministic, derived from the blueprint service type.
*/
// Sensitive fields the optimizer will never suggest (documented for the guardrail note).
export const SENSITIVE_EXCLUDED = ['SSN', 'income', 'date of birth', 'credit score', 'bank details'];
const GENERAL = [
{ source: 'owner_or_tenant', target: 'lead.role', label: 'Are you the homeowner or tenant?', transform: 'none', validation: '', rationale: 'Routes decision-makers vs. renters to the right rep.' },
{ source: 'service_urgency', target: 'lead.urgency', label: 'How urgent is this?', transform: 'none', validation: '', rationale: 'Drives the speed-to-lead SLA and triage priority.' },
{ source: 'property_address', target: 'property.address', label: 'Property address', transform: 'none', validation: 'required', rationale: 'Confirms the service area and enables routing.' },
];
const BY_SERVICE = {
storm: [
{ source: 'active_leak', target: 'lead.urgencyFlag', label: 'Do you have an active leak right now?', transform: 'bool', validation: '', rationale: 'Flags emergencies for faster dispatch.' },
{ source: 'storm_date', target: 'lead.stormDate', label: 'Storm date (if known)', transform: 'date', validation: '', rationale: 'Supports insurance documentation windows.' },
{ source: 'insurance_status', target: 'lead.insuranceStatus', label: 'Have you filed an insurance claim yet?', transform: 'none', validation: '', rationale: 'Qualifies insurance-track leads (claim status only — not financial data).' },
],
replacement: [
{ source: 'roof_age', target: 'lead.roofAge', label: 'Approximate roof age?', transform: 'int', validation: '', rationale: 'Separates replacement-ready from early research.' },
{ source: 'financing_interest', target: 'lead.financingInterest', label: 'Interested in financing options?', transform: 'bool', validation: '', rationale: 'Surfaces financing intent without asking for financial details.' },
{ source: 'timeline', target: 'lead.timeline', label: 'What is your project timeline?', transform: 'none', validation: '', rationale: 'Prioritizes near-term buyers.' },
],
emergency: [
{ source: 'active_leak', target: 'lead.urgencyFlag', label: 'Is water coming in right now?', transform: 'bool', validation: '', rationale: 'Triggers emergency dispatch within the SLA.' },
{ source: 'best_callback', target: 'contact.phone', label: 'Best callback number?', transform: 'e164', validation: 'phone', rationale: 'Enables immediate human escalation.' },
],
commercial: [
{ source: 'property_type', target: 'lead.propertyType', label: 'What type of property is it?', transform: 'none', validation: '', rationale: 'Qualifies the commercial scope.' },
{ source: 'square_footage', target: 'lead.squareFootage', label: 'Approximate square footage?', transform: 'int', validation: '', rationale: 'Sizes the bid and assigns the right estimator.' },
{ source: 'decision_timeline', target: 'lead.timeline', label: 'Decision timeline?', transform: 'none', validation: '', rationale: 'Prioritizes active buyers.' },
],
referral: [],
maintenance: [
{ source: 'past_customer', target: 'lead.pastCustomer', label: 'Are you a past customer?', transform: 'bool', validation: '', rationale: 'Routes reactivation vs. new prospect.' },
{ source: 'warranty_status', target: 'lead.warrantyStatus', label: 'Is your warranty still active?', transform: 'none', validation: '', rationale: 'Targets warranty-driven check-ups.' },
],
};
/**
* Suggest up to 4 qualifying questions not already on the form.
* Returns [{ source, target, label, transform, validation, rationale }].
*/
export function suggestQuestions(form, serviceType = 'storm') {
const existing = new Set((form.fields || []).flatMap(f => [f.source, f.target]));
const pool = [...(BY_SERVICE[serviceType] || []), ...GENERAL];
const picks = [];
for (const q of pool) {
if (existing.has(q.source) || existing.has(q.target)) continue;
if (picks.some(p => p.target === q.target || p.source === q.source)) continue;
picks.push(q);
if (picks.length >= 4) break;
}
return picks;
}
@@ -0,0 +1,164 @@
/**
* ui.jsx — shared presentational primitives for the Social Ads Engine screens.
*
* Reuses the CRM's SpotlightCard look and Tailwind tokens (zinc surfaces, amber/blue
* accents) so the module feels native to the rest of the app. Pure UI, no state.
*/
import React from 'react';
import { SpotlightCard } from '../../../components/SpotlightCard';
import { getPlatform, CAPABILITY_LABEL } from '../data/platforms.js';
// ── Card ───────────────────────────────────────────────────────────────────
export function Card({ title, subtitle, icon: Icon, action, children, className = '', pad = 'p-5' }) {
return (
<SpotlightCard className={`h-full ${className}`}>
<div className={`${pad} flex-1 flex flex-col`}>
{(title || action) && (
<div className="flex items-start justify-between mb-4 gap-3">
<div className="flex items-center gap-2 min-w-0">
{Icon && <Icon size={18} className="text-amber-500 shrink-0" />}
<div className="min-w-0">
{title && <h3 className="text-base font-bold text-zinc-900 dark:text-white truncate">{title}</h3>}
{subtitle && <p className="text-xs text-zinc-500 dark:text-zinc-400">{subtitle}</p>}
</div>
</div>
{action}
</div>
)}
{children}
</div>
</SpotlightCard>
);
}
// ── KPI tile ─────────────────────────────────────────────────────────────────
const KPI_COLORS = {
blue: 'text-blue-500 bg-blue-500/10',
emerald: 'text-emerald-500 bg-emerald-500/10',
amber: 'text-amber-500 bg-amber-500/10',
red: 'text-red-500 bg-red-500/10',
purple: 'text-purple-500 bg-purple-500/10',
cyan: 'text-cyan-500 bg-cyan-500/10',
};
export function KpiTile({ label, value, sub, icon: Icon, color = 'blue', trend }) {
const cls = KPI_COLORS[color] || KPI_COLORS.blue;
const [text, bg] = cls.split(' ');
return (
<SpotlightCard className="h-full">
<div className="p-4 sm:p-5 flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-[11px] font-semibold uppercase tracking-wider text-zinc-500 dark:text-zinc-400 truncate">{label}</p>
<p className={`mt-1.5 text-2xl font-mono font-bold ${text} tracking-tight`}>{value}</p>
{sub && <p className="text-[11px] text-zinc-400 mt-1">{sub}</p>}
{typeof trend === 'number' && (
<span className={`text-[11px] font-semibold ${trend >= 0 ? 'text-emerald-500' : 'text-red-500'}`}>
{trend >= 0 ? '▲' : '▼'} {Math.abs(trend)}%
</span>
)}
</div>
{Icon && <div className={`p-2.5 rounded-xl ${bg} ${text} shrink-0`}><Icon size={20} /></div>}
</div>
</SpotlightCard>
);
}
// ── Platform badge ───────────────────────────────────────────────────────────
export function PlatformBadge({ platformId, size = 'md', showName = true }) {
const p = getPlatform(platformId);
if (!p) return null;
const Icon = p.icon;
const dim = size === 'sm' ? 14 : 16;
return (
<span className="inline-flex items-center gap-1.5 font-semibold text-zinc-700 dark:text-zinc-200">
<span className="inline-flex items-center justify-center rounded-md p-1" style={{ backgroundColor: `${p.color}1A`, color: p.color }}>
<Icon size={dim} />
</span>
{showName && <span className={size === 'sm' ? 'text-xs' : 'text-sm'}>{p.short}</span>}
</span>
);
}
// ── Generic status chip ──────────────────────────────────────────────────────
const CHIP = {
// account / generic
connected: 'bg-emerald-100 dark:bg-emerald-500/15 text-emerald-700 dark:text-emerald-400',
disconnected: 'bg-zinc-100 dark:bg-zinc-700/40 text-zinc-500 dark:text-zinc-400',
action_required: 'bg-amber-100 dark:bg-amber-500/15 text-amber-700 dark:text-amber-400',
// campaign
active: 'bg-emerald-100 dark:bg-emerald-500/15 text-emerald-700 dark:text-emerald-400',
paused: 'bg-amber-100 dark:bg-amber-500/15 text-amber-700 dark:text-amber-400',
draft: 'bg-zinc-100 dark:bg-zinc-700/40 text-zinc-500 dark:text-zinc-300',
// approval / policy
approved: 'bg-emerald-100 dark:bg-emerald-500/15 text-emerald-700 dark:text-emerald-400',
pending: 'bg-amber-100 dark:bg-amber-500/15 text-amber-700 dark:text-amber-400',
review: 'bg-amber-100 dark:bg-amber-500/15 text-amber-700 dark:text-amber-400',
blocked: 'bg-red-100 dark:bg-red-500/15 text-red-700 dark:text-red-400',
clear: 'bg-emerald-100 dark:bg-emerald-500/15 text-emerald-700 dark:text-emerald-400',
// lead / sync
new: 'bg-blue-100 dark:bg-blue-500/15 text-blue-700 dark:text-blue-400',
assigned: 'bg-emerald-100 dark:bg-emerald-500/15 text-emerald-700 dark:text-emerald-400',
merged: 'bg-zinc-100 dark:bg-zinc-700/40 text-zinc-500 dark:text-zinc-400',
synced: 'bg-emerald-100 dark:bg-emerald-500/15 text-emerald-700 dark:text-emerald-400',
queued: 'bg-amber-100 dark:bg-amber-500/15 text-amber-700 dark:text-amber-400',
failed: 'bg-red-100 dark:bg-red-500/15 text-red-700 dark:text-red-400',
};
export function StatusChip({ status, label }) {
const cls = CHIP[status] || CHIP.draft;
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider ${cls}`}>
{label || String(status).replace(/_/g, ' ')}
</span>
);
}
// ── Capability badge (spec §11 — honest automation level) ──────────────────────
export function CapabilityBadge({ capability, active }) {
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded-md text-[10px] font-semibold border ${active
? 'border-emerald-300 dark:border-emerald-500/30 text-emerald-700 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-500/10'
: 'border-zinc-200 dark:border-zinc-700 text-zinc-400 dark:text-zinc-600 line-through'}`}>
{CAPABILITY_LABEL[capability] || capability}
</span>
);
}
// ── Section header for screens ─────────────────────────────────────────────────
export function SectionHeader({ icon: Icon, title, description, children }) {
return (
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-5">
<div className="flex items-center gap-3">
{Icon && (
<div className="p-2 rounded-xl bg-amber-500/10 text-amber-500 shrink-0">
<Icon size={22} />
</div>
)}
<div>
<h2 className="text-lg sm:text-xl font-bold text-zinc-900 dark:text-white">{title}</h2>
{description && <p className="text-xs sm:text-sm text-zinc-500 dark:text-zinc-400">{description}</p>}
</div>
</div>
{children && <div className="flex items-center gap-2 shrink-0">{children}</div>}
</div>
);
}
// ── Small button ───────────────────────────────────────────────────────────────
export function Btn({ children, onClick, variant = 'primary', size = 'md', icon: Icon, disabled, type = 'button', className = '', title }) {
const base = 'inline-flex items-center justify-center gap-1.5 font-semibold rounded-xl transition-all focus:outline-none focus:ring-2 focus:ring-amber-500 disabled:opacity-40 disabled:cursor-not-allowed';
const sizes = { sm: 'px-2.5 py-1.5 text-xs', md: 'px-4 py-2 text-sm' };
const variants = {
primary: 'bg-gradient-to-r from-amber-400 to-orange-500 text-white shadow-lg shadow-amber-500/25 hover:shadow-amber-500/40',
ghost: 'text-zinc-600 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-white/10',
outline: 'border border-zinc-200 dark:border-white/15 text-zinc-700 dark:text-zinc-200 hover:bg-zinc-100 dark:hover:bg-white/5',
danger: 'bg-red-500/10 text-red-600 dark:text-red-400 hover:bg-red-500/20',
success: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-500/20',
};
return (
<button type={type} title={title} onClick={onClick} disabled={disabled} className={`${base} ${sizes[size]} ${variants[variant]} ${className}`}>
{Icon && <Icon size={size === 'sm' ? 14 : 16} />}
{children}
</button>
);
}
@@ -0,0 +1,113 @@
/**
* blueprints.js — prebuilt roofing/construction campaign recipes (spec §4, §9).
*
* These are the tenant-cloneable templates surfaced in the Campaign Builder.
* Lead-form questions and mapped nurture paths come straight from spec §9.1.
* Front-end demo data only.
*/
export const BLUEPRINTS = [
{
id: 'bp_storm',
name: 'Storm inspection response',
objective: 'lead_generation',
serviceType: 'storm',
bestChannels: ['meta', 'google', 'nextdoor'],
offer: 'Free post-storm roof inspection',
formQuestions: [
'Property address', 'Storm date (if known)', 'Active leak?',
'Homeowner / tenant', 'Preferred contact time',
],
nurture: 'Social Lead → Appointment → Storm Inspection Nurture → Insurance Education',
adCopy: 'Recent hail or wind in {{City}}? A roof inspection can document visible damage, ventilation concerns, and repair/replacement options. Request an inspection window.',
icon: 'CloudLightning',
accent: 'amber',
},
{
id: 'bp_retail',
name: 'Retail roof replacement',
objective: 'lead_generation',
serviceType: 'replacement',
bestChannels: ['google', 'meta', 'nextdoor'],
offer: 'Free replacement quote + financing options',
formQuestions: [
'Roof age', 'Leak / aging / planned upgrade', 'Timeline', 'Financing interest',
],
nurture: 'Retail Replacement Nurture → Upgrade Education → Appointment Reminder',
adCopy: 'Roof showing its age? Compare Good / Better / Best replacement options with transparent pricing and financing. Get a free quote.',
icon: 'Home',
accent: 'blue',
},
{
id: 'bp_emergency',
name: 'Emergency leak / tarping',
objective: 'lead_generation',
serviceType: 'emergency',
bestChannels: ['google', 'meta', 'whatsapp'],
offer: 'Same-day emergency leak response',
formQuestions: [
'Active leak?', 'Safe photo upload', 'Address', 'Urgency', 'Best callback number',
],
nurture: 'Emergency Dispatch Nurture → Human escalation within 10 minutes',
adCopy: 'Roof leaking right now? Our emergency crew can tarp and stop the damage fast. Request urgent help.',
icon: 'Siren',
accent: 'red',
},
{
id: 'bp_commercial',
name: 'Commercial / property manager bid',
objective: 'lead_generation',
serviceType: 'commercial',
bestChannels: ['linkedin', 'google', 'reddit'],
offer: 'Commercial roof assessment + decision-maker checklist',
formQuestions: [
'Company', 'Role', 'Property type', 'Square footage (if known)',
'Site access', 'Decision timeline',
],
nurture: 'Commercial Bid Nurture → Decision-maker Confirmation → Capability Statement',
adCopy: 'Need a commercial roof assessment or bid package? Request a site review and receive a decision-maker checklist before the meeting.',
icon: 'Building2',
accent: 'purple',
},
{
id: 'bp_unsold',
name: 'Unsold estimate retargeting',
objective: 'retargeting',
serviceType: 'replacement',
bestChannels: ['meta', 'google', 'reddit', 'linkedin'],
offer: 'No new form — uses CRM audience',
formQuestions: ['No new form required; uses CRM audience'],
nurture: 'Unsold Estimate Warm/Cold Nurture → Financing or Options Compare',
adCopy: 'Still weighing your roof options? See how financing changes the monthly number — and what a Class 4 upgrade protects.',
icon: 'RefreshCw',
accent: 'cyan',
},
{
id: 'bp_review',
name: 'Review / referral flywheel',
objective: 'engagement',
serviceType: 'referral',
bestChannels: ['meta', 'nextdoor'],
offer: 'Referral landing page / review CTA',
formQuestions: ['Referral landing page or review CTA'],
nurture: 'Completion Promoter Path → Review → Referral Offer (if allowed)',
adCopy: 'Loved your new roof? Share your experience with neighbors — and pass along a referral if you know someone who needs us.',
icon: 'Star',
accent: 'emerald',
},
{
id: 'bp_seasonal',
name: 'Seasonal maintenance / reactivation',
objective: 'reactivation',
serviceType: 'maintenance',
bestChannels: ['nextdoor', 'meta', 'google', 'whatsapp'],
offer: 'Annual inspection check-in',
formQuestions: ['Past customer check-in', 'Inspection request', 'Warranty check'],
nurture: 'Reactivation → Annual Inspection → Storm Trigger Retarget',
adCopy: 'It has been a while since your last roof check-up. Book a seasonal inspection and keep your warranty on track.',
icon: 'CalendarClock',
accent: 'amber',
},
];
export const BLUEPRINT_BY_ID = Object.fromEntries(BLUEPRINTS.map(b => [b.id, b]));
@@ -0,0 +1,76 @@
/**
* contactPool.js mock CRM contact universe for the Audience segment builder
* (spec §12 E1 "filter CRM contacts by stage, source, service, last job,
* geography, consent" and §8 preview count).
*
* Front-end demo: a deterministic, stable pool generated from each index (no
* Math.random / Date so it never shifts between reloads). previewSegment() filters
* it and returns counts AFTER opt-out and suppression filtering exactly what the
* spec's /v1/audience-segments/preview endpoint is meant to return.
*/
export const FILTER_OPTIONS = {
stages: ['lead', 'appointment', 'estimate', 'won', 'lost', 'customer'],
sources: ['meta', 'google', 'linkedin', 'nextdoor', 'reddit', 'referral', 'organic'],
services: ['storm', 'replacement', 'emergency', 'commercial', 'maintenance', 'referral'],
zips: ['75023', '75024', '75025', '75074', '75075', '75093', '75094'],
};
const POOL_SIZE = 1400;
// Knuth multiplicative hash → stable per-index pseudo-randomness.
const hash = (i) => (Math.imul(i + 1, 2654435761) >>> 0);
export const CONTACT_POOL = Array.from({ length: POOL_SIZE }, (_, i) => {
const h = hash(i);
return {
id: `ct_${i}`,
stage: FILTER_OPTIONS.stages[h % FILTER_OPTIONS.stages.length],
source: FILTER_OPTIONS.sources[(h >> 3) % FILTER_OPTIONS.sources.length],
service: FILTER_OPTIONS.services[(h >> 6) % FILTER_OPTIONS.services.length],
zip: FILTER_OPTIONS.zips[(h >> 9) % FILTER_OPTIONS.zips.length],
lastJobDaysAgo: (h >> 12) % 540, // 0540 days
consent: ((h >> 16) % 100) < 88, // ~88% have marketing consent
suppressed: ((h >> 20) % 100) < 9, // ~9% on the suppression list
};
});
const isAny = (v) => !v || v === 'any';
/**
* Estimate a segment's reach. Returns matched / consented / suppressed / eligible,
* where eligible = matched consented not-suppressed (consent + suppression are
* mandatory per spec §6, so the eligible figure always applies both).
*/
export function previewSegment(f = {}) {
const geo = Array.isArray(f.geo) && f.geo.length ? f.geo : null;
const matched = CONTACT_POOL.filter(c =>
(isAny(f.stage) || c.stage === f.stage) &&
(isAny(f.source) || c.source === f.source) &&
(isAny(f.service) || c.service === f.service) &&
(!geo || geo.includes(c.zip)) &&
(!f.lastJobWithinDays || c.lastJobDaysAgo <= f.lastJobWithinDays),
);
const consented = matched.filter(c => c.consent).length;
const suppressed = matched.filter(c => c.suppressed).length;
const eligible = matched.filter(c => c.consent && !c.suppressed).length;
return { matched: matched.length, consented, suppressed, eligible };
}
/** Human-readable one-liner for a segment's filters (shown on the card). */
export function summarizeFilters(f = {}) {
const bits = [];
if (!isAny(f.stage)) bits.push(`stage: ${f.stage}`);
if (!isAny(f.source)) bits.push(f.source);
if (!isAny(f.service)) bits.push(f.service);
if (Array.isArray(f.geo) && f.geo.length) bits.push(`ZIP ${f.geo.join('/')}`);
if (f.lastJobWithinDays) bits.push(`last job < ${f.lastJobWithinDays}d`);
if (f.frequencyCap) bits.push(`freq cap ${f.frequencyCap}/wk`);
bits.push('consented • suppression applied');
return bits.join(' • ');
}
export const DEFAULT_FILTER_DEF = {
stage: 'any', source: 'any', service: 'any', geo: [],
lastJobWithinDays: null, frequencyCap: 3,
};
@@ -0,0 +1,115 @@
/**
* platforms.js the six ad platforms the Social Ads Engine targets (spec §3).
*
* Per spec §11, the UI must never pretend full automation exists where a platform
* only supports a subset. Each platform therefore declares an explicit set of
* CAPABILITIES, and every screen reads these flags to honestly show what is
* automated vs. manual/import-only. Front-end demo data only no real OAuth or APIs.
*
* Manufacturer/brand colors are used purely for recognizability in the demo.
*/
import { Facebook, Search, Linkedin, MessageCircle, Phone, Home } from 'lucide-react';
// Capability vocabulary (spec §11 feature flags by platform capability).
export const CAPABILITY = {
INGEST: 'ingest', // can receive leads (webhook / lead sync)
REPORT: 'report', // can pull spend/impressions/clicks/leads
CONVERSION_SYNC: 'conversion-sync', // can push offline/conversion events back
DRAFT_BUILDER: 'draft-builder', // can assemble a platform-ready draft in-app
FULL_PUBLISH: 'full-publish', // can publish a campaign directly via API
};
export const CAPABILITY_LABEL = {
[CAPABILITY.INGEST]: 'Lead ingest',
[CAPABILITY.REPORT]: 'Reporting',
[CAPABILITY.CONVERSION_SYNC]: 'Conversion sync',
[CAPABILITY.DRAFT_BUILDER]: 'Draft builder',
[CAPABILITY.FULL_PUBLISH]: 'Full publish',
};
export const PLATFORMS = [
{
id: 'meta',
name: 'Facebook + Instagram',
short: 'Meta',
icon: Facebook,
color: '#1877F2',
role: 'Local awareness, storm response, instant-form leads, retargeting, social proof.',
integrationPath: 'Meta Lead Ads / Instant Forms + webhook ingestion; Marketing API; Conversions API.',
cautions: 'App review, business verification, token lifecycle, lead retention windows.',
capabilities: [
CAPABILITY.INGEST, CAPABILITY.REPORT, CAPABILITY.CONVERSION_SYNC,
CAPABILITY.DRAFT_BUILDER, CAPABILITY.FULL_PUBLISH,
],
copyGuidance: 'Local, visual, simple offer, fast callback. Avoid panic/fear language after storms.',
},
{
id: 'google',
name: 'Google Ads',
short: 'Google',
icon: Search,
color: '#4285F4',
role: 'High-intent search, local services, lead-form assets, retargeting.',
integrationPath: 'Google Ads Lead Form Webhook; Ads API for reporting; offline conversion upload.',
cautions: 'Webhook key validation, OAuth/developer token, call tracking, conversion dedup.',
capabilities: [
CAPABILITY.INGEST, CAPABILITY.REPORT, CAPABILITY.CONVERSION_SYNC, CAPABILITY.DRAFT_BUILDER,
],
copyGuidance: 'Intent matching. Align ad text and landing/form to the exact search need.',
},
{
id: 'linkedin',
name: 'LinkedIn',
short: 'LinkedIn',
icon: Linkedin,
color: '#0A66C2',
role: 'Commercial roofing, property managers, builders, facilities, HOA and B2B.',
integrationPath: 'Lead Gen Forms + Lead Sync APIs; campaign/reporting via Marketing API program.',
cautions: 'Partner/API access, B2B field mapping, leadSync permissions, lower volume.',
capabilities: [CAPABILITY.INGEST, CAPABILITY.REPORT, CAPABILITY.DRAFT_BUILDER],
copyGuidance: 'Professional and proof-driven. Focus owners, facilities, associations, builders.',
},
{
id: 'reddit',
name: 'Reddit',
short: 'Reddit',
icon: MessageCircle,
color: '#FF4500',
role: 'Niche local research, homeowner communities, storm/insurance discussion, B2B education.',
integrationPath: 'Reddit Lead Generation Ads where available; Zapier/partner path; Conversions API.',
cautions: 'Community tone differs; avoid promotional copy; beta limitations.',
capabilities: [CAPABILITY.INGEST, CAPABILITY.REPORT],
copyGuidance: 'Value-first, specific, less polished. Offer checklist/calculator. Avoid corporate hype.',
},
{
id: 'whatsapp',
name: 'WhatsApp',
short: 'WhatsApp',
icon: Phone,
color: '#25D366',
role: 'Conversational follow-up in markets where customers prefer WhatsApp.',
integrationPath: 'WhatsApp Business Platform / Cloud API, approved templates, session messaging.',
cautions: 'Template approval, opt-in, conversation windows, category rules, pricing.',
capabilities: [CAPABILITY.INGEST],
copyGuidance: 'Conversational, short, service-oriented. Use approved templates; shift to human for complex issues.',
},
{
id: 'nextdoor',
name: 'Nextdoor',
short: 'Nextdoor',
icon: Home,
color: '#8B6F47',
role: 'Hyperlocal neighborhood credibility, storm response, referral/recommendation demand.',
integrationPath: 'Nextdoor developer/Ads API where eligible; conversion API; manual/import fallback.',
cautions: 'API access may require approval; do not scrape neighborhood content; community etiquette.',
capabilities: [CAPABILITY.REPORT],
copyGuidance: 'Neighborly, hyperlocal, trust- and reputation-focused. Avoid spammy automation or scraping.',
},
];
export const PLATFORM_BY_ID = Object.fromEntries(PLATFORMS.map(p => [p.id, p]));
export const getPlatform = (id) => PLATFORM_BY_ID[id] ?? null;
export const hasCapability = (platformId, capability) =>
!!PLATFORM_BY_ID[platformId]?.capabilities.includes(capability);
@@ -0,0 +1,98 @@
/**
* publishChecklist.js generates a platform-ready manual setup checklist for
* campaigns on channels without a full-publish API (spec §5 fallback, §11 capability
* honesty). The steps are filled with the campaign's real objective, budget caps,
* schedule, approved creative, form fields, tracking link, and webhook so an admin can
* reproduce the campaign in the platform's native ads manager, then mark it live.
*/
import { getPlatform } from './platforms.js';
import { BLUEPRINT_BY_ID } from './blueprints.js';
import { buildUtm } from './utm.js';
const PLATFORM_TIP = {
reddit: 'Reddit lead APIs are limited — drive clicks to the tracked LynkedUp landing page and ingest submissions via webhook/CSV rather than native lead forms.',
whatsapp: 'Use Click-to-WhatsApp ads or the WhatsApp Business app; route inbound conversations into LynkedUp through the WhatsApp Business webhook.',
nextdoor: 'Nextdoor Ads are managed in Nextdoor Business Center; use a tracked landing-page link and confirm neighborhood-level targeting.',
default: 'This platform has no full-publish API — complete setup in its native ads manager, then return to mark the campaign live.',
};
const money = (n) => `$${Number(n || 0).toLocaleString('en-US')}`;
/** Build a structured, value-filled checklist for a manual-publish campaign. */
export function buildChecklist(campaign, { creative, form } = {}) {
const p = getPlatform(campaign.platform);
const g = campaign.guardrails || {};
const pauseRules = [
g.pauseOnDailyCap && 'daily cap',
g.pauseOnCplBreach && 'CPL breach',
g.anomalyDetection && 'spend anomaly',
].filter(Boolean).join(', ') || 'none';
const formFields = form?.fields?.map(f => f.target || f.source)
|| BLUEPRINT_BY_ID[campaign.blueprintId]?.formQuestions
|| ['name', 'phone', 'email', 'address', 'consent'];
const steps = [
{
title: `Open ${p?.name || 'the platform'} Ads Manager`,
detail: `Create a new ${p?.short || ''} campaign. Account capability: ${p?.role || 'manual setup only'}.`,
},
{
title: 'Set objective & campaign name',
detail: `Objective: ${(campaign.objective || 'lead_generation').replace(/_/g, ' ')}. Name: “${campaign.name}”.`,
},
{
title: 'Set geography / targeting',
detail: campaign.serviceArea || 'Define the service-area ZIPs, city, or radius.',
},
{
title: 'Set budget & spend caps',
detail: `Daily ${money(campaign.budgetDaily)} (total ${money(campaign.budgetTotal)}). Enforce caps — weekly ${money(g.weeklyCap)}, monthly ${money(g.monthlyCap)}. Alert at ${g.alertPct || 80}% of cap or CPL > ${money(g.cplThreshold)}. Auto-pause on: ${pauseRules}.`,
},
{
title: 'Set flight dates',
detail: `${campaign.startAt}${campaign.endAt}.`,
},
{
title: 'Add the approved creative',
detail: creative
? `Headline: “${creative.headline}\nBody: “${creative.body}${creative.description ? `\nDescription: “${creative.description}` : ''}`
: 'Paste a policy-clear, approved creative from Creative Studio.',
},
{
title: 'Configure the lead form & consent',
detail: `Capture fields: ${formFields.join(', ')}. ${form ? 'Apply the locked consent text + privacy URL from the Lead Form Mapper.' : 'Use the default capture forms consent text + privacy URL.'}`,
},
{
title: 'Add the tracking link',
detail: `Point the ad to the tracked landing page (UTM parameters pre-filled for attribution):\n${buildUtm(campaign, { creative }).url}`,
},
{
title: 'Connect lead ingestion',
detail: `In Connected Accounts → ${p?.short || 'this platform'}, verify the webhook so submissions ingest into the Unified Lead Inbox.`,
},
{
title: 'Submit & launch in-platform',
detail: `Submit for ${p?.short || 'platform'} review, then set the campaign live. Return here to mark setup complete.`,
},
];
return {
platform: campaign.platform,
platformName: p?.name || campaign.platform,
tip: PLATFORM_TIP[campaign.platform] || PLATFORM_TIP.default,
steps,
};
}
/** Flatten a checklist to copyable plain text (for "Copy instructions"). */
export function checklistToText(campaign, checklist) {
const lines = [
`Manual setup checklist — ${campaign.name}`,
`Platform: ${checklist.platformName}`,
`Note: ${checklist.tip}`,
'',
...checklist.steps.map((s, i) => `${i + 1}. ${s.title}\n ${s.detail.replace(/\n/g, '\n ')}`),
];
return lines.join('\n');
}
+514
View File
@@ -0,0 +1,514 @@
/**
* seed.js initial mock data for the Social Ads Engine demo (spec §7 data model).
*
* Each export maps to a spec entity:
* accounts ad_accounts
* campaigns ad_campaigns
* creatives ad_creatives
* leadForms lead_forms (+ field_mappings inline)
* leads ad_leads
* nurtureRules ad-to-nurture rules (spec §5, §8)
* audiences audience_segments
* conversionEvents conversion_events
* approvals creative_reviews / compliance queue
* analyticsDaily campaign_analytics_daily
*
* Dates are fixed strings so the demo never drifts. Front-end only; no backend.
*/
// ── ad_accounts ──────────────────────────────────────────────────────────────
export const accounts = [
{
id: 'acc_meta', platform: 'meta', name: 'LynkedUp Roofing — Meta Business',
externalAccountId: 'act_109_445_882', status: 'connected',
tokenStatus: 'healthy', tokenExpiresAt: '2026-09-02', webhookStatus: 'verified',
lastSyncAt: '2026-06-10T07:40:00Z', scopes: ['leads_retrieval', 'ads_read', 'ads_management'],
},
{
id: 'acc_google', platform: 'google', name: 'LynkedUp Roofing — Google Ads',
externalAccountId: '742-553-9981', status: 'connected',
tokenStatus: 'healthy', tokenExpiresAt: '2026-08-19', webhookStatus: 'verified',
lastSyncAt: '2026-06-10T07:38:00Z', scopes: ['adwords', 'lead_form_webhook'],
},
{
id: 'acc_linkedin', platform: 'linkedin', name: 'LynkedUp Commercial — LinkedIn',
externalAccountId: '510882', status: 'connected',
tokenStatus: 'expiring', tokenExpiresAt: '2026-06-15', webhookStatus: 'verified',
lastSyncAt: '2026-06-09T22:10:00Z', scopes: ['r_ads', 'r_leadgen', 'rw_ads'],
},
{
id: 'acc_reddit', platform: 'reddit', name: 'LynkedUp — Reddit Ads',
externalAccountId: 't2_8h3k1', status: 'action_required',
tokenStatus: 'revoked', tokenExpiresAt: null, webhookStatus: 'failing',
lastSyncAt: '2026-06-07T14:02:00Z', scopes: [],
},
{
id: 'acc_whatsapp', platform: 'whatsapp', name: 'WhatsApp Business — (972) 555-0100',
externalAccountId: 'waba_338201', status: 'disconnected',
tokenStatus: 'none', tokenExpiresAt: null, webhookStatus: 'not_configured',
lastSyncAt: null, scopes: [],
},
{
id: 'acc_nextdoor', platform: 'nextdoor', name: 'LynkedUp Roofing — Nextdoor',
externalAccountId: 'nd_ads_44120', status: 'connected',
tokenStatus: 'healthy', tokenExpiresAt: '2026-12-01', webhookStatus: 'manual',
lastSyncAt: '2026-06-10T06:00:00Z', scopes: ['ads_read'],
},
];
// ── ad_campaigns ─────────────────────────────────────────────────────────────
export const campaigns = [
{
id: 'camp_1', blueprintId: 'bp_storm', platform: 'meta', name: 'Plano Hail Response — June',
objective: 'lead_generation', serviceType: 'storm', status: 'active',
budgetDaily: 120, budgetTotal: 3600, startAt: '2026-06-01', endAt: '2026-06-30',
playbook: 'Storm Inspection Nurture', approvalStatus: 'approved',
serviceArea: 'Plano, TX 75023 / 75025 (hail-hit ZIPs)',
},
{
id: 'camp_2', blueprintId: 'bp_retail', platform: 'google', name: 'Roof Replacement — North Dallas Search',
objective: 'lead_generation', serviceType: 'replacement', status: 'active',
budgetDaily: 95, budgetTotal: 2850, startAt: '2026-05-20', endAt: '2026-06-30',
playbook: 'Retail Replacement Nurture', approvalStatus: 'approved',
serviceArea: 'Plano / Allen / Frisco, TX',
},
{
id: 'camp_3', blueprintId: 'bp_commercial', platform: 'linkedin', name: 'Commercial Bid — Property Managers',
objective: 'lead_generation', serviceType: 'commercial', status: 'active',
budgetDaily: 70, budgetTotal: 2100, startAt: '2026-05-25', endAt: '2026-07-15',
playbook: 'Commercial Bid Nurture', approvalStatus: 'approved',
serviceArea: 'DFW Metro commercial corridors',
},
{
id: 'camp_4', blueprintId: 'bp_emergency', platform: 'google', name: 'Emergency Leak — Always On',
objective: 'lead_generation', serviceType: 'emergency', status: 'active',
budgetDaily: 45, budgetTotal: 1350, startAt: '2026-05-01', endAt: '2026-07-31',
playbook: 'Emergency Dispatch Nurture', approvalStatus: 'approved',
serviceArea: 'Plano + 15mi radius',
},
{
id: 'camp_5', blueprintId: 'bp_review', platform: 'nextdoor', name: 'Neighborhood Review Flywheel',
objective: 'engagement', serviceType: 'referral', status: 'paused',
budgetDaily: 25, budgetTotal: 750, startAt: '2026-05-10', endAt: '2026-06-30',
playbook: 'Completion Promoter Path', approvalStatus: 'approved',
serviceArea: 'Completed-job neighborhoods',
},
{
id: 'camp_6', blueprintId: 'bp_unsold', platform: 'meta', name: 'Unsold Estimate Retarget — Q2',
objective: 'retargeting', serviceType: 'replacement', status: 'draft',
budgetDaily: 40, budgetTotal: 1200, startAt: '2026-06-15', endAt: '2026-07-15',
playbook: 'Unsold Estimate Warm Nurture', approvalStatus: 'pending',
serviceArea: 'CRM audience: unsold estimates < 90 days',
},
{
// LinkedIn has no full-publish API (ingest/report only), so this draft surfaces
// the "Manual setup" checklist instead of "Publish". Approved → can go live.
id: 'camp_7', blueprintId: 'bp_commercial', platform: 'linkedin', name: 'Commercial Roofing — Property Managers (Draft)',
objective: 'lead_generation', serviceType: 'commercial', status: 'draft',
budgetDaily: 60, budgetTotal: 1800, startAt: '2026-06-20', endAt: '2026-07-20',
playbook: 'Commercial Bid Nurture', approvalStatus: 'approved',
serviceArea: 'DFW commercial corridors — property managers',
creativeId: 'cre_4', formId: 'form_2',
},
];
// ── ad_creatives ─────────────────────────────────────────────────────────────
export const creatives = [
{
id: 'cre_1', campaignId: 'camp_1', platform: 'meta', aiGenerated: true,
headline: 'Hail in Plano? Document the damage.',
body: 'Recent hail or wind? A free roof inspection documents visible damage, ventilation concerns, and your repair/replacement options. Request an inspection window today.',
description: 'Free inspection • Local crew • Insurance-savvy',
approvalStatus: 'approved', policyStatus: 'clear', policyFlags: [],
},
{
id: 'cre_2', campaignId: 'camp_1', platform: 'meta', aiGenerated: true,
headline: 'Storm rolled through. Is your roof okay?',
body: 'Our local team checks for hail bruising, lifted shingles, and ventilation issues — then walks you through options. No pressure, just facts.',
description: 'Same-week appointments',
approvalStatus: 'pending', policyStatus: 'review',
policyFlags: ['Verify "no pressure" claim against approved messaging'],
},
{
id: 'cre_3', campaignId: 'camp_2', platform: 'google', aiGenerated: true,
headline: 'Roof Replacement Quotes — Plano',
body: 'Compare Good / Better / Best options with transparent pricing and financing. Free, no-obligation quote.',
description: 'Financing available',
approvalStatus: 'approved', policyStatus: 'clear', policyFlags: [],
},
{
id: 'cre_4', campaignId: 'camp_3', platform: 'linkedin', aiGenerated: false,
headline: 'Commercial Roof Assessment + Bid Package',
body: 'Request a site review and receive a decision-maker checklist before your meeting. Capability statement and COI on request.',
description: 'For property managers & facilities',
approvalStatus: 'approved', policyStatus: 'clear', policyFlags: [],
},
{
id: 'cre_5', campaignId: 'camp_6', platform: 'meta', aiGenerated: true,
headline: 'Your roof quote is about to expire',
body: 'Lock in this season\'s pricing before material costs jump 18%. Limited inspection slots left this week!',
description: 'Act now — prices rising',
approvalStatus: 'blocked', policyStatus: 'blocked',
policyFlags: ['Unverified discount / price-jump claim', 'Deceptive scarcity ("limited slots")'],
},
];
// ── lead_forms (+ inline field_mappings) ──────────────────────────────────────
export const leadForms = [
{
id: 'form_1', campaignId: 'camp_1', platform: 'meta', platformFormId: 'fb_form_88231',
title: 'Storm Inspection Request', status: 'live',
consentText: 'By submitting, I agree LynkedUp Roofing may contact me by call, text, and email about my inspection. Msg/data rates may apply. Reply STOP to opt out.',
privacyPolicyUrl: 'https://lynkeduppro.com/privacy',
thankYou: 'We received your request. Choose an appointment window or watch for a scheduling text.',
fields: [
{ source: 'full_name', target: 'contact.name', required: true, transform: 'titlecase' },
{ source: 'phone_number', target: 'contact.phone', required: true, transform: 'e164' },
{ source: 'email', target: 'contact.email', required: true, transform: 'lowercase' },
{ source: 'street_address', target: 'property.address', required: true, transform: 'none' },
{ source: 'active_leak', target: 'lead.urgencyFlag', required: false, transform: 'bool' },
{ source: 'owner_or_tenant', target: 'lead.role', required: true, transform: 'none' },
{ source: 'preferred_window', target: 'appointment.preferredWindow', required: false, transform: 'none' },
],
},
{
id: 'form_2', campaignId: 'camp_3', platform: 'linkedin', platformFormId: 'li_form_5521',
title: 'Commercial Bid Request', status: 'live',
consentText: 'I agree to be contacted regarding a commercial roofing assessment. LynkedUp will store my details per its privacy policy.',
privacyPolicyUrl: 'https://lynkeduppro.com/privacy',
thankYou: 'Thanks — a commercial estimator will reach out with a decision-maker checklist.',
fields: [
{ source: 'company', target: 'contact.company', required: true, transform: 'none' },
{ source: 'job_title', target: 'lead.role', required: true, transform: 'none' },
{ source: 'work_email', target: 'contact.email', required: true, transform: 'lowercase' },
{ source: 'property_type', target: 'lead.propertyType', required: true, transform: 'none' },
{ source: 'building_count', target: 'lead.buildingCount', required: false, transform: 'int' },
{ source: 'decision_timeline', target: 'lead.timeline', required: false, transform: 'none' },
],
},
{
id: 'form_3', campaignId: 'camp_4', platform: 'google', platformFormId: 'ggl_lf_70012',
title: 'Emergency Leak — Get Help Now', status: 'live',
consentText: 'I consent to urgent contact by phone and text about my roof emergency. Reply STOP to opt out.',
privacyPolicyUrl: 'https://lynkeduppro.com/privacy',
thankYou: 'Help is on the way — keep your phone handy, our dispatcher will call shortly.',
fields: [
{ source: 'name', target: 'contact.name', required: true, transform: 'titlecase' },
{ source: 'phone', target: 'contact.phone', required: true, transform: 'e164' },
{ source: 'address', target: 'property.address', required: true, transform: 'none' },
{ source: 'active_leak', target: 'lead.urgencyFlag', required: true, transform: 'bool' },
],
},
];
// ── ad_leads ──────────────────────────────────────────────────────────────────
// triage: { serviceType, urgency, role, intent, quality (0-100), confidence (0-1) }
// stage : lifecycle position (spec §5/§10.1) — lead → appointment → showed →
// estimate → won | lost. Advancing a stage emits a conversion_event and
// live-updates the dashboard KPIs. dealValue = estimate/won $; premium =
// won with a premium package; firstContactSec = speed-to-lead.
export const leads = [
{
id: 'lead_1', platform: 'meta', campaignId: 'camp_1', formId: 'form_1',
externalLeadId: 'fbl_99120', receivedAt: '2026-06-10T13:42:00Z', status: 'new', stage: 'lead',
name: 'Marcus Webb', phone: '(972) 555-0188', email: 'm.webb@example.com',
address: '2612 Dunwick Dr, Plano, TX 75023',
consent: true, role: 'homeowner', activeLeak: false,
triage: { serviceType: 'storm', urgency: 'medium', role: 'homeowner', intent: 'inspection', quality: 82, confidence: 0.91 },
duplicateOf: null, assignedTo: null, dealValue: 0, premium: false, firstContactSec: null,
slaSecondsAgo: 220, // storm target 300s → ~80s left (ticking toward breach)
},
{
id: 'lead_2', platform: 'google', campaignId: 'camp_4', formId: 'form_3',
externalLeadId: 'ggl_44021', receivedAt: '2026-06-10T13:05:00Z', status: 'new', stage: 'lead',
name: 'Priya Nair', phone: '(469) 555-0143', email: 'priya.nair@example.com',
address: '6609 Phoenix Pl, Plano, TX 75023',
consent: true, role: 'homeowner', activeLeak: true,
triage: { serviceType: 'emergency', urgency: 'high', role: 'homeowner', intent: 'emergency', quality: 95, confidence: 0.97 },
duplicateOf: null, assignedTo: null, dealValue: 0, premium: false, firstContactSec: null,
slaSecondsAgo: 700, // emergency target 600s → already breached (+100s)
},
{
id: 'lead_3', platform: 'linkedin', campaignId: 'camp_3', formId: 'form_2',
externalLeadId: 'lil_30221', receivedAt: '2026-06-10T11:20:00Z', status: 'assigned', stage: 'appointment',
name: 'Dana Cole', phone: '(214) 555-0177', email: 'dcole@summitproperties.com',
address: 'Summit Properties, 1200 Legacy Dr, Plano, TX',
consent: true, role: 'property_manager', activeLeak: false,
triage: { serviceType: 'commercial', urgency: 'low', role: 'property_manager', intent: 'bid', quality: 88, confidence: 0.89 },
duplicateOf: null, assignedTo: 'rep_jordan', dealValue: 0, premium: false, firstContactSec: 240,
},
{
id: 'lead_4', platform: 'meta', campaignId: 'camp_1', formId: 'form_1',
externalLeadId: 'fbl_99131', receivedAt: '2026-06-10T10:58:00Z', status: 'review', stage: 'lead',
name: 'Marcus W.', phone: '(972) 555-0188', email: 'm.webb@example.com',
address: '2612 Dunwick Dr, Plano TX',
consent: true, role: 'homeowner', activeLeak: false,
triage: { serviceType: 'storm', urgency: 'medium', role: 'homeowner', intent: 'inspection', quality: 60, confidence: 0.42 },
duplicateOf: 'lead_1', assignedTo: null, dealValue: 0, premium: false, firstContactSec: null,
},
{
id: 'lead_5', platform: 'reddit', campaignId: null, formId: null,
externalLeadId: 'rdl_1102', receivedAt: '2026-06-09T18:30:00Z', status: 'new', stage: 'lead',
name: 'Anon User', phone: '', email: 'roofcurious@example.com',
address: 'ZIP 75025',
consent: false, role: 'homeowner', activeLeak: false,
triage: { serviceType: 'replacement', urgency: 'low', role: 'homeowner', intent: 'research', quality: 35, confidence: 0.55 },
duplicateOf: null, assignedTo: null, dealValue: 0, premium: false, firstContactSec: null,
},
{
id: 'lead_6', platform: 'google', campaignId: 'camp_2', formId: null,
externalLeadId: 'ggl_44090', receivedAt: '2026-06-09T16:12:00Z', status: 'assigned', stage: 'won',
name: 'Helen Ortiz', phone: '(972) 555-0210', email: 'helen.ortiz@example.com',
address: '3913 Arizona Pl, Plano, TX 75023',
consent: true, role: 'homeowner', activeLeak: false,
triage: { serviceType: 'replacement', urgency: 'medium', role: 'homeowner', intent: 'quote', quality: 78, confidence: 0.84 },
duplicateOf: null, assignedTo: 'rep_sam', dealValue: 28100, premium: true, firstContactSec: 180,
},
{
id: 'lead_7', platform: 'meta', campaignId: 'camp_1', formId: 'form_1',
externalLeadId: 'fbl_99140', receivedAt: '2026-06-08T15:20:00Z', status: 'assigned', stage: 'showed',
name: 'Greg Tanaka', phone: '(972) 555-0233', email: 'greg.tanaka@example.com',
address: '2608 Dunwick Dr, Plano, TX 75023',
consent: true, role: 'homeowner', activeLeak: false,
triage: { serviceType: 'storm', urgency: 'medium', role: 'homeowner', intent: 'inspection', quality: 80, confidence: 0.9 },
duplicateOf: null, assignedTo: 'rep_jordan', dealValue: 0, premium: false, firstContactSec: 300,
},
{
id: 'lead_8', platform: 'google', campaignId: 'camp_2', formId: null,
externalLeadId: 'ggl_44120', receivedAt: '2026-06-07T12:10:00Z', status: 'assigned', stage: 'estimate',
name: 'Lena Brooks', phone: '(469) 555-0299', email: 'lena.brooks@example.com',
address: '3917 Arizona Pl, Plano, TX 75023',
consent: true, role: 'homeowner', activeLeak: false,
triage: { serviceType: 'replacement', urgency: 'medium', role: 'homeowner', intent: 'quote', quality: 85, confidence: 0.88 },
duplicateOf: null, assignedTo: 'rep_sam', dealValue: 31200, premium: false, firstContactSec: 220,
},
];
// ── ad-to-nurture rules (spec §5, §8) ──────────────────────────────────────────
export const nurtureRules = [
{
id: 'rule_1', priority: 1,
when: { platform: 'any', serviceType: 'emergency' },
then: { playbook: 'Emergency Dispatch Nurture', rep: 'On-call dispatcher', speedToLead: 'WhatsApp + call < 10 min', education: 'Leak mitigation tips' },
enabled: true,
},
{
id: 'rule_2', priority: 2,
when: { platform: 'linkedin', serviceType: 'commercial' },
then: { playbook: 'Commercial Bid Nurture', rep: 'Commercial estimator', speedToLead: 'Email < 1 hr', education: 'Capability statement' },
enabled: true,
},
{
id: 'rule_3', priority: 3,
when: { platform: 'meta', serviceType: 'storm' },
then: { playbook: 'Storm Inspection Nurture', rep: 'Field agent (territory)', speedToLead: 'Text < 5 min', education: 'Storm checklist + insurance' },
enabled: true,
},
{
id: 'rule_4', priority: 4,
when: { platform: 'google', serviceType: 'replacement' },
then: { playbook: 'Retail Replacement Nurture', rep: 'Inside sales', speedToLead: 'Call < 5 min', education: 'Good/Better/Best + financing' },
enabled: true,
},
{
id: 'rule_5', priority: 99,
when: { platform: 'any', serviceType: 'any' },
then: { playbook: 'Default Inbound Lead Nurture', rep: 'Round-robin', speedToLead: 'Text < 15 min', education: 'Company intro' },
enabled: true,
},
];
// ── audience_segments ──────────────────────────────────────────────────────────
export const audiences = [
{
id: 'aud_1', name: 'Plano hail-hit neighborhoods', segmentType: 'storm_geo',
totalCount: 4820, consentCount: 4120, suppressionCount: 700, lastCount: '2026-06-10',
filters: 'ZIP 75023/75025 • storm event < 30 days • residential',
},
{
id: 'aud_2', name: 'Unsold estimates < 90 days', segmentType: 'crm_stage',
totalCount: 142, consentCount: 138, suppressionCount: 4, lastCount: '2026-06-10',
filters: 'Estimate sent • not won • age < 90d • has consent',
},
{
id: 'aud_3', name: 'Past customers — maintenance due', segmentType: 'reactivation',
totalCount: 980, consentCount: 910, suppressionCount: 70, lastCount: '2026-06-09',
filters: 'Job completed 10-14 mo ago • warranty active • opted in',
},
{
id: 'aud_4', name: 'Commercial property managers (DFW)', segmentType: 'b2b',
totalCount: 310, consentCount: 295, suppressionCount: 15, lastCount: '2026-06-08',
filters: 'Role = property/facilities manager • DFW metro • consented',
},
{
id: 'aud_5', name: 'Review promoters (NPS 9-10)', segmentType: 'referral',
totalCount: 268, consentCount: 268, suppressionCount: 0, lastCount: '2026-06-10',
filters: 'Completed job • NPS ≥ 9 • review-permission on file',
},
];
// ── media_assets (spec §7 media_asset_id) ──────────────────────────────────────
// Front-end demo: no real binaries — each asset renders as a labeled gradient tile.
// Uploaded assets add an in-memory object-URL preview at runtime.
export const mediaAssets = [
{ id: 'med_1', name: 'Hail-damaged shingles (closeup)', type: 'image', kind: 'storm', w: 1200, h: 1200, gradient: ['#f59e0b', '#ea580c'], photo: 'https://loremflickr.com/600/400/roof,shingles?lock=21' },
{ id: 'med_2', name: 'Aerial roof inspection', type: 'image', kind: 'storm', w: 1200, h: 628, gradient: ['#3b82f6', '#1d4ed8'], photo: 'https://loremflickr.com/600/400/roof,aerial?lock=22' },
{ id: 'med_3', name: 'New architectural shingle roof', type: 'image', kind: 'replacement', w: 1080, h: 1080, gradient: ['#10b981', '#047857'], photo: 'https://loremflickr.com/600/400/roof,house?lock=23' },
{ id: 'med_4', name: 'Crew tarping an active leak', type: 'image', kind: 'emergency', w: 1200, h: 628, gradient: ['#ef4444', '#b91c1c'], photo: 'https://loremflickr.com/600/400/roof,rain?lock=24' },
{ id: 'med_5', name: 'Commercial flat-roof survey', type: 'image', kind: 'commercial', w: 1200, h: 800, gradient: ['#8b5cf6', '#6d28d9'], photo: 'https://loremflickr.com/600/400/commercial,building?lock=25' },
{ id: 'med_6', name: 'Branded crew + truck', type: 'image', kind: 'referral', w: 1080, h: 1080, gradient: ['#06b6d4', '#0e7490'], photo: 'https://loremflickr.com/600/400/construction,truck?lock=26' },
];
// ── consent text versions (spec §8 "consent text versions"; §9 consent text) ────
// Tenant-approved SMS/email consent language with opt-out instruction + privacy URL,
// versioned and approval-gated. One approved version is live per form at a time.
export const consentVersions = [
{ id: 'cv1_1', formId: 'form_1', version: 1, channel: 'SMS + Email', status: 'superseded', reviewer: 'fe@lynkeduppro.com', createdAt: '2026-05-20T10:00:00Z', privacyPolicyUrl: 'https://lynkeduppro.com/privacy', optOut: 'Reply STOP to opt out.', text: 'By submitting, I agree LynkedUp Roofing may contact me about my inspection.' },
{ id: 'cv1_2', formId: 'form_1', version: 2, channel: 'SMS + Email', status: 'approved', reviewer: 'fe@lynkeduppro.com', createdAt: '2026-06-05T10:00:00Z', privacyPolicyUrl: 'https://lynkeduppro.com/privacy', optOut: 'Reply STOP to opt out.', text: 'By submitting, I agree LynkedUp Roofing may contact me by call, text, and email about my inspection. Msg/data rates may apply.' },
{ id: 'cv2_1', formId: 'form_2', version: 1, channel: 'Email', status: 'approved', reviewer: 'fe@lynkeduppro.com', createdAt: '2026-05-25T09:00:00Z', privacyPolicyUrl: 'https://lynkeduppro.com/privacy', optOut: 'Unsubscribe link in every email.', text: 'I agree to be contacted regarding a commercial roofing assessment. LynkedUp will store my details per its privacy policy.' },
{ id: 'cv3_1', formId: 'form_3', version: 1, channel: 'SMS + Voice', status: 'superseded', reviewer: 'fe@lynkeduppro.com', createdAt: '2026-05-10T08:00:00Z', privacyPolicyUrl: 'https://lynkeduppro.com/privacy', optOut: 'Reply STOP to opt out.', text: 'I consent to urgent contact by phone and text about my roof emergency.' },
{ id: 'cv3_2', formId: 'form_3', version: 2, channel: 'SMS + Voice', status: 'pending', reviewer: null, createdAt: '2026-06-10T07:00:00Z', privacyPolicyUrl: 'https://lynkeduppro.com/privacy', optOut: 'Reply STOP to opt out anytime.', text: 'I consent to urgent contact by phone, text, and email about my roof emergency. Standard message and data rates may apply.' },
];
// ── anomaly alerts (spec §11: CPL spike, lead-quality drop, duplicate rate, ──────
// source-to-appointment decline → AI recommends pause/review; tenant executes).
export const anomalies = [
{ id: 'an_1', campaignId: 'camp_4', type: 'cpl_spike', severity: 'high', metric: 'CPL $26 vs $11 baseline (+136%)', detail: 'Cost per lead more than doubled over the last 2 days while volume held flat.', recommendation: 'review', detectedAt: '2026-06-10T09:10:00Z', status: 'open' },
{ id: 'an_2', campaignId: 'camp_2', type: 'appt_decline', severity: 'medium', metric: 'Appt rate 31% vs 48% (35% WoW)', detail: 'Source-to-appointment conversion dropped sharply — check speed-to-lead and form quality.', recommendation: 'review', detectedAt: '2026-06-10T08:40:00Z', status: 'open' },
{ id: 'an_3', campaignId: 'camp_1', type: 'duplicate_rate', severity: 'medium', metric: 'Duplicate rate 24% (threshold 15%)', detail: 'Abnormal duplicate submissions — likely repeat clicks or bot/form traffic.', recommendation: 'review', detectedAt: '2026-06-10T07:55:00Z', status: 'open' },
{ id: 'an_4', campaignId: 'camp_3', type: 'quality_drop', severity: 'high', metric: 'Avg lead quality 44 (down from 71)', detail: 'Lead quality fell well below the qualified threshold — likely low-intent traffic.', recommendation: 'pause', detectedAt: '2026-06-10T07:30:00Z', status: 'open' },
];
// ── multi-touch journeys (spec §11: first/last/multi-touch attribution models) ──
// Each closed-won customer's ordered touch path across sources. Revenue is credited
// differently per model: first-touch → touches[0], last-touch → final touch, multi
// (linear) → split evenly across all touches. Touches carry platform + campaignId so
// the same keyOf() mapping resolves creative/playbook dimensions too.
export const journeys = [
{ id: 'jny_1', customer: 'Helen Ortiz', revenue: 28100, touches: [{ platform: 'meta', campaignId: 'camp_1' }, { platform: 'google', campaignId: 'camp_2' }] },
{ id: 'jny_2', customer: 'Greg Tanaka', revenue: 24850, touches: [{ platform: 'meta', campaignId: 'camp_1' }] },
{ id: 'jny_3', customer: 'Summit Properties', revenue: 86000, touches: [{ platform: 'linkedin', campaignId: 'camp_3' }, { platform: 'google', campaignId: 'camp_2' }, { platform: 'linkedin', campaignId: 'camp_3' }] },
{ id: 'jny_4', customer: 'Lena Brooks', revenue: 31200, touches: [{ platform: 'google', campaignId: 'camp_2' }] },
{ id: 'jny_5', customer: 'Marco Rivera', revenue: 22400, touches: [{ platform: 'nextdoor', campaignId: 'camp_5' }, { platform: 'meta', campaignId: 'camp_1' }] },
{ id: 'jny_6', customer: 'Tina Nguyen', revenue: 46200, touches: [{ platform: 'google', campaignId: 'camp_4' }, { platform: 'meta', campaignId: 'camp_1' }] },
{ id: 'jny_7', customer: 'Paul Adler', revenue: 19800, touches: [{ platform: 'meta', campaignId: 'camp_1' }] },
{ id: 'jny_8', customer: 'Carla Boone', revenue: 25400, touches: [{ platform: 'google', campaignId: 'camp_2' }, { platform: 'linkedin', campaignId: 'camp_3' }] },
];
// ── reviews + referral lift (spec §8 "reviews"; §10 review/referral lift) ───────
// Post-job reviews tied back to the acquisition source. referralLeads = new leads the
// review/promoter generated; reactivationRevenue = repeat/reactivation $ by source.
export const reviews = [
{ id: 'rev_1', platform: 'meta', campaignId: 'camp_1', leadId: 'lead_7', rating: 5, referralLeads: 2, reactivationRevenue: 0, text: 'Fast storm inspection, handled the insurance paperwork for us.', submittedAt: '2026-06-09T18:00:00Z', usagePermission: 'granted', disclosureStatus: 'compliant', usedIn: 'camp_1' },
{ id: 'rev_2', platform: 'google', campaignId: 'camp_2', leadId: 'lead_6', rating: 5, referralLeads: 1, reactivationRevenue: 6500, text: 'Second roof they have done for us — came back for the rental property.', submittedAt: '2026-06-09T20:10:00Z' },
{ id: 'rev_3', platform: 'google', campaignId: 'camp_2', leadId: 'lead_8', rating: 4, referralLeads: 0, reactivationRevenue: 0, text: 'Good work, scheduling took a couple tries.', submittedAt: '2026-06-08T15:30:00Z' },
{ id: 'rev_4', platform: 'nextdoor', campaignId: 'camp_5', leadId: null, rating: 5, referralLeads: 3, reactivationRevenue: 0, text: 'Recommended to three neighbors after the hail storm.', submittedAt: '2026-06-08T12:00:00Z', usagePermission: 'pending', disclosureStatus: 'missing', usedIn: 'camp_5' },
{ id: 'rev_5', platform: 'linkedin', campaignId: 'camp_3', leadId: 'lead_3', rating: 5, referralLeads: 1, reactivationRevenue: 0, text: 'Professional crew for our commercial portfolio.', submittedAt: '2026-06-07T09:45:00Z', usagePermission: 'granted', disclosureStatus: 'compliant', usedIn: 'camp_3' },
{ id: 'rev_6', platform: 'meta', campaignId: 'camp_1', leadId: null, rating: 4, referralLeads: 0, reactivationRevenue: 0, text: 'Solid job on the ridge cap.', submittedAt: '2026-06-07T17:20:00Z' },
{ id: 'rev_7', platform: 'google', campaignId: 'camp_2', leadId: null, rating: 5, referralLeads: 1, reactivationRevenue: 8200, text: 'Called us back for annual maintenance — great value.', submittedAt: '2026-06-06T14:05:00Z' },
{ id: 'rev_8', platform: 'nextdoor', campaignId: 'camp_5', leadId: null, rating: 5, referralLeads: 2, reactivationRevenue: 0, text: 'Whole street is using them now.', submittedAt: '2026-06-05T11:30:00Z', usagePermission: 'granted', disclosureStatus: 'compliant', usedIn: 'camp_5' },
];
// ── inbound conversations (spec §4 "leads AND conversations"; §6 Comment/DM triage) ──
// Comments + DMs on ad posts, pre-classified by AI as lead/complaint/spam/referral/
// support. status: new → routed | converted | dismissed.
export const conversations = [
{
id: 'cv_1', platform: 'meta', channel: 'comment', author: 'Brian Keller', handle: '@bkeller',
postRef: 'Plano Hail Response — June (ad)', receivedAt: '2026-06-10T14:10:00Z', status: 'new',
text: 'How much for an inspection? My roof took a beating in the last hail storm and I think I have a leak.',
classification: { type: 'lead', confidence: 0.93 },
},
{
id: 'cv_2', platform: 'meta', channel: 'comment', author: 'Donna Pratt', handle: '@dpratt75',
postRef: 'Plano Hail Response — June (ad)', receivedAt: '2026-06-10T13:30:00Z', status: 'new',
text: 'Worst experience ever. Crew showed up late and damaged my gutters. Still waiting on a callback. Avoid!',
classification: { type: 'complaint', confidence: 0.95 },
},
{
id: 'cv_3', platform: 'meta', channel: 'dm', author: 'Maria Lopez', handle: '@maria.l',
postRef: 'Storm inspection reel', receivedAt: '2026-06-10T12:05:00Z', status: 'new',
text: 'I highly recommend you all — my neighbor asked who did our roof so I sent her your page!',
classification: { type: 'referral', confidence: 0.9 },
},
{
id: 'cv_4', platform: 'google', channel: 'comment', author: 'Anon', handle: '@deal_hunter',
postRef: 'Roof Replacement — North Dallas Search', receivedAt: '2026-06-10T11:40:00Z', status: 'new',
text: 'Make $5000/week from home 🔥 click here → http://spam.example/earn',
classification: { type: 'spam', confidence: 0.97 },
},
{
id: 'cv_5', platform: 'nextdoor', channel: 'comment', author: 'Greg Tanaka', handle: '@gtanaka',
postRef: 'Neighborhood Review Flywheel', receivedAt: '2026-06-10T10:20:00Z', status: 'new',
text: 'Already a customer here — when will the crew come back to finish the ridge cap? Reschedule please.',
classification: { type: 'support', confidence: 0.88 },
},
{
id: 'cv_6', platform: 'linkedin', channel: 'dm', author: 'Dana Cole', handle: 'Dana Cole',
postRef: 'Commercial Bid — Property Managers', receivedAt: '2026-06-09T17:50:00Z', status: 'routed',
text: 'We manage 6 properties in Plano and need bids on two flat roofs. Can you send a capability statement?',
classification: { type: 'lead', confidence: 0.91 }, routedTo: 'Commercial estimator',
},
{
id: 'cv_7', platform: 'meta', channel: 'dm', author: 'Unknown', handle: '@user_8841',
postRef: 'Unsold Estimate Retarget — Q2', receivedAt: '2026-06-09T16:00:00Z', status: 'dismissed',
text: 'buy cheap followers + crypto giveaway, dm me to earn',
classification: { type: 'spam', confidence: 0.96 },
},
];
// ── conversion_events (spec §10.1) ─────────────────────────────────────────────
export const conversionEvents = [
{ id: 'ce_1', platform: 'meta', eventName: 'LeadCreated', entityType: 'lead', entityId: 'lead_1', value: 0, currency: 'USD', dedupKey: 'meta:fbl_99120', syncStatus: 'synced', syncedAt: '2026-06-10T13:43:00Z' },
{ id: 'ce_2', platform: 'google', eventName: 'AppointmentScheduled', entityType: 'opportunity', entityId: 'opp_2210', value: 0, currency: 'USD', dedupKey: 'opp_2210+appt_55', syncStatus: 'synced', syncedAt: '2026-06-10T09:15:00Z' },
{ id: 'ce_3', platform: 'meta', eventName: 'EstimateSent', entityType: 'opportunity', entityId: 'opp_2188', value: 0, currency: 'USD', dedupKey: 'appt_51+2026-06-08', syncStatus: 'synced', syncedAt: '2026-06-09T17:02:00Z' },
{ id: 'ce_4', platform: 'google', eventName: 'JobWon', entityType: 'estimate', entityId: 'est_990', value: 24850, currency: 'USD', dedupKey: 'est_990', syncStatus: 'synced', syncedAt: '2026-06-08T20:30:00Z' },
{ id: 'ce_5', platform: 'linkedin', eventName: 'AppointmentScheduled', entityType: 'opportunity', entityId: 'opp_2241', value: 0, currency: 'USD', dedupKey: 'opp_2241+appt_60', syncStatus: 'queued', syncedAt: null },
{ id: 'ce_6', platform: 'reddit', eventName: 'LeadCreated', entityType: 'lead', entityId: 'lead_5', value: 0, currency: 'USD', dedupKey: 'reddit:rdl_1102', syncStatus: 'failed', syncedAt: null },
];
// ── compliance queue / creative_reviews (spec §8 Compliance Center) ─────────────
export const approvals = [
{ id: 'apr_1', type: 'creative', refId: 'cre_2', title: 'Storm creative — "no pressure"', status: 'pending', flags: ['Verify "no pressure" against approved messaging'], submittedAt: '2026-06-10T08:30:00Z', reviewer: null },
{ id: 'apr_2', type: 'creative', refId: 'cre_5', title: 'Unsold retarget — price-jump + scarcity', status: 'blocked', flags: ['Unverified discount claim', 'Deceptive scarcity'], submittedAt: '2026-06-09T15:40:00Z', reviewer: null },
{ id: 'apr_3', type: 'consent_text', refId: 'form_1', title: 'Storm form consent language v2', status: 'approved', flags: [], submittedAt: '2026-06-05T10:00:00Z', reviewer: 'fe@lynkeduppro.com' },
{ id: 'apr_4', type: 'review_disclosure', refId: 'camp_5', title: 'Neighbor review creative — disclosure check', status: 'pending', flags: ['Confirm reviewer permission + FTC disclosure on file'], submittedAt: '2026-06-10T07:00:00Z', reviewer: null },
{ id: 'apr_5', type: 'campaign', refId: 'camp_6', title: 'Unsold Estimate Retarget — publish approval', status: 'pending', flags: ['Blocked creative cre_5 must be resolved before publish'], submittedAt: '2026-06-10T09:00:00Z', reviewer: null },
];
// ── campaign_analytics_daily (materialized reporting) ───────────────────────────
// Compact 7-day series per active campaign for the Attribution Dashboard.
export const analyticsDaily = [
// camp_1 (Meta storm)
{ date: '2026-06-04', campaignId: 'camp_1', platform: 'meta', spend: 118, impressions: 9200, clicks: 240, leads: 9, appointments: 4, shows: 3, wins: 1, revenue: 0 },
{ date: '2026-06-05', campaignId: 'camp_1', platform: 'meta', spend: 121, impressions: 9800, clicks: 261, leads: 11, appointments: 5, shows: 4, wins: 1, revenue: 22400 },
{ date: '2026-06-06', campaignId: 'camp_1', platform: 'meta', spend: 120, impressions: 8900, clicks: 233, leads: 8, appointments: 3, shows: 3, wins: 0, revenue: 0 },
{ date: '2026-06-07', campaignId: 'camp_1', platform: 'meta', spend: 119, impressions: 9100, clicks: 248, leads: 10, appointments: 5, shows: 4, wins: 2, revenue: 46200 },
{ date: '2026-06-08', campaignId: 'camp_1', platform: 'meta', spend: 122, impressions: 10100, clicks: 272, leads: 12, appointments: 6, shows: 5, wins: 1, revenue: 24850 },
{ date: '2026-06-09', campaignId: 'camp_1', platform: 'meta', spend: 120, impressions: 9600, clicks: 255, leads: 10, appointments: 4, shows: 3, wins: 1, revenue: 21900 },
{ date: '2026-06-10', campaignId: 'camp_1', platform: 'meta', spend: 60, impressions: 4700, clicks: 130, leads: 6, appointments: 2, shows: 1, wins: 0, revenue: 0 },
// camp_2 (Google replacement)
{ date: '2026-06-04', campaignId: 'camp_2', platform: 'google', spend: 94, impressions: 3100, clicks: 180, leads: 6, appointments: 3, shows: 2, wins: 1, revenue: 26700 },
{ date: '2026-06-05', campaignId: 'camp_2', platform: 'google', spend: 96, impressions: 3250, clicks: 192, leads: 7, appointments: 4, shows: 3, wins: 1, revenue: 28100 },
{ date: '2026-06-06', campaignId: 'camp_2', platform: 'google', spend: 95, impressions: 3000, clicks: 175, leads: 5, appointments: 2, shows: 2, wins: 0, revenue: 0 },
{ date: '2026-06-07', campaignId: 'camp_2', platform: 'google', spend: 95, impressions: 3300, clicks: 200, leads: 8, appointments: 4, shows: 3, wins: 2, revenue: 51200 },
{ date: '2026-06-08', campaignId: 'camp_2', platform: 'google', spend: 97, impressions: 3400, clicks: 210, leads: 7, appointments: 3, shows: 3, wins: 1, revenue: 25400 },
{ date: '2026-06-09', campaignId: 'camp_2', platform: 'google', spend: 95, impressions: 3150, clicks: 188, leads: 6, appointments: 3, shows: 2, wins: 1, revenue: 23900 },
{ date: '2026-06-10', campaignId: 'camp_2', platform: 'google', spend: 48, impressions: 1600, clicks: 95, leads: 4, appointments: 2, shows: 1, wins: 0, revenue: 0 },
// camp_3 (LinkedIn commercial)
{ date: '2026-06-04', campaignId: 'camp_3', platform: 'linkedin', spend: 70, impressions: 1400, clicks: 42, leads: 2, appointments: 1, shows: 1, wins: 0, revenue: 0 },
{ date: '2026-06-05', campaignId: 'camp_3', platform: 'linkedin', spend: 68, impressions: 1350, clicks: 38, leads: 1, appointments: 1, shows: 0, wins: 0, revenue: 0 },
{ date: '2026-06-06', campaignId: 'camp_3', platform: 'linkedin', spend: 71, impressions: 1500, clicks: 45, leads: 2, appointments: 1, shows: 1, wins: 1, revenue: 84000 },
{ date: '2026-06-07', campaignId: 'camp_3', platform: 'linkedin', spend: 70, impressions: 1420, clicks: 40, leads: 1, appointments: 0, shows: 0, wins: 0, revenue: 0 },
{ date: '2026-06-08', campaignId: 'camp_3', platform: 'linkedin', spend: 72, impressions: 1480, clicks: 44, leads: 3, appointments: 2, shows: 1, wins: 0, revenue: 0 },
{ date: '2026-06-09', campaignId: 'camp_3', platform: 'linkedin', spend: 69, impressions: 1390, clicks: 39, leads: 2, appointments: 1, shows: 1, wins: 0, revenue: 0 },
{ date: '2026-06-10', campaignId: 'camp_3', platform: 'linkedin', spend: 35, impressions: 700, clicks: 20, leads: 1, appointments: 1, shows: 0, wins: 0, revenue: 0 },
// camp_4 (Google emergency)
{ date: '2026-06-04', campaignId: 'camp_4', platform: 'google', spend: 44, impressions: 1100, clicks: 88, leads: 4, appointments: 3, shows: 3, wins: 2, revenue: 18400 },
{ date: '2026-06-05', campaignId: 'camp_4', platform: 'google', spend: 45, impressions: 1180, clicks: 95, leads: 5, appointments: 4, shows: 3, wins: 2, revenue: 21200 },
{ date: '2026-06-06', campaignId: 'camp_4', platform: 'google', spend: 46, impressions: 1050, clicks: 82, leads: 3, appointments: 2, shows: 2, wins: 1, revenue: 9800 },
{ date: '2026-06-07', campaignId: 'camp_4', platform: 'google', spend: 45, impressions: 1200, clicks: 99, leads: 5, appointments: 4, shows: 4, wins: 3, revenue: 27600 },
{ date: '2026-06-08', campaignId: 'camp_4', platform: 'google', spend: 45, impressions: 1150, clicks: 90, leads: 4, appointments: 3, shows: 2, wins: 1, revenue: 10500 },
{ date: '2026-06-09', campaignId: 'camp_4', platform: 'google', spend: 44, impressions: 1090, clicks: 85, leads: 4, appointments: 3, shows: 3, wins: 2, revenue: 19100 },
{ date: '2026-06-10', campaignId: 'camp_4', platform: 'google', spend: 22, impressions: 540, clicks: 44, leads: 2, appointments: 2, shows: 1, wins: 0, revenue: 0 },
];
@@ -0,0 +1,108 @@
/**
* sessionStore.js a tiny module-level singleton that lets the PUBLIC ad
* lead-capture page (a separate, chrome-less route with its own React tree) hand a
* lead to the admin Social Ads Engine inbox.
*
* Why a singleton and not localStorage: the CRM demo deliberately avoids localStorage
* (state is in-memory and resets on reload). This singleton lives for the SPA session,
* so a lead submitted on /ads/lp and then viewed via /marketing#inbox (same tab) shows
* up live closing the spec's loop (§5) without persisting anything to disk.
*
* The admin SocialAdsProvider reads getPublicLeads() when it mounts AND subscribes to
* live updates, so a lead submitted on /ads/lp surfaces in the inbox immediately
* no CRM reload. Updates also bridge across browser tabs via BroadcastChannel (still
* no localStorage), so submitting in one tab updates an Ad Engine open in another.
*/
const publicLeads = [];
let seq = 0;
const newId = () => `lead_pub_${Date.now().toString(36)}_${seq++}`;
// ── Live update bus ──────────────────────────────────────────────────────────
const listeners = new Set();
/** Subscribe to new public leads; returns an unsubscribe fn. */
export function subscribeToPublicLeads(fn) {
listeners.add(fn);
return () => listeners.delete(fn);
}
function emit(lead) {
listeners.forEach(fn => { try { fn(lead); } catch { /* listener errors must not break ingest */ } });
}
// Cross-tab bridge: structured-clones the lead to other tabs. No disk persistence.
const channel = typeof BroadcastChannel !== 'undefined' ? new BroadcastChannel('lynkedup_ads_leads') : null;
if (channel) {
channel.onmessage = (e) => {
const lead = e.data?.type === 'new_lead' ? e.data.lead : null;
if (!lead || publicLeads.some(l => l.id === lead.id)) return;
publicLeads.unshift(lead); // keep this tab's store in sync for later remounts
emit(lead);
};
}
// Map a service type to a seed campaign/form so the lead has plausible attribution.
const SERVICE_CAMPAIGN = {
storm: { campaignId: 'camp_1', formId: 'form_1' },
emergency: { campaignId: 'camp_4', formId: 'form_3' },
commercial: { campaignId: 'camp_3', formId: 'form_2' },
replacement: { campaignId: 'camp_2', formId: null },
maintenance: { campaignId: null, formId: null },
referral: { campaignId: 'camp_5', formId: null },
};
/** Lightweight AI triage stand-in from the captured form answers (spec §6 triage). */
function triageFromCapture({ serviceType, activeLeak, role }) {
const urgency = activeLeak || serviceType === 'emergency' ? 'high' : 'medium';
const intent = { storm: 'inspection', emergency: 'emergency', commercial: 'bid', replacement: 'quote' }[serviceType] || 'inquiry';
let quality = 70;
if (activeLeak) quality += 15;
if (role === 'property_manager') quality += 10;
if (serviceType === 'commercial') quality += 5;
return { serviceType, urgency, role, intent, quality: Math.min(98, quality), confidence: 0.86 };
}
/** Build a fully-shaped ad_lead from the public capture form. */
export function buildLeadFromCapture(form) {
const { campaignId, formId } = SERVICE_CAMPAIGN[form.serviceType] || {};
return {
id: newId(),
platform: form.platform || 'meta',
campaignId: campaignId ?? null,
formId: formId ?? null,
externalLeadId: `pub_${Date.now().toString(36)}`,
receivedAt: new Date().toISOString(),
slaStartTs: Date.now(), // SLA clock starts now (live countdown)
status: 'new',
stage: 'lead',
name: form.name,
phone: form.phone,
email: form.email,
address: form.address,
consent: !!form.consent,
role: form.role,
activeLeak: !!form.activeLeak,
triage: triageFromCapture(form),
duplicateOf: null,
assignedTo: null,
dealValue: 0,
premium: false,
firstContactSec: null,
source: 'public_landing',
};
}
export function addPublicLead(form) {
const lead = buildLeadFromCapture(form);
publicLeads.unshift(lead);
emit(lead); // live update to a mounted provider
channel?.postMessage({ type: 'new_lead', lead }); // and to the Ad Engine in other tabs
return lead;
}
/** Newest-first copy for the provider to merge on mount. */
export function getPublicLeads() {
return [...publicLeads];
}
+69
View File
@@ -0,0 +1,69 @@
/**
* sla.js speed-to-lead SLA targets and live state (spec §10 "Speed-to-lead",
* §9 recipe response times, §11 "Lost leads" mitigation).
*
* Speed-to-lead = seconds from a lead being received to the first permitted outbound
* contact. A new, consented, non-duplicate lead is "on the clock" until it is
* assigned (first contact). Breaching the target should create a task/alert.
*
* Targets come from the campaign recipes (§9):
* emergency human escalation within 10 min
* high-urgency / storm / replacement text/call within 5 min
* commercial email within 1 hr
* everything else default 15 min
*/
export const SLA_TARGETS = {
emergency: 600,
commercial: 3600,
storm: 300,
replacement: 300,
maintenance: 900,
referral: 900,
default: 900,
};
/** Target seconds for a given lead (urgency can tighten the window). */
export function slaTargetSec(lead) {
const s = lead?.triage?.serviceType;
if (s === 'emergency') return SLA_TARGETS.emergency;
if (s === 'commercial') return SLA_TARGETS.commercial;
if (lead?.triage?.urgency === 'high') return SLA_TARGETS.storm;
return SLA_TARGETS[s] ?? SLA_TARGETS.default;
}
/** Resolve the clock start (ms). Public leads carry slaStartTs; seed leads derive it. */
function startMs(lead) {
if (lead?.slaStartTs) return lead.slaStartTs;
if (lead?.receivedAt) return Date.parse(lead.receivedAt);
return Date.now();
}
/**
* Live SLA state for a lead at a given clock time.
* eligible=false not on the clock (no consent, duplicate, or already contacted)
* eligible=true { elapsed, target, remaining, breached }
*/
export function slaState(lead, nowMs = Date.now()) {
if (!lead) return { eligible: false, reason: 'unknown' };
if (lead.status !== 'new' || lead.stage !== 'lead') return { eligible: false, reason: 'contacted' };
if (lead.duplicateOf) return { eligible: false, reason: 'duplicate' };
if (!lead.consent) return { eligible: false, reason: 'no_consent' };
const target = slaTargetSec(lead);
const elapsed = Math.max(0, Math.round((nowMs - startMs(lead)) / 1000));
const remaining = target - elapsed;
return { eligible: true, elapsed, target, remaining, breached: remaining < 0 };
}
/** Count leads currently breaching SLA (for banners / recommended actions). */
export function countBreaches(leads, nowMs = Date.now()) {
return leads.filter(l => slaState(l, nowMs).breached).length;
}
/** mm:ss formatter that prefixes a sign for breach overshoot. */
export function fmtClock(sec) {
const sign = sec < 0 ? '+' : '';
const a = Math.abs(sec);
return `${sign}${Math.floor(a / 60)}:${String(a % 60).padStart(2, '0')}`;
}
@@ -0,0 +1,68 @@
/**
* testLead.js simulate a platform lead payload flowing through a form's field
* mapping into CRM fields (spec phased C: "Test lead maps correctly into CRM fields").
*
* For each mapped field it generates a plausible raw value, applies the transform, runs
* the validation rule, and reports source transformed value CRM target with a
* pass/fail. Pure + deterministic no network, no real webhook.
*/
// Pick a believable raw value from the field's source/target keywords.
function sampleRaw(field) {
const s = `${field.source} ${field.target}`.toLowerCase();
if (/email/.test(s)) return 'Jane.Homeowner@Example.com';
if (/phone|callback/.test(s)) return '(972) 555-0142';
if (/name/.test(s)) return 'jane homeowner';
if (/address/.test(s)) return '123 Main St, Plano, TX 75023';
if (/leak|active/.test(s)) return 'yes';
if (/owner|tenant|role/.test(s)) return 'homeowner';
if (/company/.test(s)) return 'Pinnacle Property Group';
if (/title/.test(s)) return 'Facilities Manager';
if (/property.?type|\btype\b/.test(s)) return 'Office building';
if (/square|footage/.test(s)) return '12,000';
if (/count|age/.test(s)) return '18';
if (/timeline|window|when/.test(s)) return '30-60 days';
if (/date/.test(s)) return '2026-06-08';
if (/urgency/.test(s)) return 'high';
if (/insurance/.test(s)) return 'Filed';
if (/financing/.test(s)) return 'yes';
if (/warranty/.test(s)) return 'active';
if (/past.?customer/.test(s)) return 'yes';
return 'Sample value';
}
function applyTransform(value, t) {
switch (t) {
case 'titlecase': return value.replace(/\w\S*/g, w => w[0].toUpperCase() + w.slice(1).toLowerCase());
case 'lowercase': return value.toLowerCase();
case 'e164': { const d = value.replace(/\D/g, ''); return d ? `+1${d.slice(-10)}` : ''; }
case 'bool': return /^(yes|true|1|y)$/i.test(value.trim()) ? 'true' : 'false';
case 'int': { const n = parseInt(value.replace(/[^\d]/g, ''), 10); return Number.isNaN(n) ? '' : String(n); }
case 'date': return value;
default: return value;
}
}
function validate(field, value) {
const rule = (field.validation || '').toLowerCase();
if ((field.required || /required/.test(rule)) && !String(value).trim()) return 'Required value missing';
if (/email/.test(rule) && value && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(value)) return 'Invalid email format';
if (/phone/.test(rule) && value && value.replace(/\D/g, '').length < 10) return 'Invalid phone number';
return null;
}
/** Run a sample lead through the form mapping. Returns { rows, ok, issues }. */
export function runTestLead(form) {
const rows = (form.fields || []).map(f => {
const raw = sampleRaw(f);
const value = applyTransform(raw, f.transform || 'none');
const issue = validate(f, value);
return {
source: f.source, raw, transform: f.transform || 'none',
value, target: f.target || '(unmapped)', required: !!f.required, valid: !issue, issue,
};
});
// A row with no target is a mapping gap.
rows.forEach(r => { if (!r.target || r.target === '(unmapped)') { r.valid = false; r.issue = r.issue || 'No CRM target set'; } });
return { rows, ok: rows.length > 0 && rows.every(r => r.valid), issues: rows.filter(r => !r.valid).length };
}
+67
View File
@@ -0,0 +1,67 @@
/**
* utm.js trackable campaign links (spec §7 `utm_links` entity).
*
* Mints a UTM-tagged URL to the public landing page so every ad click is attributable
* end-to-end (source / medium / campaign / content / term destination). Front-end
* demo: pure string building, no shortener or backend.
*/
// Use the running app's origin so the copied link opens the demo landing page; in a
// real deployment this resolves to your public site (swap to your domain when hosting).
const ORIGIN = (typeof window !== 'undefined' && window.location?.origin) || 'https://lynkeduppro.com';
const BASE = `${ORIGIN}/ads/lp`;
// Ad medium by platform (search vs paid social vs messaging).
const MEDIUM = {
google: 'paid_search',
meta: 'paid_social',
linkedin: 'paid_social',
reddit: 'paid_social',
nextdoor: 'paid_social',
whatsapp: 'messaging',
};
const slug = (s) => (s || '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 60);
/**
* Build the tracked link + its utm_links field breakdown for a campaign.
* Returns { url, fields }.
*/
export function buildUtm(campaign, { creative } = {}) {
const source = campaign.platform;
const medium = MEDIUM[source] || 'paid';
const name = slug(campaign.name) || campaign.blueprintId || 'campaign';
const content = creative ? (slug(creative.headline) || creative.id) : (campaign.creativeId || 'default');
const term = campaign.serviceType || '';
const params = {
c: campaign.blueprintId,
src: source,
utm_source: source,
utm_medium: medium,
utm_campaign: name,
utm_content: content,
utm_term: term,
};
const qs = Object.entries(params)
.filter(([, v]) => v)
.map(([k, v]) => `${k}=${encodeURIComponent(v)}`)
.join('&');
return {
url: `${BASE}?${qs}`,
fields: {
campaignId: campaign.id || null,
source,
medium,
campaign: name,
content,
term,
destinationUrl: BASE,
},
};
}
@@ -0,0 +1,224 @@
/**
* attribution.js pure derivations for the Attribution Dashboard (spec §10).
*
* No state, no side effects: feed it the analyticsDaily rows and it returns the
* source-to-close metrics that matter to a roofing tenant. Keeping this pure means
* the dashboard and the hub home can both reuse it without duplicating math.
*/
const safeDiv = (a, b) => (b > 0 ? a / b : 0);
/** Sum the raw daily counters across a set of rows. */
export function totals(rows) {
return rows.reduce(
(acc, r) => ({
spend: acc.spend + r.spend,
impressions: acc.impressions + r.impressions,
clicks: acc.clicks + r.clicks,
leads: acc.leads + r.leads,
appointments: acc.appointments + r.appointments,
shows: acc.shows + r.shows,
wins: acc.wins + r.wins,
revenue: acc.revenue + r.revenue,
}),
{ spend: 0, impressions: 0, clicks: 0, leads: 0, appointments: 0, shows: 0, wins: 0, revenue: 0 },
);
}
/** Derived ratios from a totals() object (spec §10 metric definitions). */
export function metrics(t) {
return {
...t,
cpl: safeDiv(t.spend, t.leads), // cost per lead
costPerAppt: safeDiv(t.spend, t.appointments),
apptRate: safeDiv(t.appointments, t.leads), // appt scheduled / valid leads
showRate: safeDiv(t.shows, t.appointments),
closeRate: safeDiv(t.wins, t.appointments),
roas: safeDiv(t.revenue, t.spend), // return on ad spend
ctr: safeDiv(t.clicks, t.impressions),
};
}
/** Group rows by a key field and return metrics per group. */
export function metricsBy(rows, key) {
return metricsByFn(rows, (r) => r[key]);
}
/**
* Group rows by a derived key (function) and return metrics per group. Lets the
* dashboard break down by creative or playbook (spec §10 "by source/campaign/
* creative/playbook") even though the daily rows are keyed by campaign/platform.
* Rows whose keyFn returns null/undefined are skipped.
*/
export function metricsByFn(rows, keyFn) {
const buckets = {};
for (const r of rows) {
const k = keyFn(r);
if (k == null) continue;
(buckets[k] ||= []).push(r);
}
return Object.entries(buckets).map(([k, rs]) => ({ key: k, ...metrics(totals(rs)) }));
}
// ── Budget pacing (spec §4 Phase 4, §11 anomaly detection) ─────────────────────
/**
* Per active campaign: spend-to-date, average daily spend, a 30-day projection, and
* how that projection paces against the monthly cap. status: over | on | under.
*/
export function budgetPacing(campaigns, analyticsDaily) {
const spendBy = {};
const daysBy = {};
for (const r of analyticsDaily) {
spendBy[r.campaignId] = (spendBy[r.campaignId] || 0) + r.spend;
(daysBy[r.campaignId] ||= new Set()).add(r.date);
}
return campaigns.filter(c => c.status === 'active').map(c => {
const spent = spendBy[c.id] || 0;
const days = daysBy[c.id]?.size || 1;
const avgDaily = spent / days;
const monthlyCap = c.guardrails?.monthlyCap || c.budgetDaily * 30;
const projected = avgDaily * 30;
const pacePct = monthlyCap ? projected / monthlyCap : 0;
const status = pacePct > 1.05 ? 'over' : pacePct < 0.6 ? 'under' : 'on';
return { id: c.id, name: c.name, platform: c.platform, spent, avgDaily, monthlyCap, projected, pacePct, status };
});
}
// ── Multi-touch attribution models (spec §11: first/last/multi-touch) ──────────
/**
* Credit each journey's revenue to its touches under three models at once:
* first 100% to the first touch's source
* last 100% to the final (converting) touch
* multi split evenly (linear) across every touch
* keyFn maps a touch { platform, campaignId } to the active dimension key, so the
* same function powers platform/campaign/creative/playbook attribution.
*/
export function attributionByModel(journeys, keyFn) {
const acc = {};
const add = (k, model, v) => {
if (k == null) return;
(acc[k] ||= { first: 0, last: 0, multi: 0 });
acc[k][model] += v;
};
for (const j of journeys) {
const t = j.touches || [];
if (!t.length) continue;
add(keyFn(t[0]), 'first', j.revenue);
add(keyFn(t[t.length - 1]), 'last', j.revenue);
const share = j.revenue / t.length;
for (const touch of t) add(keyFn(touch), 'multi', share);
}
return Object.entries(acc).map(([key, v]) => ({ key, ...v }));
}
// ── Review & referral lift (spec §8 "reviews"; §10 review/referral lift) ───────
/** Aggregate a set of reviews into count, avg rating, referral leads, reactivation $. */
export function reviewTotals(reviews) {
const count = reviews.length;
return {
count,
avgRating: count ? reviews.reduce((s, r) => s + r.rating, 0) / count : 0,
referralLeads: reviews.reduce((s, r) => s + (r.referralLeads || 0), 0),
reactivationRevenue: reviews.reduce((s, r) => s + (r.reactivationRevenue || 0), 0),
};
}
/** Group reviews by a derived key and return the lift per group. */
export function reviewsByFn(reviews, keyFn) {
const buckets = {};
for (const r of reviews) {
const k = keyFn(r);
if (k == null) continue;
(buckets[k] ||= []).push(r);
}
return Object.entries(buckets).map(([k, rs]) => ({ key: k, ...reviewTotals(rs) }));
}
/** Time series of one numeric field, sorted by date (for line/area charts). */
export function timeSeries(rows, field) {
const byDate = {};
for (const r of rows) byDate[r.date] = (byDate[r.date] || 0) + r[field];
return Object.entries(byDate)
.sort(([a], [b]) => a.localeCompare(b))
.map(([date, value]) => ({ date: date.slice(5), value }));
}
/** Source-to-close funnel stages (spec §10 / §8 hub funnel). */
export function funnel(rows) {
const t = totals(rows);
return [
{ stage: 'Leads', value: t.leads },
{ stage: 'Appointments', value: t.appointments },
{ stage: 'Showed', value: t.shows },
{ stage: 'Won', value: t.wins },
];
}
// ── Live, CRM-event-driven derivations (spec §5 closed loop, §10) ──────────────
// These read the live `leads` array (not the historical analyticsDaily table) so the
// dashboard reflects actions taken in the Lead Inbox in real time. Analytics are
// based on CRM events, not impression-level assumptions (spec §6).
// Funnel order; index lets us ask "has the lead reached at least this stage?".
export const LIFECYCLE = ['lead', 'appointment', 'showed', 'estimate', 'won'];
const stageRank = (s) => LIFECYCLE.indexOf(s);
/** A lead "counts" for the funnel unless it was merged away or had no consent path. */
const isActive = (l) => l.status !== 'merged';
/** Reached at least `stage` (won counts toward estimate, showed, appointment, etc.). */
const reached = (l, stage) => l.stage !== 'lost' && stageRank(l.stage) >= stageRank(stage);
/** Live source-to-close counters from the leads array. */
export function liveCounts(leads) {
const active = leads.filter(isActive);
const contacted = active.filter(l => l.firstContactSec != null);
return {
leads: active.length,
qualified: active.filter(l => l.triage.quality >= 50).length,
spam: active.filter(l => l.triage.quality < 40).length,
duplicates: leads.filter(l => l.duplicateOf).length,
appointments: active.filter(l => reached(l, 'appointment')).length,
shows: active.filter(l => reached(l, 'showed')).length,
estimates: active.filter(l => reached(l, 'estimate')).length,
wins: active.filter(l => l.stage === 'won').length,
revenue: active.reduce((s, l) => s + (l.stage === 'won' ? l.dealValue : 0), 0),
premiumWins: active.filter(l => l.stage === 'won' && l.premium).length,
avgSpeedToLead: contacted.length
? Math.round(contacted.reduce((s, l) => s + l.firstContactSec, 0) / contacted.length)
: null,
contacted: contacted.length,
};
}
/** Live ratios (spec §10 metric definitions) derived from liveCounts. */
export function liveMetrics(leads) {
const c = liveCounts(leads);
return {
...c,
apptRate: safeDiv(c.appointments, c.leads),
showRate: safeDiv(c.shows, c.appointments),
estimateRate: safeDiv(c.estimates, c.shows),
closeRate: safeDiv(c.wins, c.estimates),
premiumMix: safeDiv(c.premiumWins, c.wins),
spamRate: safeDiv(c.spam, c.leads),
};
}
/** Live funnel array for the bar chart. */
export function liveFunnel(leads) {
const c = liveCounts(leads);
return [
{ stage: 'Leads', value: c.leads },
{ stage: 'Appointments', value: c.appointments },
{ stage: 'Showed', value: c.shows },
{ stage: 'Estimate', value: c.estimates },
{ stage: 'Won', value: c.wins },
];
}
/** Live metrics grouped by platform (for source-to-close by channel). */
export function liveMetricsByPlatform(leads) {
const buckets = {};
for (const l of leads.filter(isActive)) (buckets[l.platform] ||= []).push(l);
return Object.entries(buckets).map(([platform, ls]) => ({ key: platform, ...liveMetrics(ls) }));
}
@@ -0,0 +1,191 @@
/**
* AdToNurtureRules editable "if platform/service then playbook/rep/speed-to-lead/
* education" routing rules (spec §5, §8). Rules evaluate by priority (reorderable); a
* catch-all default guarantees no lead is left unmapped. Deterministic rules override
* AI triage.
*/
import React, { useState } from 'react';
import {
GitBranch, ArrowRight, Workflow, UserCheck, Zap, BookOpen, Power,
Plus, Trash2, ChevronUp, ChevronDown, CalendarClock, Sparkles, CheckCircle2,
} from 'lucide-react';
import { useSocialAds } from '../SocialAdsContext.jsx';
import { PLATFORMS } from '../data/platforms.js';
import { matchRule, recommendPath } from '../ai/nurtureAdvisor.js';
import { Card, SectionHeader, Btn } from '../components/ui.jsx';
const SERVICE_TYPES = ['storm', 'replacement', 'emergency', 'commercial', 'referral', 'maintenance'];
const SCHEDULING = ['Send scheduling link', 'Auto-offer next available slot', 'Manual booking by rep', 'Human callback within SLA', 'No scheduling (info only)'];
const selCls = 'w-full rounded-lg border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 px-2 py-1.5 text-base sm:text-xs text-zinc-800 dark:text-zinc-100 focus:outline-none focus:ring-2 focus:ring-amber-500';
const inCls = 'w-full rounded-lg border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 px-2 py-1.5 text-base sm:text-xs text-zinc-800 dark:text-zinc-100 focus:outline-none focus:ring-2 focus:ring-amber-500';
export default function AdToNurtureRules() {
const { nurtureRules, campaigns, leadForms, toggleRule, updateRule, addRule, removeRule, moveRule, addRuleFromSuggestion } = useSocialAds();
const sorted = [...nurtureRules].sort((a, b) => a.priority - b.priority);
const movable = sorted.filter(r => r.priority !== 99);
// AI "Next best nurture selection" (spec §6).
const [advPlatform, setAdvPlatform] = useState('google');
const [advService, setAdvService] = useState('emergency');
const [advice, setAdvice] = useState(null);
const suggest = () => {
const m = matchRule(nurtureRules, { platform: advPlatform, serviceType: advService });
if (m.rule && !m.isDefault) setAdvice({ covered: true, rule: m.rule });
else setAdvice({ covered: false, ...recommendPath(advService) });
};
const acceptAdvice = () => {
addRuleFromSuggestion({ platform: advPlatform, serviceType: advService }, advice.then);
setAdvice(null);
};
return (
<div>
<SectionHeader icon={GitBranch} title="Ad-to-Nurture Rules" description="Route each inbound lead to the right playbook, rep, speed-to-lead channel, and education track. Deterministic rules override AI triage.">
<Btn icon={Plus} onClick={addRule}>Add rule</Btn>
</SectionHeader>
{/* AI next-best-nurture (spec §6) */}
<div className="rounded-xl border border-purple-200 dark:border-purple-500/20 bg-purple-50/60 dark:bg-purple-500/5 p-3 mb-4">
<div className="flex flex-wrap items-end gap-2">
<div className="flex items-center gap-2 mr-auto">
<Sparkles size={15} className="text-purple-500 shrink-0" />
<p className="text-xs text-zinc-600 dark:text-zinc-300">Next best nurture pick a sample lead; AI recommends a route. Deterministic rules override AI.</p>
</div>
<select value={advPlatform} onChange={e => setAdvPlatform(e.target.value)} className={selCls + ' !w-auto'}>
{PLATFORMS.map(p => <option key={p.id} value={p.id}>{p.short}</option>)}
</select>
<select value={advService} onChange={e => setAdvService(e.target.value)} className={selCls + ' !w-auto'}>
{SERVICE_TYPES.map(s => <option key={s} value={s}>{s}</option>)}
</select>
<Btn size="sm" variant="outline" icon={Sparkles} onClick={suggest}>Suggest route</Btn>
</div>
{advice && (advice.covered ? (
<div className="mt-3 flex items-start gap-2 text-xs text-emerald-700 dark:text-emerald-400">
<CheckCircle2 size={14} className="mt-0.5 shrink-0" />
<span>Rule #{advice.rule.priority} (<b>{advice.rule.then.playbook}</b>) already routes this lead AI deferred to your deterministic rule.</span>
</div>
) : (
<div className="mt-3 rounded-lg bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-white/10 p-3">
<div className="flex flex-wrap items-center justify-between gap-2 mb-2">
<span className="text-[10px] font-bold uppercase tracking-wider text-purple-500">AI recommended path</span>
<Btn size="sm" variant="primary" icon={Plus} onClick={acceptAdvice}>Add as rule</Btn>
</div>
<p className="text-[11px] text-zinc-500 mb-2">{advice.rationale}</p>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-x-4 gap-y-1.5 text-[11px]">
<PathBit label="Playbook" value={advice.then.playbook} />
<PathBit label="Rep" value={advice.then.rep} />
<PathBit label="Speed-to-lead" value={advice.then.speedToLead} />
<PathBit label="Education" value={advice.then.education} />
<PathBit label="Scheduling" value={advice.then.scheduling} />
</div>
</div>
))}
</div>
<div className="space-y-3">
{sorted.map(rule => {
const isDefault = rule.priority === 99;
const idx = movable.findIndex(r => r.id === rule.id);
return (
<Card key={rule.id} className={`!h-auto ${!rule.enabled ? 'opacity-60' : ''}`} pad="p-4">
<div className="flex flex-col lg:flex-row lg:items-start gap-3">
{/* Priority + reorder */}
<div className="flex lg:flex-col items-center gap-1.5 shrink-0">
<span className={`flex items-center justify-center w-7 h-7 rounded-lg text-xs font-bold ${isDefault ? 'bg-zinc-200 dark:bg-zinc-700 text-zinc-500' : 'bg-amber-500/15 text-amber-600 dark:text-amber-400'}`}>
{isDefault ? '' : rule.priority}
</span>
{!isDefault && (
<div className="flex lg:flex-col">
<button onClick={() => moveRule(rule.id, -1)} disabled={idx === 0} className="p-0.5 text-zinc-400 hover:text-amber-500 disabled:opacity-30" title="Higher priority"><ChevronUp size={15} /></button>
<button onClick={() => moveRule(rule.id, 1)} disabled={idx === movable.length - 1} className="p-0.5 text-zinc-400 hover:text-amber-500 disabled:opacity-30" title="Lower priority"><ChevronDown size={15} /></button>
</div>
)}
</div>
{/* WHEN */}
<div className="lg:w-52 shrink-0">
<span className="text-[10px] font-bold uppercase tracking-wider text-zinc-400 block mb-1">When</span>
{isDefault ? (
<p className="text-xs text-zinc-500">Any platform · any service <span className="block text-[10px] text-zinc-400">(catch-all default)</span></p>
) : (
<div className="space-y-1.5">
<select value={rule.when.platform} onChange={e => updateRule(rule.id, { when: { platform: e.target.value } })} className={selCls}>
<option value="any">Any platform</option>
{PLATFORMS.map(p => <option key={p.id} value={p.id}>{p.short}</option>)}
</select>
<select value={rule.when.serviceType} onChange={e => updateRule(rule.id, { when: { serviceType: e.target.value } })} className={selCls}>
<option value="any">Any service</option>
{SERVICE_TYPES.map(s => <option key={s} value={s}>{s}</option>)}
</select>
<select value={rule.when.campaignId || 'any'} onChange={e => updateRule(rule.id, { when: { campaignId: e.target.value } })} className={selCls}>
<option value="any">Any campaign</option>
{campaigns.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
<select value={rule.when.formId || 'any'} onChange={e => updateRule(rule.id, { when: { formId: e.target.value } })} className={selCls}>
<option value="any">Any form</option>
{leadForms.map(f => <option key={f.id} value={f.id}>{f.title}</option>)}
</select>
</div>
)}
</div>
<ArrowRight size={16} className="text-amber-500 shrink-0 hidden lg:block mt-6" />
{/* THEN */}
<div className="flex-1 grid grid-cols-1 sm:grid-cols-2 gap-2 min-w-0">
<ThenField icon={Workflow} label="Playbook" value={rule.then.playbook} onChange={v => updateRule(rule.id, { then: { playbook: v } })} />
<ThenField icon={UserCheck} label="Rep" value={rule.then.rep} onChange={v => updateRule(rule.id, { then: { rep: v } })} />
<ThenField icon={Zap} label="Speed-to-lead" value={rule.then.speedToLead} onChange={v => updateRule(rule.id, { then: { speedToLead: v } })} />
<ThenField icon={BookOpen} label="Education" value={rule.then.education} onChange={v => updateRule(rule.id, { then: { education: v } })} />
<ThenField icon={CalendarClock} label="Scheduling" value={rule.then.scheduling || ''} options={SCHEDULING} onChange={v => updateRule(rule.id, { then: { scheduling: v } })} />
</div>
{/* Controls */}
<div className="flex lg:flex-col items-center gap-2 shrink-0">
<button onClick={() => toggleRule(rule.id)} disabled={isDefault}
className={`flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs font-semibold transition-colors ${rule.enabled ? 'text-emerald-600 dark:text-emerald-400 bg-emerald-500/10' : 'text-zinc-400 bg-zinc-100 dark:bg-white/5'} ${isDefault ? 'cursor-not-allowed opacity-60' : 'hover:opacity-80'}`}>
<Power size={13} /> {rule.enabled ? 'On' : 'Off'}
</button>
{!isDefault && (
<button onClick={() => removeRule(rule.id)} title="Delete rule" className="p-1.5 text-zinc-400 hover:text-red-500"><Trash2 size={15} /></button>
)}
</div>
</div>
</Card>
);
})}
</div>
<p className="text-[11px] text-zinc-400 mt-4 flex items-center gap-1">
<GitBranch size={12} /> Rules evaluate top-down by priority. If no specific rule matches, the default inbound playbook runs and an admin is alerted.
</p>
</div>
);
}
function PathBit({ label, value }) {
return (
<div className="min-w-0">
<span className="text-[9px] font-bold uppercase tracking-wider text-zinc-400 block">{label}</span>
<span className="text-zinc-700 dark:text-zinc-200">{value}</span>
</div>
);
}
function ThenField({ icon: Icon, label, value, onChange, options }) {
return (
<label className="min-w-0 block">
<span className="text-[9px] font-bold uppercase tracking-wider text-zinc-400 flex items-center gap-1 mb-1">{Icon && <Icon size={10} />}{label}</span>
{options
? (
<select value={value} onChange={e => onChange(e.target.value)} className={inCls}>
<option value=""></option>
{options.map(o => <option key={o} value={o}>{o}</option>)}
</select>
)
: <input value={value} onChange={e => onChange(e.target.value)} className={inCls} />}
</label>
);
}
@@ -0,0 +1,441 @@
/**
* AttributionDashboard spend, CPL, cost-per-appointment, show rate, close rate,
* revenue, and ROAS by source/campaign (spec §8, §10). Analytics are built from CRM
* events (the analyticsDaily series), not impression-level assumptions.
*/
import React, { useMemo, useState, useCallback } from 'react';
import {
BarChart3, DollarSign, Users, CalendarCheck, TrendingUp, Percent, Eye, Trophy,
Zap, FileText, Crown, Activity, Filter, X, Star, Gift, Repeat, Route,
ThumbsDown, Copy, CalendarX, Pause, Check, ShieldAlert,
} from 'lucide-react';
import {
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell,
LineChart, Line, CartesianGrid, Legend,
} from 'recharts';
import { useSocialAds } from '../SocialAdsContext.jsx';
import { totals, metrics, metricsBy, metricsByFn, timeSeries, liveMetrics, reviewTotals, reviewsByFn, attributionByModel, budgetPacing } from '../engine/attribution.js';
import { getPlatform } from '../data/platforms.js';
import { Card, KpiTile, SectionHeader, PlatformBadge } from '../components/ui.jsx';
const money = (n) => `$${Math.round(n).toLocaleString('en-US')}`;
const pct = (n) => `${Math.round(n * 100)}%`;
const mmss = (s) => s == null ? '—' : `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, '0')}s`;
const MODEL_NOTE = {
first: 'First-touch credits 100% to the source that first introduced the customer.',
last: 'Last-touch credits 100% to the final source before the sale (the default single-touch view).',
multi: 'Multi-touch splits credit evenly (linear) across every source in the journey.',
};
const ANOMALY_META = {
cpl_spike: { label: 'CPL spike', icon: TrendingUp, tone: 'red' },
quality_drop: { label: 'Lead quality drop', icon: ThumbsDown, tone: 'amber' },
duplicate_rate: { label: 'Abnormal duplicate rate', icon: Copy, tone: 'amber' },
appt_decline: { label: 'Source-to-appointment decline', icon: CalendarX, tone: 'red' },
};
const PACE_CHIP = {
over: 'text-red-600 dark:text-red-400 bg-red-500/10',
on: 'text-emerald-600 dark:text-emerald-400 bg-emerald-500/10',
under: 'text-amber-600 dark:text-amber-400 bg-amber-500/10',
};
const PACE_LABEL = { over: 'Pacing hot', on: 'On track', under: 'Under-pacing' };
export default function AttributionDashboard() {
const { analyticsDaily, campaigns, creatives, leads, reviews, journeys, anomalies, resolveAnomaly } = useSocialAds();
const [view, setView] = useState('platform'); // platform | campaign | creative | playbook
const [selected, setSelected] = useState(null); // drill-down: a key within the current view
const [model, setModel] = useState('last'); // attribution model: first | last | multi
// Lookups for the creative + playbook breakdowns (spec §10 dimensions).
const campById = useMemo(() => Object.fromEntries(campaigns.map(c => [c.id, c])), [campaigns]);
const creativeById = useMemo(() => Object.fromEntries(creatives.map(c => [c.id, c])), [creatives]);
const primaryCreativeByCampaign = useMemo(() => {
const m = {};
for (const cr of creatives) if (!m[cr.campaignId]) m[cr.campaignId] = cr;
return m;
}, [creatives]);
// The dimension key for any object carrying { platform, campaignId } works for
// both analyticsDaily rows and leads, so a drill-down filters both consistently.
const keyOf = useCallback((v, o) => {
if (v === 'platform') return o.platform;
if (v === 'campaign') return o.campaignId;
if (v === 'creative') return primaryCreativeByCampaign[o.campaignId]?.id || `nocre:${o.campaignId}`;
return campById[o.campaignId]?.playbook || 'Unmapped';
}, [primaryCreativeByCampaign, campById]);
// Scope spend + leads to the drilled-down entity (top KPIs + trend), if any.
const switchView = (v) => { setView(v); setSelected(null); };
const filteredDaily = useMemo(() => selected ? analyticsDaily.filter(r => keyOf(view, r) === selected) : analyticsDaily, [analyticsDaily, selected, view, keyOf]);
const filteredLeads = useMemo(() => selected ? leads.filter(l => keyOf(view, l) === selected) : leads, [leads, selected, view, keyOf]);
const filteredReviews = useMemo(() => selected ? reviews.filter(r => keyOf(view, r) === selected) : reviews, [reviews, selected, view, keyOf]);
// Review & referral lift (spec §10) totals scope to the drill-down; the breakdown
// groups all reviews by the current dimension.
const reviewAgg = useMemo(() => reviewTotals(filteredReviews), [filteredReviews]);
const reviewsBreakdown = useMemo(() => reviewsByFn(reviews, r => keyOf(view, r)).sort((a, b) => b.count - a.count), [reviews, view, keyOf]);
// Multi-touch attribution by model (spec §11), grouped by the current dimension.
const modelRows = useMemo(() => attributionByModel(journeys, t => keyOf(view, t)).sort((a, b) => b[model] - a[model]), [journeys, view, keyOf, model]);
const modelTotal = useMemo(() => modelRows.reduce((s, r) => s + r[model], 0), [modelRows, model]);
// Budget pacing + open anomaly alerts (spec §11).
const pacing = useMemo(() => budgetPacing(campaigns, analyticsDaily), [campaigns, analyticsDaily]);
const openAlerts = useMemo(() => anomalies.filter(a => a.status === 'open'), [anomalies]);
// Spend/ROAS = platform-reported; leadwon + rates = live CRM events (spec §6, §10).
// Both honor the active drill-down filter so the KPI cards reconcile with it.
const reported = useMemo(() => metrics(totals(filteredDaily)), [filteredDaily]);
const live = useMemo(() => liveMetrics(filteredLeads), [filteredLeads]);
const m = useMemo(() => ({
...live,
spend: reported.spend,
cpl: live.leads ? reported.spend / live.leads : 0,
costPerAppt: live.appointments ? reported.spend / live.appointments : 0,
roas: reported.spend ? live.revenue / reported.spend : 0,
}), [reported, live]);
const bySource = useMemo(() => {
if (view === 'platform') return metricsBy(analyticsDaily, 'platform');
if (view === 'campaign') return metricsBy(analyticsDaily, 'campaignId');
if (view === 'creative') return metricsByFn(analyticsDaily, r => primaryCreativeByCampaign[r.campaignId]?.id || `nocre:${r.campaignId}`);
if (view === 'playbook') return metricsByFn(analyticsDaily, r => campById[r.campaignId]?.playbook || 'Unmapped');
return [];
}, [analyticsDaily, view, primaryCreativeByCampaign, campById]);
const revenueSeries = useMemo(() => {
// Merge revenue + spend per date for a dual-line chart (respects drill-down).
const rev = timeSeries(filteredDaily, 'revenue');
const spend = timeSeries(filteredDaily, 'spend');
return rev.map((r, i) => ({ date: r.date, revenue: r.value, spend: spend[i]?.value || 0 }));
}, [filteredDaily]);
const campName = (id) => campById[id]?.name || id;
// Full + short labels for whichever dimension is selected.
const labelFor = (k) => {
if (view === 'platform') return getPlatform(k)?.name || k;
if (view === 'campaign') return campName(k);
if (view === 'creative') return k.startsWith('nocre:') ? `${campName(k.slice(6))} — no creative on file` : (creativeById[k]?.headline || k);
return k; // playbook key is the name itself
};
const shortLabel = (k) => {
if (view === 'platform') return getPlatform(k)?.short || k;
if (view === 'campaign') return campName(k).slice(0, 10);
if (view === 'creative') return (k.startsWith('nocre:') ? campName(k.slice(6)) : (creativeById[k]?.headline || k)).slice(0, 12);
return k.split(' ').slice(0, 2).join(' ');
};
const barColor = (k) => {
if (view === 'platform') return getPlatform(k)?.color || '#3b82f6';
if (view === 'creative' && !k.startsWith('nocre:')) return getPlatform(creativeById[k]?.platform)?.color || '#3b82f6';
return '#3b82f6';
};
// ROAS leaderboard
const ranked = [...bySource].sort((a, b) => b.roas - a.roas);
return (
<div className="space-y-6">
<SectionHeader icon={BarChart3} title="Attribution Dashboard" description="Source-to-close economics built from CRM events — leads alone are not enough.">
<div className="flex flex-wrap rounded-lg overflow-hidden border border-zinc-200 dark:border-white/10">
{['platform', 'campaign', 'creative', 'playbook'].map(v => (
<button key={v} onClick={() => switchView(v)}
className={`px-3 py-1.5 text-xs font-semibold capitalize ${view === v ? 'bg-amber-500 text-white' : 'bg-white dark:bg-zinc-900 text-zinc-500'}`}>
By {v}
</button>
))}
</div>
</SectionHeader>
{/* Live banner + drill-down filter chip */}
<div className="flex flex-wrap items-center justify-between gap-2 -mb-2">
<div className="flex items-center gap-2 text-[11px] text-emerald-600 dark:text-emerald-400">
<Activity size={13} className="animate-pulse" />
<span className="font-semibold">Live</span>
<span className="text-zinc-400"> lead-to-won metrics update from CRM events as you act in the Lead Inbox. Spend is platform-reported.</span>
</div>
{selected && (
<button onClick={() => setSelected(null)}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-semibold bg-amber-500/15 text-amber-700 dark:text-amber-400 hover:bg-amber-500/25 transition-colors">
<Filter size={12} /> {labelFor(selected)} <span className="text-amber-500/60">· by {view}</span> <X size={13} />
</button>
)}
</div>
{/* Budget pacing & anomaly alerts (spec §11) */}
<Card title="Budget pacing & anomaly alerts" subtitle="AI flags spend/quality anomalies; you pause or review" icon={ShieldAlert}>
{/* Alerts */}
{openAlerts.length === 0 ? (
<div className="flex items-center gap-2 text-sm text-emerald-600 dark:text-emerald-400 mb-4">
<Check size={16} /> No open anomalies all campaigns within guardrails.
</div>
) : (
<div className="space-y-2 mb-5">
{openAlerts.map(a => {
const meta = ANOMALY_META[a.type] || ANOMALY_META.cpl_spike;
const AIcon = meta.icon;
const toneCls = meta.tone === 'red' ? 'text-red-500 bg-red-500/10' : 'text-amber-500 bg-amber-500/10';
return (
<div key={a.id} className="flex flex-col sm:flex-row sm:items-center gap-3 p-3 rounded-xl border border-zinc-200 dark:border-white/10 bg-zinc-50 dark:bg-white/[0.02]">
<span className={`p-2 rounded-lg shrink-0 self-start ${toneCls}`}>{AIcon && <AIcon size={16} />}</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-bold text-sm text-zinc-900 dark:text-white">{campName(a.campaignId)}</span>
<span className={`text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded ${a.severity === 'high' ? 'text-red-600 dark:text-red-400 bg-red-500/10' : 'text-amber-600 dark:text-amber-400 bg-amber-500/10'}`}>{a.severity}</span>
<span className="text-[11px] text-zinc-400">{meta.label}</span>
</div>
<p className="text-xs font-mono text-zinc-700 dark:text-zinc-200 mt-0.5">{a.metric}</p>
<p className="text-[11px] text-zinc-500 dark:text-zinc-400">{a.detail}</p>
<p className="text-[11px] text-purple-500 mt-0.5 flex items-center gap-1"><Activity size={11} /> AI recommends: <b>{a.recommendation === 'pause' ? 'pause the campaign' : 'review & optimize'}</b></p>
</div>
<div className="flex items-center gap-1.5 shrink-0 self-start sm:self-center">
<button onClick={() => resolveAnomaly(a.id, 'paused')} title="Pause campaign"
className={`inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs font-semibold ${a.recommendation === 'pause' ? 'bg-red-500/10 text-red-600 dark:text-red-400 hover:bg-red-500/20' : 'bg-zinc-100 dark:bg-white/5 text-zinc-600 dark:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-white/10'}`}>
<Pause size={13} /> Pause
</button>
<button onClick={() => resolveAnomaly(a.id, 'reviewed')} title="Mark reviewed"
className="inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs font-semibold bg-zinc-100 dark:bg-white/5 text-zinc-600 dark:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-white/10">
<Check size={13} /> Review
</button>
<button onClick={() => resolveAnomaly(a.id, 'dismissed')} title="Dismiss" className="p-1.5 text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200"><X size={15} /></button>
</div>
</div>
);
})}
</div>
)}
{/* Budget pacing per active campaign */}
<p className="text-[10px] font-bold uppercase tracking-wider text-zinc-400 mb-2">Budget pacing active campaigns</p>
<div className="space-y-2">
{pacing.map(p => (
<div key={p.id} className="flex items-center gap-3">
<span className="text-xs font-semibold text-zinc-700 dark:text-zinc-200 w-40 sm:w-52 truncate shrink-0" title={p.name}>{p.name}</span>
<div className="flex-1 min-w-0">
<div className="h-2 rounded-full bg-zinc-200 dark:bg-zinc-700 overflow-hidden">
<div className={`h-full ${p.status === 'over' ? 'bg-red-500' : p.status === 'under' ? 'bg-amber-500' : 'bg-emerald-500'}`} style={{ width: `${Math.min(100, p.pacePct * 100)}%` }} />
</div>
</div>
<span className="text-[11px] font-mono text-zinc-400 w-28 text-right shrink-0 hidden sm:block">{money(p.projected)} / {money(p.monthlyCap)}</span>
<span className={`text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded-full shrink-0 ${PACE_CHIP[p.status]}`}>{PACE_LABEL[p.status]}</span>
</div>
))}
</div>
</Card>
{/* KPI row */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<KpiTile label="Spend (reported)" value={money(m.spend)} icon={DollarSign} color="amber" />
<KpiTile label="CPL" value={money(m.cpl)} icon={Users} color="blue" sub={`${m.leads} leads`} />
<KpiTile label="Cost / appt" value={money(m.costPerAppt)} icon={CalendarCheck} color="purple" sub={`${m.appointments} appts`} />
<KpiTile label="ROAS" value={`${m.roas.toFixed(1)}×`} icon={TrendingUp} color="emerald" sub={money(m.revenue)} />
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<KpiTile label="Appt rate" value={pct(m.apptRate)} icon={CalendarCheck} color="blue" />
<KpiTile label="Show rate" value={pct(m.showRate)} icon={Eye} color="cyan" />
<KpiTile label="Estimate rate" value={pct(m.estimateRate)} icon={FileText} color="blue" sub={`${m.estimates} sent`} />
<KpiTile label="Close rate" value={pct(m.closeRate)} icon={Percent} color="emerald" sub={`${m.wins} won`} />
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<KpiTile label="Speed-to-lead" value={mmss(m.avgSpeedToLead)} icon={Zap} color="amber" sub="avg to first contact" />
<KpiTile label="Premium mix" value={pct(m.premiumMix)} icon={Crown} color="purple" sub={`${m.premiumWins} premium`} />
<KpiTile label="Qualified leads" value={m.qualified} icon={Users} color="emerald" sub={`spam ${pct(m.spamRate)}`} />
<KpiTile label="Revenue (won)" value={money(m.revenue)} icon={Trophy} color="amber" />
</div>
{/* Revenue vs spend */}
<Card title="Revenue vs. spend" subtitle="Daily, all sources" icon={TrendingUp}>
<ResponsiveContainer width="100%" height={240}>
<LineChart data={revenueSeries} margin={{ top: 5, right: 10, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#71717a22" vertical={false} />
<XAxis dataKey="date" tick={{ fill: '#71717a', fontSize: 11 }} axisLine={false} tickLine={false} />
<YAxis tick={{ fill: '#71717a', fontSize: 11 }} axisLine={false} tickLine={false} />
<Tooltip contentStyle={{ background: 'rgba(24,24,27,0.95)', border: '1px solid #3f3f46', borderRadius: 12, fontSize: 12 }} labelStyle={{ color: '#fff' }} formatter={(v, n) => [money(v), n === 'revenue' ? 'Revenue' : 'Spend']} />
<Legend wrapperStyle={{ fontSize: 12 }} />
<Line type="monotone" dataKey="revenue" stroke="#10b981" strokeWidth={2.5} dot={false} name="Revenue" />
<Line type="monotone" dataKey="spend" stroke="#f59e0b" strokeWidth={2} dot={false} name="Spend" />
</LineChart>
</ResponsiveContainer>
</Card>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* CPL by source */}
<Card title={`Cost per lead by ${view}`} icon={Users}>
<ResponsiveContainer width="100%" height={240}>
<BarChart data={bySource} margin={{ top: 5, right: 20, left: -10, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#71717a22" vertical={false} />
<XAxis dataKey="key" tick={{ fill: '#71717a', fontSize: 10 }} axisLine={false} tickLine={false}
interval={0} tickFormatter={shortLabel} />
<YAxis tick={{ fill: '#71717a', fontSize: 11 }} axisLine={false} tickLine={false} />
<Tooltip cursor={{ fill: 'transparent' }} contentStyle={{ background: 'rgba(24,24,27,0.95)', border: '1px solid #3f3f46', borderRadius: 12, fontSize: 12 }}
formatter={(v) => [money(v), 'CPL']} labelFormatter={labelFor} />
<Bar dataKey="cpl" radius={[6, 6, 0, 0]} barSize={36}>
{bySource.map((s, i) => <Cell key={i} fill={barColor(s.key)} />)}
</Bar>
</BarChart>
</ResponsiveContainer>
</Card>
{/* ROAS leaderboard */}
<Card title="ROAS leaderboard" subtitle="Ranked by return — click a row to filter" icon={Trophy}>
<div className="space-y-2">
{ranked.map((s, i) => (
<div key={s.key} onClick={() => setSelected(sel => sel === s.key ? null : s.key)}
title="Click to filter the dashboard to this row"
className={`flex items-center gap-3 px-3 py-2 rounded-lg cursor-pointer transition-colors ${selected === s.key ? 'bg-amber-50 dark:bg-amber-500/10 ring-1 ring-amber-400/60' : 'bg-zinc-50 dark:bg-white/5 hover:bg-zinc-100 dark:hover:bg-white/10'}`}>
<span className="text-xs font-bold text-zinc-400 w-4">{i + 1}</span>
{view === 'platform'
? <PlatformBadge platformId={s.key} />
: <span className="text-sm font-semibold text-zinc-700 dark:text-zinc-200 truncate flex-1" title={labelFor(s.key)}>{labelFor(s.key)}</span>}
<div className="flex items-center gap-3 ml-auto text-xs">
<span className="text-zinc-400">{money(s.revenue)}</span>
<span className={`font-mono font-bold w-12 text-right ${s.roas >= 1 ? 'text-emerald-500' : 'text-red-500'}`}>{s.roas.toFixed(1)}×</span>
</div>
</div>
))}
</div>
</Card>
</div>
{/* Review & referral lift (spec §8 "reviews"; §10 review/referral lift) */}
<Card title="Review & referral lift" subtitle={`Post-job reviews attributed back to source${selected ? ` · filtered to ${labelFor(selected)}` : ''}`} icon={Star}>
<div className="grid grid-cols-3 gap-2 mb-4">
<LiftStat icon={Star} color="text-amber-500" value={reviewAgg.count} label="Reviews" sub={reviewAgg.count ? `${reviewAgg.avgRating.toFixed(1)}★ avg` : 'no reviews'} />
<LiftStat icon={Gift} color="text-emerald-500" value={reviewAgg.referralLeads} label="Referral leads" sub="generated" />
<LiftStat icon={Repeat} color="text-purple-500" value={money(reviewAgg.reactivationRevenue)} label="Reactivation $" sub="repeat revenue" />
</div>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="text-left text-[10px] uppercase tracking-wider text-zinc-400 border-b border-zinc-100 dark:border-white/5">
<th className="py-2 pr-3 font-bold">{view}</th>
<th className="py-2 px-2 font-bold text-right">Reviews</th>
<th className="py-2 px-2 font-bold text-right">Avg rating</th>
<th className="py-2 px-2 font-bold text-right">Referral leads</th>
<th className="py-2 pl-2 font-bold text-right">Reactivation $</th>
</tr>
</thead>
<tbody>
{reviewsBreakdown.length === 0 && <tr><td colSpan={5} className="py-4 text-center text-zinc-400">No reviews for this dimension yet.</td></tr>}
{reviewsBreakdown.map(b => (
<tr key={b.key} onClick={() => setSelected(sel => sel === b.key ? null : b.key)}
className={`border-b border-zinc-50 dark:border-white/5 cursor-pointer transition-colors ${selected === b.key ? 'bg-amber-50 dark:bg-amber-500/10' : 'hover:bg-zinc-50 dark:hover:bg-white/5'}`}>
<td className="py-2 pr-3">
{view === 'platform' ? <PlatformBadge platformId={b.key} size="sm" /> : <span className="font-semibold text-zinc-700 dark:text-zinc-200">{labelFor(b.key)}</span>}
</td>
<td className="py-2 px-2 text-right font-mono">{b.count}</td>
<td className="py-2 px-2 text-right font-mono text-amber-600 dark:text-amber-400">{b.avgRating.toFixed(1)}</td>
<td className="py-2 px-2 text-right font-mono text-emerald-600 dark:text-emerald-400">{b.referralLeads}</td>
<td className="py-2 pl-2 text-right font-mono">{money(b.reactivationRevenue)}</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
{/* Multi-touch attribution models (spec §11) */}
<Card title="Multi-touch attribution" subtitle={`How closed-won revenue is credited across sources, by ${view}`} icon={Route}
action={
<div className="flex rounded-lg overflow-hidden border border-zinc-200 dark:border-white/10">
{[['first', 'First-touch'], ['last', 'Last-touch'], ['multi', 'Multi-touch']].map(([m, label]) => (
<button key={m} onClick={() => setModel(m)}
className={`px-2.5 py-1.5 text-[11px] font-semibold ${model === m ? 'bg-amber-500 text-white' : 'bg-white dark:bg-zinc-900 text-zinc-500'}`}>
{label}
</button>
))}
</div>
}>
<p className="text-[11px] text-zinc-500 dark:text-zinc-400 mb-3 flex items-start gap-1.5">
<Route size={13} className="text-amber-500 mt-0.5 shrink-0" />
{MODEL_NOTE[model]} Highlighted column is the active model; compare to see how credit shifts.
</p>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="text-left text-[10px] uppercase tracking-wider text-zinc-400 border-b border-zinc-100 dark:border-white/5">
<th className="py-2 pr-3 font-bold">{view}</th>
<th className={`py-2 px-2 font-bold text-right ${model === 'first' ? 'text-amber-600 dark:text-amber-400' : ''}`}>First-touch</th>
<th className={`py-2 px-2 font-bold text-right ${model === 'last' ? 'text-amber-600 dark:text-amber-400' : ''}`}>Last-touch</th>
<th className={`py-2 pl-2 font-bold text-right ${model === 'multi' ? 'text-amber-600 dark:text-amber-400' : ''}`}>Multi-touch</th>
</tr>
</thead>
<tbody>
{modelRows.length === 0 && <tr><td colSpan={4} className="py-4 text-center text-zinc-400">No closed-won journeys for this dimension yet.</td></tr>}
{modelRows.map(r => (
<tr key={r.key} onClick={() => setSelected(sel => sel === r.key ? null : r.key)}
className={`border-b border-zinc-50 dark:border-white/5 cursor-pointer transition-colors ${selected === r.key ? 'bg-amber-50 dark:bg-amber-500/10' : 'hover:bg-zinc-50 dark:hover:bg-white/5'}`}>
<td className="py-2 pr-3">
{view === 'platform' ? <PlatformBadge platformId={r.key} size="sm" /> : <span className="font-semibold text-zinc-700 dark:text-zinc-200">{labelFor(r.key)}</span>}
</td>
<td className={`py-2 px-2 text-right font-mono ${model === 'first' ? 'font-bold text-amber-600 dark:text-amber-400' : 'text-zinc-500'}`}>{money(r.first)}</td>
<td className={`py-2 px-2 text-right font-mono ${model === 'last' ? 'font-bold text-amber-600 dark:text-amber-400' : 'text-zinc-500'}`}>{money(r.last)}</td>
<td className={`py-2 pl-2 text-right font-mono ${model === 'multi' ? 'font-bold text-amber-600 dark:text-amber-400' : 'text-zinc-500'}`}>{money(r.multi)}</td>
</tr>
))}
</tbody>
{modelRows.length > 0 && (
<tfoot>
<tr className="text-[11px] font-bold text-zinc-600 dark:text-zinc-300">
<td className="py-2 pr-3">Total ({model}-touch)</td>
<td colSpan={3} className="py-2 pl-2 text-right font-mono text-emerald-600 dark:text-emerald-400">{money(modelTotal)}</td>
</tr>
</tfoot>
)}
</table>
</div>
</Card>
{/* Detail table */}
<Card title={`Performance by ${view}`} subtitle="Click a row to filter the KPIs above" icon={BarChart3}>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="text-left text-[10px] uppercase tracking-wider text-zinc-400 border-b border-zinc-100 dark:border-white/5">
<th className="py-2 pr-3 font-bold">{view}</th>
<th className="py-2 px-2 font-bold text-right">Spend</th>
<th className="py-2 px-2 font-bold text-right">Leads</th>
<th className="py-2 px-2 font-bold text-right">CPL</th>
<th className="py-2 px-2 font-bold text-right">Appts</th>
<th className="py-2 px-2 font-bold text-right">Show</th>
<th className="py-2 px-2 font-bold text-right">Close</th>
<th className="py-2 px-2 font-bold text-right">Revenue</th>
<th className="py-2 pl-2 font-bold text-right">ROAS</th>
</tr>
</thead>
<tbody>
{bySource.map(s => (
<tr key={s.key} onClick={() => setSelected(sel => sel === s.key ? null : s.key)}
className={`border-b border-zinc-50 dark:border-white/5 cursor-pointer transition-colors ${selected === s.key ? 'bg-amber-50 dark:bg-amber-500/10' : 'hover:bg-zinc-50 dark:hover:bg-white/5'}`}>
<td className="py-2 pr-3">
{view === 'platform' ? <PlatformBadge platformId={s.key} size="sm" /> : <span className="font-semibold text-zinc-700 dark:text-zinc-200">{labelFor(s.key)}</span>}
</td>
<td className="py-2 px-2 text-right font-mono">{money(s.spend)}</td>
<td className="py-2 px-2 text-right font-mono">{s.leads}</td>
<td className="py-2 px-2 text-right font-mono">{money(s.cpl)}</td>
<td className="py-2 px-2 text-right font-mono">{s.appointments}</td>
<td className="py-2 px-2 text-right font-mono">{pct(s.showRate)}</td>
<td className="py-2 px-2 text-right font-mono">{pct(s.closeRate)}</td>
<td className="py-2 px-2 text-right font-mono text-emerald-600 dark:text-emerald-400">{money(s.revenue)}</td>
<td className={`py-2 pl-2 text-right font-mono font-bold ${s.roas >= 1 ? 'text-emerald-500' : 'text-red-500'}`}>{s.roas.toFixed(1)}×</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
</div>
);
}
function LiftStat({ icon: Icon, color, value, label, sub }) {
return (
<div className="rounded-xl bg-zinc-50 dark:bg-white/5 p-3 text-center">
{Icon && <Icon size={16} className={`${color} mx-auto mb-1`} />}
<p className="text-lg font-mono font-bold text-zinc-800 dark:text-zinc-100">{value}</p>
<p className="text-[10px] uppercase tracking-wider text-zinc-400">{label}</p>
{sub && <p className="text-[10px] text-zinc-400 mt-0.5">{sub}</p>}
</div>
);
}
@@ -0,0 +1,379 @@
/**
* AudienceSegments reusable audiences with consent count, suppression count,
* export/sync status, refresh, AND an editable segment builder with a live preview
* count that always applies opt-out + suppression filtering (spec §4, §8, §12 E1/E2).
* Exports/syncs only permitted, consented, suppression-filtered contacts.
*/
import React, { useState, useMemo } from 'react';
import { createPortal } from 'react-dom';
import {
Users, RefreshCw, Download, Upload, ShieldCheck, ShieldX, UserCheck,
Plus, Pencil, X, Target, MapPin, Lock, ShieldQuestion, Sparkles, SlidersHorizontal,
CheckCircle2, ChevronDown, CircleDashed,
} from 'lucide-react';
import { useSocialAds } from '../SocialAdsContext.jsx';
import { FILTER_OPTIONS, previewSegment, DEFAULT_FILTER_DEF } from '../data/contactPool.js';
import { AUDIENCE_SUGGESTIONS } from '../ai/audienceAdvisor.js';
import { Card, SectionHeader, Btn } from '../components/ui.jsx';
import { toast } from 'sonner';
const SEGMENT_LABEL = {
storm_geo: 'Storm geo', crm_stage: 'CRM stage', reactivation: 'Reactivation',
b2b: 'B2B', referral: 'Referral',
};
const LAST_JOB_OPTIONS = [['', 'Any time'], ['90', '< 90 days'], ['180', '< 180 days'], ['365', '< 1 year'], ['540', '< 18 months']];
export default function AudienceSegments() {
const { audiences, createAudience, updateAudience, syncAudience, refreshAudience } = useSocialAds();
const [editing, setEditing] = useState(null); // { audience } edit · { preset } new-from-AI · {} blank new · null closed
const [dismissed, setDismissed] = useState([]);
// Hide a suggestion once a segment with that name exists, or the user dismisses it.
const existingNames = useMemo(() => new Set(audiences.map(a => a.name)), [audiences]);
const suggestions = AUDIENCE_SUGGESTIONS.filter(s => !existingNames.has(s.name) && !dismissed.includes(s.key));
const exportCsv = (a) => toast.success(`Exported ${(a.eligibleCount ?? a.consentCount).toLocaleString()} eligible contacts from "${a.name}" to CSV`);
const handleSync = (a, dest) => {
const tid = toast.loading(`Syncing "${a.name}" to ${dest}`);
setTimeout(() => { toast.dismiss(tid); syncAudience(a.id, dest); }, 700);
};
return (
<div>
<SectionHeader icon={Users} title="Audience Segments" description="Reusable CRM audiences. Only consented contacts sync, and suppression lists are always applied — including for retargeting.">
<Btn icon={Plus} onClick={() => setEditing({})}>New segment</Btn>
</SectionHeader>
{/* AI audience suggestions (spec §6) */}
{suggestions.length > 0 && (
<div className="rounded-xl border border-purple-200 dark:border-purple-500/20 bg-purple-50/60 dark:bg-purple-500/5 p-3 mb-5">
<div className="flex items-center gap-2 mb-3">
<Sparkles size={15} className="text-purple-500 shrink-0" />
<p className="text-xs text-zinc-600 dark:text-zinc-300">AI suggested segments consent + suppression always applied; no sensitive-attribute targeting.</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{suggestions.map(s => (
<SuggestionCard key={s.key} s={s}
onCreate={() => createAudience({ name: s.name, segmentType: s.segmentType, filterDef: s.filterDef })}
onCustomize={() => setEditing({ preset: s })}
onDismiss={() => setDismissed(d => [...d, s.key])} />
))}
</div>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{audiences.map(a => {
const eligible = a.eligibleCount ?? a.consentCount;
const eligiblePct = a.totalCount ? Math.round((eligible / a.totalCount) * 100) : 0;
return (
<Card key={a.id} className="!h-auto" pad="p-4">
<div className="flex items-start justify-between mb-2 gap-2">
<div className="min-w-0">
<h4 className="font-bold text-sm text-zinc-900 dark:text-white">{a.name}</h4>
<p className="text-[11px] text-zinc-400">{a.filters}</p>
</div>
<span className="text-[10px] font-bold uppercase tracking-wide px-2 py-0.5 rounded-full bg-zinc-100 dark:bg-white/10 text-zinc-500 shrink-0">
{SEGMENT_LABEL[a.segmentType] || a.segmentType}
</span>
</div>
{/* Count breakdown */}
<div className="grid grid-cols-3 gap-2 my-3">
<Stat icon={Users} label="Total" value={a.totalCount} color="text-zinc-500" />
<Stat icon={ShieldCheck} label="Consented" value={a.consentCount} color="text-emerald-500" />
<Stat icon={ShieldX} label="Suppressed" value={a.suppressionCount} color="text-red-500" />
</div>
{/* Eligible bar (consent ∩ not-suppressed) */}
<div className="mb-3">
<div className="flex items-center justify-between text-[10px] text-zinc-400 mb-1">
<span className="flex items-center gap-1"><UserCheck size={11} /> Eligible to sync (consent + suppression)</span>
<span className="font-mono font-semibold text-emerald-600 dark:text-emerald-400">{eligible.toLocaleString()} · {eligiblePct}%</span>
</div>
<div className="h-2 rounded-full bg-zinc-200 dark:bg-zinc-700 overflow-hidden">
<div className="h-full bg-gradient-to-r from-emerald-400 to-emerald-500" style={{ width: `${eligiblePct}%` }} />
</div>
</div>
<div className="flex items-center justify-between gap-2 mb-2">
<span className="text-[10px] text-zinc-400">Updated {a.lastCount}{a.frequencyCap ? ` · freq cap ${a.frequencyCap}/wk` : ''}</span>
<SyncStatusChip audience={a} />
</div>
<div className="flex flex-wrap items-center gap-1.5 pt-3 border-t border-zinc-100 dark:border-white/5">
<Btn size="sm" variant="ghost" icon={Pencil} onClick={() => setEditing({ audience: a })}>Edit</Btn>
<Btn size="sm" variant="ghost" icon={RefreshCw} onClick={() => refreshAudience(a.id)}>Refresh</Btn>
<div className="flex items-center gap-1.5 ml-auto">
<Btn size="sm" variant="outline" icon={Download} onClick={() => exportCsv(a)}>CSV</Btn>
<SyncMenu onSync={(dest) => handleSync(a, dest)} />
</div>
</div>
</Card>
);
})}
</div>
{editing && (
<SegmentBuilder
audience={editing.audience}
preset={editing.preset}
onClose={() => setEditing(null)}
onSave={(def) => {
if (editing.audience) updateAudience(editing.audience.id, def);
else createAudience(def);
setEditing(null);
}}
/>
)}
</div>
);
}
function Stat({ icon: Icon, label, value, color }) {
return (
<div className="rounded-lg bg-zinc-50 dark:bg-white/5 p-2 text-center">
{Icon && <Icon size={14} className={`${color} mx-auto mb-0.5`} />}
<p className="text-sm font-mono font-bold text-zinc-800 dark:text-zinc-100">{value.toLocaleString()}</p>
<p className="text-[9px] uppercase tracking-wider text-zinc-400">{label}</p>
</div>
);
}
// Sync status + destination menu (spec §8 "export/sync status"; §12 E2)
const SYNC_DESTINATIONS = ['Meta Custom Audience', 'Google Customer Match', 'LinkedIn Matched Audiences'];
function SyncStatusChip({ audience }) {
if (audience.syncStatus === 'synced') {
const when = audience.lastSyncedAt
? new Date(audience.lastSyncedAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
: '';
const short = (audience.syncDestination || '').split(' ')[0];
return (
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-emerald-600 dark:text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded-full" title={`Synced to ${audience.syncDestination}`}>
<CheckCircle2 size={11} /> Synced · {short}{when ? ` · ${when}` : ''}
</span>
);
}
return (
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-zinc-400 bg-zinc-100 dark:bg-white/5 px-2 py-0.5 rounded-full">
<CircleDashed size={11} /> Not synced
</span>
);
}
function SyncMenu({ onSync }) {
const [open, setOpen] = useState(false);
return (
<div className="relative">
<button onClick={() => setOpen(o => !o)}
className="inline-flex items-center justify-center gap-1 font-semibold rounded-xl transition-all focus:outline-none focus:ring-2 focus:ring-amber-500 px-2.5 py-1.5 text-xs bg-gradient-to-r from-amber-400 to-orange-500 text-white shadow-lg shadow-amber-500/25 hover:shadow-amber-500/40">
<Upload size={14} /> Sync <ChevronDown size={13} />
</button>
{open && (
<>
<div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
<div className="absolute right-0 mt-1 w-52 rounded-xl border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 shadow-xl z-20 p-1">
<p className="px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider text-zinc-400">Sync to platform</p>
{SYNC_DESTINATIONS.map(d => (
<button key={d} onClick={() => { onSync(d); setOpen(false); }}
className="w-full text-left px-3 py-2 rounded-lg text-xs text-zinc-700 dark:text-zinc-200 hover:bg-amber-500/10 hover:text-amber-600 dark:hover:text-amber-400 transition-colors">
{d}
</button>
))}
</div>
</>
)}
</div>
);
}
// AI suggestion card (spec §6): reasons visible, one-click create, opt-out aware
function SuggestionCard({ s, onCreate, onCustomize, onDismiss }) {
const SIcon = s.icon;
const reach = useMemo(() => previewSegment(s.filterDef), [s.filterDef]);
return (
<div className="rounded-xl border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 p-3 flex flex-col">
<div className="flex items-start justify-between gap-2 mb-1.5">
<div className="flex items-center gap-2 min-w-0">
<span className="p-1.5 rounded-lg bg-purple-500/10 text-purple-500 shrink-0">{SIcon && <SIcon size={15} />}</span>
<div className="min-w-0">
<h4 className="font-bold text-sm text-zinc-900 dark:text-white truncate">{s.name}</h4>
<span className="text-[10px] font-bold uppercase tracking-wide text-purple-500">AI · {Math.round(s.confidence * 100)}% match</span>
</div>
</div>
<button onClick={onDismiss} title="Dismiss" className="p-1 text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200 shrink-0"><X size={14} /></button>
</div>
<p className="text-[11px] text-zinc-500 dark:text-zinc-400 mb-2">{s.rationale}</p>
{/* AI reasons visible (spec §12 E1) */}
<ul className="space-y-1 mb-2">
{s.reasons.map((r, i) => (
<li key={i} className="text-[11px] text-zinc-600 dark:text-zinc-300 flex items-start gap-1.5">
<span className="text-purple-400 mt-1.5 w-1 h-1 rounded-full bg-purple-400 shrink-0" /> {r}
</li>
))}
</ul>
<div className="flex items-center gap-1.5 text-[11px] text-zinc-400 mb-3 mt-auto pt-2 border-t border-zinc-100 dark:border-white/5">
<UserCheck size={12} className="text-emerald-500" />
<span className="text-emerald-600 dark:text-emerald-400 font-mono font-semibold">{reach.eligible.toLocaleString()}</span> eligible after consent + suppression
</div>
<div className="flex items-center gap-2">
<Btn size="sm" variant="primary" icon={Plus} onClick={onCreate}>Create</Btn>
<Btn size="sm" variant="outline" icon={SlidersHorizontal} onClick={onCustomize}>Customize</Btn>
</div>
</div>
);
}
// Segment builder modal: filter CRM contacts with a live, opt-out-aware count
function SegmentBuilder({ audience, preset, onClose, onSave }) {
const init = audience || preset; // edit an existing audience, or seed a new one from an AI suggestion
const [name, setName] = useState(init?.name || '');
const [segmentType, setSegmentType] = useState(init?.segmentType || 'crm_stage');
const [f, setF] = useState(() => ({ ...DEFAULT_FILTER_DEF, ...(init?.filterDef || {}) }));
const set = (patch) => setF(prev => ({ ...prev, ...patch }));
const toggleZip = (z) => set({ geo: f.geo.includes(z) ? f.geo.filter(x => x !== z) : [...f.geo, z] });
const preview = useMemo(() => previewSegment(f), [f]);
const optOuts = preview.matched - preview.consented;
return createPortal(
<div className="fixed inset-0 z-[1000] flex items-center justify-center p-4" onClick={onClose}>
<div className="absolute inset-0 bg-black/50 backdrop-blur-[1px]" />
<div onClick={e => e.stopPropagation()} className="relative w-full max-w-2xl max-h-[90vh] overflow-y-auto rounded-2xl bg-white dark:bg-zinc-900 shadow-2xl border border-zinc-200 dark:border-white/10 animate-in zoom-in-95">
{/* Header */}
<div className="sticky top-0 z-10 bg-white/95 dark:bg-zinc-900/95 backdrop-blur border-b border-zinc-200 dark:border-white/10 px-5 py-3 flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Target size={18} className="text-amber-500" />
<h3 className="font-bold text-sm text-zinc-900 dark:text-white">{audience ? 'Edit segment' : 'New segment'}</h3>
</div>
<button onClick={onClose} className="p-1.5 rounded-lg text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200 hover:bg-zinc-100 dark:hover:bg-white/10"><X size={18} /></button>
</div>
<div className="p-5 space-y-4">
{/* Name + type */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<Field label="Segment name">
<input value={name} onChange={e => setName(e.target.value)} placeholder="e.g. Plano storm — past 30 days"
className={inCls} />
</Field>
<Field label="Segment type">
<select value={segmentType} onChange={e => setSegmentType(e.target.value)} className={inCls}>
{Object.entries(SEGMENT_LABEL).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</Field>
</div>
{/* CRM filters (spec §12 E1) */}
<div>
<p className="text-[10px] font-bold uppercase tracking-wider text-zinc-400 mb-2">Filter CRM contacts</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<Field label="Lifecycle stage">
<select value={f.stage} onChange={e => set({ stage: e.target.value })} className={inCls}>
<option value="any">Any stage</option>
{FILTER_OPTIONS.stages.map(s => <option key={s} value={s}>{s}</option>)}
</select>
</Field>
<Field label="Source">
<select value={f.source} onChange={e => set({ source: e.target.value })} className={inCls}>
<option value="any">Any source</option>
{FILTER_OPTIONS.sources.map(s => <option key={s} value={s}>{s}</option>)}
</select>
</Field>
<Field label="Service">
<select value={f.service} onChange={e => set({ service: e.target.value })} className={inCls}>
<option value="any">Any service</option>
{FILTER_OPTIONS.services.map(s => <option key={s} value={s}>{s}</option>)}
</select>
</Field>
<Field label="Last job within">
<select value={f.lastJobWithinDays || ''} onChange={e => set({ lastJobWithinDays: e.target.value ? Number(e.target.value) : null })} className={inCls}>
{LAST_JOB_OPTIONS.map(([v, l]) => <option key={v} value={v}>{l}</option>)}
</select>
</Field>
<Field label="Frequency cap (per week)">
<input type="number" min={0} max={14} value={f.frequencyCap ?? ''} onChange={e => set({ frequencyCap: e.target.value ? Number(e.target.value) : null })} className={inCls} />
</Field>
</div>
</div>
{/* Geography */}
<Field label={<span className="flex items-center gap-1"><MapPin size={11} /> Geography (service-area ZIPs)</span>}>
<div className="flex flex-wrap gap-1.5">
{FILTER_OPTIONS.zips.map(z => (
<button key={z} onClick={() => toggleZip(z)}
className={`px-2.5 py-1 rounded-lg text-xs font-mono font-semibold transition-colors ${f.geo.includes(z) ? 'bg-amber-500 text-white' : 'bg-zinc-100 dark:bg-white/5 text-zinc-600 dark:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-white/10'}`}>
{z}
</button>
))}
{f.geo.length > 0 && <button onClick={() => set({ geo: [] })} className="px-2.5 py-1 rounded-lg text-xs text-zinc-400 hover:text-red-500">clear</button>}
</div>
</Field>
{/* Mandatory consent + suppression (spec §6) */}
<div className="rounded-xl border border-emerald-200 dark:border-emerald-500/20 bg-emerald-50/60 dark:bg-emerald-500/5 p-3 space-y-1.5">
<p className="text-[11px] font-semibold text-emerald-700 dark:text-emerald-400 flex items-center gap-1.5"><Lock size={12} /> Always applied (mandatory)</p>
<p className="text-[11px] text-zinc-600 dark:text-zinc-300 flex items-center gap-1.5"><ShieldCheck size={12} className="text-emerald-500" /> Consent filter only opted-in contacts are reachable.</p>
<p className="text-[11px] text-zinc-600 dark:text-zinc-300 flex items-center gap-1.5"><ShieldX size={12} className="text-red-500" /> Suppression list opt-outs/DNC removed, including for retargeting.</p>
<p className="text-[11px] text-zinc-500 flex items-start gap-1.5 pt-1 border-t border-emerald-200/50 dark:border-emerald-500/10"><ShieldQuestion size={12} className="text-zinc-400 mt-0.5 shrink-0" /> No sensitive-attribute targeting only the CRM fields above can be used.</p>
</div>
{/* Live preview (spec §8 / §12 E2: count respects opt-outs + suppressions) */}
<div className="rounded-xl border border-zinc-200 dark:border-white/10 bg-zinc-50 dark:bg-white/[0.02] p-4">
<p className="text-[10px] font-bold uppercase tracking-wider text-zinc-400 mb-3">Estimated reach (live)</p>
<div className="flex items-center justify-between gap-2 flex-wrap">
<FunnelStep label="Matched" value={preview.matched} />
<FunnelArrow note={` ${optOuts.toLocaleString()} no consent`} />
<FunnelStep label="Consented" value={preview.consented} />
<FunnelArrow note={` ${preview.suppressed.toLocaleString()} suppressed`} />
<FunnelStep label="Eligible" value={preview.eligible} highlight />
</div>
</div>
</div>
{/* Footer */}
<div className="sticky bottom-0 bg-white/95 dark:bg-zinc-900/95 backdrop-blur border-t border-zinc-200 dark:border-white/10 px-5 py-3 flex justify-end gap-2">
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
<Btn variant="primary" icon={Target} onClick={() => onSave({ name, segmentType, filterDef: f })}>
{audience ? 'Save segment' : 'Create segment'} · {preview.eligible.toLocaleString()} eligible
</Btn>
</div>
</div>
</div>,
document.body,
);
}
const inCls = 'w-full rounded-lg border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 px-2.5 py-2 text-sm text-zinc-800 dark:text-zinc-100 focus:outline-none focus:ring-2 focus:ring-amber-500';
function Field({ label, children }) {
return (
<label className="block">
<span className="text-[11px] font-semibold text-zinc-500 dark:text-zinc-400 mb-1 block">{label}</span>
{children}
</label>
);
}
function FunnelStep({ label, value, highlight }) {
return (
<div className="text-center">
<p className={`text-xl font-mono font-bold ${highlight ? 'text-emerald-600 dark:text-emerald-400' : 'text-zinc-800 dark:text-zinc-100'}`}>{value.toLocaleString()}</p>
<p className="text-[10px] uppercase tracking-wider text-zinc-400">{label}</p>
</div>
);
}
function FunnelArrow({ note }) {
return (
<div className="flex flex-col items-center text-zinc-400 min-w-0">
<span className="text-lg leading-none"></span>
<span className="text-[9px] text-red-400 whitespace-nowrap">{note}</span>
</div>
);
}
@@ -0,0 +1,810 @@
/**
* CampaignBuilder blueprint library + a guided builder that clones a recipe into a
* campaign draft: objective, platform, service type, geography, budget, schedule,
* offer, creative, lead form, playbook mapping, and approval workflow (spec §8).
* Honors platform capability flags: where full-publish is unavailable, it offers a
* manual checklist. An AI Campaign Brief Generator (spec §6) drafts a whole campaign
* from a natural-language prompt and hands it to the builder for human approval.
*/
import React, { useState, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Megaphone, Plus, X, Check, ChevronRight, MapPin, DollarSign,
Calendar, Target, Workflow, FileCheck2, ListChecks, ExternalLink,
Sparkles, Wand2, ClipboardList, ShieldCheck, AlertTriangle, ArrowRight, Image,
Gauge, Bell, ShieldAlert, Power, ArrowLeft, Copy, CheckCircle2, Circle, Rocket, Link2,
} from 'lucide-react';
import { toast } from 'sonner';
import { useSocialAds } from '../SocialAdsContext.jsx';
import { BLUEPRINTS, BLUEPRINT_BY_ID } from '../data/blueprints.js';
import { PLATFORMS, getPlatform, hasCapability, CAPABILITY } from '../data/platforms.js';
import { generateVariants } from '../ai/creativeGenerator.js';
import { generateBrief, BRIEF_EXAMPLES } from '../ai/briefGenerator.js';
import { buildChecklist, checklistToText } from '../data/publishChecklist.js';
import { buildUtm } from '../data/utm.js';
import { Card, StatusChip, SectionHeader, Btn, PlatformBadge } from '../components/ui.jsx';
const STEPS = ['Blueprint', 'Targeting', 'Budget', 'Creative', 'Lead form', 'Mapping', 'Review'];
// Build the exact public landing-page URL a click on this campaign's ad would open:
// blueprint drives the headline/offer (?c), platform sets the "Sponsored" source (?src).
const publicFormUrl = (blueprintId, platform) =>
`/ads/lp?c=${blueprintId}&src=${platform}`;
const POLICY_META = {
clear: { color: 'text-emerald-500', label: 'Policy clear' },
review: { color: 'text-amber-500', label: 'Needs review' },
blocked: { color: 'text-red-500', label: 'Blocked' },
};
export default function CampaignBuilder() {
const { campaigns, createCampaign, publishCampaign, setCampaignStatus, addCreative, completeManualSetup } = useSocialAds();
const [mode, setMode] = useState('list'); // 'list' | 'building' | 'briefing'
const [seedDraft, setSeedDraft] = useState(null);
const navigate = useNavigate();
const close = () => { setMode('list'); setSeedDraft(null); };
// Brief builder: persist the AI-drafted creative, then seed the wizard with the brief.
const openFromBrief = (brief) => {
const creative = addCreative({
platform: brief.platform,
serviceType: brief.serviceType,
headline: brief.adCopy.headline,
body: brief.adCopy.body,
description: brief.adCopy.description,
aiGenerated: true,
policyStatus: brief.adCopy.policy.status,
policyFlags: brief.adCopy.policy.flags.map(f => f.message),
});
setSeedDraft({
blueprintId: brief.blueprintId,
name: brief.prompt.slice(0, 60) || `${brief.blueprintName} — New`,
platform: brief.platform,
objective: brief.objective,
serviceType: brief.serviceType,
serviceArea: brief.geo || '',
offer: brief.offer,
playbook: brief.nurturePath,
creativeId: creative.id,
});
setMode('building');
};
return (
<div>
<SectionHeader icon={Megaphone} title="Campaign Builder" description="Clone a roofing blueprint into a campaign draft, attach creative and a lead form, map it to a nurture playbook, and route it through approval.">
{mode === 'list' && (
<>
<Btn variant="outline" icon={Wand2} onClick={() => setMode('briefing')}>AI brief</Btn>
<Btn icon={Plus} onClick={() => { setSeedDraft(null); setMode('building'); }}>New campaign</Btn>
</>
)}
</SectionHeader>
{mode === 'briefing' && <BriefPanel onClose={close} onUse={openFromBrief} />}
{mode === 'building' && <BuilderWizard onClose={close} onCreate={createCampaign} initialDraft={seedDraft} />}
{mode === 'list' && <CampaignList campaigns={campaigns} onPublish={publishCampaign} onStatus={setCampaignStatus} onManualComplete={completeManualSetup} />}
{mode === 'list' && (
<>
<h3 className="text-sm font-bold text-zinc-700 dark:text-zinc-300 mt-8 mb-3 flex items-center gap-2">
<Target size={16} className="text-amber-500" /> Blueprint library
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
{BLUEPRINTS.map(bp => (
<Card key={bp.id} className="!h-auto" pad="p-4">
<div className="flex items-center justify-between mb-2">
<h4 className="font-bold text-sm text-zinc-900 dark:text-white">{bp.name}</h4>
<span className={`text-[10px] font-bold uppercase tracking-wide text-${bp.accent}-500`}>{bp.objective.replace(/_/g, ' ')}</span>
</div>
<p className="text-xs text-zinc-500 dark:text-zinc-400 mb-3 line-clamp-2">{bp.adCopy}</p>
<div className="flex flex-wrap items-center gap-1.5 mb-2">
{bp.bestChannels.map(c => <PlatformBadge key={c} platformId={c} size="sm" showName={false} />)}
</div>
<div className="flex items-center justify-between gap-2">
<p className="text-[11px] text-zinc-400 flex items-center gap-1 min-w-0 truncate"><Workflow size={11} className="shrink-0" /> {bp.nurture}</p>
<button
onClick={() => navigate(publicFormUrl(bp.id, bp.bestChannels[0]))}
title="Preview the public lead-capture page for this blueprint"
className="shrink-0 inline-flex items-center gap-1 text-[11px] font-semibold text-amber-600 dark:text-amber-400 hover:underline">
<ExternalLink size={11} /> Preview form
</button>
</div>
</Card>
))}
</div>
</>
)}
</div>
);
}
// AI Campaign Brief Generator (spec §6)
function BriefPanel({ onClose, onUse }) {
const [prompt, setPrompt] = useState('');
const [brief, setBrief] = useState(null);
const [thinking, setThinking] = useState(false);
const run = (text) => {
const p = (text ?? prompt).trim();
if (!p) return;
if (text != null) setPrompt(text);
setThinking(true);
setBrief(null);
setTimeout(() => { setBrief(generateBrief(p)); setThinking(false); }, 700);
};
return (
<Card className="!h-auto">
<div className="flex items-start justify-between gap-3 mb-3">
<div className="flex items-center gap-2">
<div className="p-2 rounded-xl bg-purple-500/10 text-purple-500"><Wand2 size={18} /></div>
<div>
<h3 className="font-bold text-zinc-900 dark:text-white">AI Campaign Brief</h3>
<p className="text-[11px] text-zinc-500">Describe the campaign in plain language AI drafts it from approved blueprints. You approve before anything is created.</p>
</div>
</div>
<button onClick={onClose} className="p-1 rounded-lg text-zinc-400 hover:bg-zinc-100 dark:hover:bg-white/10"><X size={18} /></button>
</div>
<textarea
value={prompt}
onChange={e => setPrompt(e.target.value)}
rows={2}
placeholder='e.g. "storm inspection campaign for hail-hit ZIPs in Plano"'
className="w-full rounded-xl border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 px-3.5 py-2.5 text-base sm:text-sm text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-purple-500 resize-none"
/>
<div className="flex flex-wrap items-center gap-1.5 mt-2">
{BRIEF_EXAMPLES.map(ex => (
<button key={ex} onClick={() => run(ex)}
className="text-[11px] px-2 py-1 rounded-lg bg-zinc-100 dark:bg-white/5 text-zinc-600 dark:text-zinc-300 hover:bg-purple-500/10 hover:text-purple-600 dark:hover:text-purple-400 transition-colors">
{ex}
</button>
))}
</div>
<div className="flex justify-end mt-3">
<Btn icon={Sparkles} onClick={() => run()} disabled={thinking || !prompt.trim()}>
{thinking ? 'Drafting…' : 'Generate brief'}
</Btn>
</div>
{brief && <BriefSummary brief={brief} onUse={onUse} onRegenerate={() => run()} />}
</Card>
);
}
function BriefSummary({ brief, onUse, onRegenerate }) {
const policy = POLICY_META[brief.adCopy.policy.status] || POLICY_META.review;
return (
<div className="mt-4 pt-4 border-t border-zinc-100 dark:border-white/5 space-y-3">
<div className="flex flex-wrap items-center gap-2">
<span className="text-[10px] font-bold uppercase tracking-wide text-zinc-400">Drafted brief</span>
<StatusChip status="draft" label={brief.blueprintName} />
<PlatformBadge platformId={brief.platform} size="sm" />
<span className="text-[11px] font-semibold text-zinc-500">{brief.objectiveLabel}</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<BriefBlock icon={Target} title="Audience notes">{brief.audienceNotes}</BriefBlock>
<BriefBlock icon={Megaphone} title="Call to action">{brief.cta} · <span className="text-zinc-400">{brief.offer}</span></BriefBlock>
</div>
<div className="rounded-xl border border-zinc-200 dark:border-white/10 p-3">
<div className="flex items-center justify-between mb-1">
<span className="text-[10px] font-bold uppercase tracking-wide text-zinc-400 flex items-center gap-1"><Sparkles size={11} className="text-purple-500" /> Ad copy draft</span>
<span className={`text-[10px] font-bold flex items-center gap-1 ${policy.color}`}><ShieldCheck size={11} /> {policy.label}</span>
</div>
<p className="font-bold text-sm text-zinc-900 dark:text-white">{brief.adCopy.headline}</p>
<p className="text-xs text-zinc-600 dark:text-zinc-300 mt-0.5">{brief.adCopy.body}</p>
{brief.adCopy.policy.flags.length > 0 && (
<ul className="mt-1.5 space-y-0.5">
{brief.adCopy.policy.flags.map(f => (
<li key={f.id} className="text-[10px] text-amber-600 dark:text-amber-400/80 flex items-start gap-1"><AlertTriangle size={10} className="mt-0.5 shrink-0" /> {f.message}</li>
))}
</ul>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<BriefBlock icon={ClipboardList} title="Suggested form fields">
<div className="flex flex-wrap gap-1.5 mt-1">
{brief.formFields.map(q => (
<span key={q} className="text-[11px] px-2 py-0.5 rounded-full bg-zinc-100 dark:bg-white/10 text-zinc-600 dark:text-zinc-300">{q}</span>
))}
</div>
</BriefBlock>
<BriefBlock icon={Workflow} title="Nurture path">{brief.nurturePath}</BriefBlock>
</div>
<div className="flex flex-wrap items-center justify-end gap-2 pt-1">
<Btn variant="ghost" icon={Sparkles} onClick={onRegenerate}>Regenerate</Btn>
<Btn icon={ArrowRight} onClick={() => onUse(brief)}>Open in builder</Btn>
</div>
<p className="text-[11px] text-zinc-400 flex items-center gap-1">
<ShieldCheck size={12} /> AI only uses tenant-approved blueprints and offers. The draft requires your approval before it can publish.
</p>
</div>
);
}
function BriefBlock({ icon: Icon, title, children }) {
return (
<div className="rounded-xl bg-zinc-50 dark:bg-white/5 p-3">
<span className="text-[10px] font-bold uppercase tracking-wide text-zinc-400 flex items-center gap-1 mb-1">{Icon && <Icon size={11} className="text-amber-500" />} {title}</span>
<div className="text-xs text-zinc-600 dark:text-zinc-300">{children}</div>
</div>
);
}
const STATUS_FILTERS = [
['all', 'All'],
['active', 'Active'],
['pending', 'Pending approval'],
['draft', 'Drafts'],
['paused', 'Paused'],
];
function CampaignList({ campaigns, onPublish, onStatus, onManualComplete }) {
const navigate = useNavigate();
const [statusFilter, setStatusFilter] = useState('all');
const [checklistId, setChecklistId] = useState(null);
const checklistCampaign = campaigns.find(c => c.id === checklistId);
if (checklistCampaign) {
return (
<ManualPublishChecklist
campaign={checklistCampaign}
onClose={() => setChecklistId(null)}
onComplete={() => { onManualComplete(checklistCampaign.id); setChecklistId(null); }}
/>
);
}
const matches = (c) => {
if (statusFilter === 'all') return true;
if (statusFilter === 'pending') return c.status === 'draft' && c.approvalStatus === 'pending';
if (statusFilter === 'draft') return c.status === 'draft' && c.approvalStatus !== 'pending';
return c.status === statusFilter;
};
const counts = Object.fromEntries(STATUS_FILTERS.map(([id]) => [
id,
campaigns.filter(c => {
if (id === 'all') return true;
if (id === 'pending') return c.status === 'draft' && c.approvalStatus === 'pending';
if (id === 'draft') return c.status === 'draft' && c.approvalStatus !== 'pending';
return c.status === id;
}).length,
]));
const visible = campaigns.filter(matches);
return (
<div className="space-y-2">
{/* Status filter */}
<div className="flex flex-wrap items-center gap-2 mb-3">
{STATUS_FILTERS.map(([id, label]) => (
<button key={id} onClick={() => setStatusFilter(id)}
className={`px-3 py-1.5 rounded-lg text-xs font-semibold transition-colors ${statusFilter === id ? 'bg-amber-500 text-white' : 'bg-zinc-100 dark:bg-white/5 text-zinc-600 dark:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-white/10'}`}>
{label} <span className="opacity-60">{counts[id]}</span>
</button>
))}
</div>
{visible.length === 0 && <p className="text-sm text-zinc-500 text-center py-8">No campaigns match this filter.</p>}
{visible.map(c => {
const canPublish = hasCapability(c.platform, CAPABILITY.FULL_PUBLISH);
return (
<Card key={c.id} className="!h-auto" pad="p-4">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div className="flex items-center gap-3 min-w-0">
<PlatformBadge platformId={c.platform} showName={false} />
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h4 className="font-bold text-sm text-zinc-900 dark:text-white truncate">{c.name}</h4>
<StatusChip status={c.status} />
<StatusChip status={c.approvalStatus} label={`${c.approvalStatus} approval`} />
</div>
<p className="text-[11px] text-zinc-500 dark:text-zinc-400 mt-0.5">
{c.serviceArea} · ${c.budgetDaily}/day · {c.startAt} {c.endAt} · {c.playbook}
</p>
{c.guardrails && (
<p className="text-[11px] text-zinc-400 mt-0.5 flex items-center gap-1">
<Gauge size={11} className="text-amber-500 shrink-0" />
Caps ${c.guardrails.weeklyCap.toLocaleString()}/wk · ${c.guardrails.monthlyCap.toLocaleString()}/mo · CPL alert &gt; ${c.guardrails.cplThreshold}
{(c.guardrails.pauseOnDailyCap || c.guardrails.pauseOnCplBreach || c.guardrails.anomalyDetection) && ' · auto-pause on'}
</p>
)}
</div>
</div>
<div className="flex flex-wrap items-center gap-2 shrink-0">
<Btn size="sm" variant="ghost" icon={ExternalLink}
title="Open the public lead-capture page this campaign's ad links to"
onClick={() => navigate(publicFormUrl(c.blueprintId, c.platform))}>
Preview form
</Btn>
<Btn size="sm" variant="ghost" icon={Link2}
title="Copy this campaign's tracked UTM link"
onClick={() => copyToClipboard(buildUtm(c).url, 'Tracked link copied')}>
Copy link
</Btn>
{c.status === 'active'
? <Btn size="sm" variant="outline" onClick={() => onStatus(c.id, 'paused')}>Pause</Btn>
: c.status === 'paused'
? <Btn size="sm" variant="success" onClick={() => onStatus(c.id, 'active')}>Resume</Btn>
: canPublish
? <Btn size="sm" variant="primary" icon={FileCheck2} onClick={() => onPublish(c.id)}>Publish</Btn>
: <Btn size="sm" variant="outline" icon={ListChecks} onClick={() => setChecklistId(c.id)}>Manual setup</Btn>}
</div>
</div>
{c.status === 'draft' && !canPublish && (
<p className="text-[11px] text-amber-600 dark:text-amber-400/80 mt-2 flex items-center gap-1">
<ListChecks size={12} /> {getPlatform(c.platform)?.short} has no full-publish API generate a platform-ready setup checklist instead.
</p>
)}
</Card>
);
})}
</div>
);
}
// Manual-publish setup checklist (spec §5/§11)
function ManualPublishChecklist({ campaign, onClose, onComplete }) {
const { creatives, leadForms } = useSocialAds();
const creative = creatives.find(c => c.id === campaign.creativeId);
const form = leadForms.find(f => f.id === campaign.formId);
const checklist = useMemo(() => buildChecklist(campaign, { creative, form }), [campaign, creative, form]);
const [done, setDone] = useState({});
const completedCount = checklist.steps.filter((_, i) => done[i]).length;
const allDone = completedCount === checklist.steps.length;
const approved = campaign.approvalStatus === 'approved';
const copyInstructions = async () => {
try {
await navigator.clipboard.writeText(checklistToText(campaign, checklist));
toast.success('Setup instructions copied to clipboard');
} catch {
toast.error('Could not copy — select and copy manually');
}
};
return (
<Card className="!h-auto">
<div className="flex items-start justify-between gap-3 mb-4">
<div className="flex items-center gap-2 min-w-0">
<Btn size="sm" variant="ghost" icon={ArrowLeft} onClick={onClose}>Back</Btn>
<div className="min-w-0">
<h3 className="font-bold text-zinc-900 dark:text-white truncate flex items-center gap-2">
<ListChecks size={18} className="text-amber-500 shrink-0" /> Manual setup · {campaign.name}
</h3>
<div className="flex items-center gap-2 mt-0.5">
<PlatformBadge platformId={campaign.platform} size="sm" />
<span className="text-[11px] text-zinc-400">No full-publish API set up by hand, then mark live.</span>
</div>
</div>
</div>
<Btn size="sm" variant="outline" icon={Copy} onClick={copyInstructions}>Copy</Btn>
</div>
<div className="rounded-xl bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/20 p-3 mb-4">
<p className="text-xs text-amber-700 dark:text-amber-300/90 flex items-start gap-1.5">
<AlertTriangle size={13} className="mt-0.5 shrink-0" /> {checklist.tip}
</p>
</div>
<div className="space-y-2">
{checklist.steps.map((s, i) => {
const checked = !!done[i];
return (
<button key={i} type="button" onClick={() => setDone(d => ({ ...d, [i]: !d[i] }))}
className={`w-full text-left flex items-start gap-3 p-3 rounded-xl border transition-colors ${checked ? 'border-emerald-300 dark:border-emerald-500/30 bg-emerald-50 dark:bg-emerald-500/10' : 'border-zinc-200 dark:border-white/10 hover:bg-zinc-50 dark:hover:bg-white/5'}`}>
{checked
? <CheckCircle2 size={18} className="text-emerald-500 shrink-0 mt-0.5" />
: <Circle size={18} className="text-zinc-300 dark:text-zinc-600 shrink-0 mt-0.5" />}
<div className="min-w-0">
<p className="text-sm font-semibold text-zinc-800 dark:text-zinc-100">{i + 1}. {s.title}</p>
<p className="text-xs text-zinc-500 dark:text-zinc-400 mt-0.5 whitespace-pre-line">{s.detail}</p>
</div>
</button>
);
})}
</div>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mt-5 pt-4 border-t border-zinc-100 dark:border-white/5">
<span className="text-xs text-zinc-500">{completedCount}/{checklist.steps.length} steps done{!approved && ' · approval required to go live'}</span>
<Btn icon={Rocket} disabled={!approved} title={approved ? 'Mark the campaign live' : 'Approve in Compliance first'} onClick={onComplete}>
{allDone ? 'Mark setup complete & go live' : 'Mark setup complete'}
</Btn>
</div>
{!approved && (
<p className="text-[11px] text-amber-600 dark:text-amber-400/80 mt-2 flex items-center gap-1">
<ShieldAlert size={12} /> This campaign is awaiting approval approve it in the Compliance Center before marking setup complete.
</p>
)}
</Card>
);
}
function BuilderWizard({ onClose, onCreate, initialDraft }) {
const { creatives, leadForms, addCreative } = useSocialAds();
const [step, setStep] = useState(0);
const [draft, setDraft] = useState(() => ({
blueprintId: '', name: '', platform: '', objective: '', serviceType: '',
serviceArea: '', budgetDaily: 75, budgetTotal: 2250,
startAt: '2026-06-15', endAt: '2026-07-15', playbook: '',
creativeId: '', formId: '',
// Spend guardrails hard caps + alert thresholds set before activation (spec §3/§4).
guardrails: {
weeklyCap: 525, monthlyCap: 2250, cplThreshold: 120, alertPct: 80,
pauseOnDailyCap: true, pauseOnCplBreach: false, anomalyDetection: true,
},
...(initialDraft || {}),
}));
const [variants, setVariants] = useState([]);
const [generating, setGenerating] = useState(false);
const set = (patch) => setDraft(d => ({ ...d, ...patch }));
const setG = (patch) => setDraft(d => ({ ...d, guardrails: { ...d.guardrails, ...patch } }));
const bp = BLUEPRINT_BY_ID[draft.blueprintId];
const g = draft.guardrails;
const pickBlueprint = (b) => set({
blueprintId: b.id, objective: b.objective, serviceType: b.serviceType,
platform: b.bestChannels[0], playbook: b.nurture, offer: b.offer,
name: draft.name || `${b.name} — New`,
creativeId: '', formId: '',
});
const creativesForPlatform = creatives.filter(c => c.platform === draft.platform);
const formsForPlatform = leadForms.filter(f => f.platform === draft.platform);
const selectedCreative = creatives.find(c => c.id === draft.creativeId);
const selectedForm = leadForms.find(f => f.id === draft.formId);
const genVariants = () => {
setGenerating(true);
setTimeout(() => {
setVariants(generateVariants({ platformId: draft.platform, serviceType: draft.serviceType || 'storm', city: draft.serviceArea || 'your area', count: 3 }));
setGenerating(false);
}, 600);
};
const applyVariant = (v) => {
const c = addCreative({
platform: draft.platform, serviceType: draft.serviceType,
headline: v.headline, body: v.body, description: v.description,
aiGenerated: true, policyStatus: v.policy.status,
policyFlags: v.policy.flags.map(f => f.message),
});
set({ creativeId: c.id });
setVariants([]);
};
const canNext = [
() => !!draft.blueprintId,
() => !!draft.platform && !!draft.serviceArea,
() => draft.budgetDaily > 0 && !!draft.startAt && !!draft.endAt,
() => !!draft.creativeId,
() => true, // lead form is optional (some blueprints reuse a CRM audience)
() => !!draft.playbook,
() => true,
][step]();
const finish = () => { onCreate(draft); onClose(); };
return (
<Card className="!h-auto">
{/* Stepper */}
<div className="flex items-center justify-between mb-5 gap-1">
{STEPS.map((s, i) => (
<React.Fragment key={s}>
<div className="flex items-center gap-1.5 min-w-0">
<span className={`flex items-center justify-center w-6 h-6 rounded-full text-[11px] font-bold shrink-0 ${i < step ? 'bg-emerald-500 text-white' : i === step ? 'bg-amber-500 text-white' : 'bg-zinc-200 dark:bg-zinc-700 text-zinc-500'}`}>
{i < step ? <Check size={13} /> : i + 1}
</span>
<span className={`text-[11px] font-semibold truncate hidden md:block ${i === step ? 'text-zinc-900 dark:text-white' : 'text-zinc-400'}`}>{s}</span>
</div>
{i < STEPS.length - 1 && <div className="flex-1 h-px bg-zinc-200 dark:bg-zinc-700" />}
</React.Fragment>
))}
<button onClick={onClose} className="ml-2 p-1 rounded-lg text-zinc-400 hover:bg-zinc-100 dark:hover:bg-white/10"><X size={18} /></button>
</div>
{/* Step body */}
<div className="min-h-[220px]">
{step === 0 && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{BLUEPRINTS.map(b => (
<button key={b.id} onClick={() => pickBlueprint(b)}
className={`text-left p-3 rounded-xl border-2 transition-all ${draft.blueprintId === b.id ? 'border-amber-500 bg-amber-500/5' : 'border-zinc-200 dark:border-white/10 hover:border-amber-300'}`}>
<div className="flex items-center justify-between">
<span className="font-bold text-sm text-zinc-900 dark:text-white">{b.name}</span>
{draft.blueprintId === b.id && <Check size={16} className="text-amber-500" />}
</div>
<p className="text-[11px] text-zinc-500 mt-1 line-clamp-2">{b.adCopy}</p>
</button>
))}
</div>
)}
{step === 1 && (
<div className="space-y-4">
<Field label="Campaign name">
<input value={draft.name} onChange={e => set({ name: e.target.value })} className={inputCls} placeholder="e.g. Plano Hail Response — July" />
</Field>
<Field label="Objective">
<div className="flex flex-wrap gap-2">
{['lead_generation', 'retargeting', 'engagement', 'reactivation'].map(o => (
<button key={o} onClick={() => set({ objective: o })}
className={`px-3 py-1.5 rounded-lg border-2 text-xs font-semibold capitalize transition-all ${draft.objective === o ? 'border-amber-500 bg-amber-500/5' : 'border-zinc-200 dark:border-white/10'}`}>
{o.replace(/_/g, ' ')}
</button>
))}
</div>
</Field>
<Field label="Platform" hint={bp ? `Recommended: ${bp.bestChannels.map(c => getPlatform(c).short).join(', ')}` : ''}>
<div className="flex flex-wrap gap-2">
{PLATFORMS.map(p => (
<button key={p.id} onClick={() => set({ platform: p.id, creativeId: '', formId: '' })}
className={`px-3 py-1.5 rounded-lg border-2 text-xs font-semibold transition-all ${draft.platform === p.id ? 'border-amber-500 bg-amber-500/5' : 'border-zinc-200 dark:border-white/10'}`}>
{p.short}
</button>
))}
</div>
</Field>
<Field label="Service area / geography" icon={MapPin}>
<input value={draft.serviceArea} onChange={e => set({ serviceArea: e.target.value })} className={inputCls} placeholder="ZIP codes, city, or radius" />
</Field>
</div>
)}
{step === 2 && (
<div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Field label="Daily budget" icon={DollarSign}>
<input type="number" value={draft.budgetDaily}
onChange={e => {
const v = +e.target.value;
setDraft(d => ({ ...d, budgetDaily: v, budgetTotal: v * 30, guardrails: { ...d.guardrails, weeklyCap: v * 7, monthlyCap: v * 30 } }));
}}
className={inputCls} />
</Field>
<Field label="Total budget (≈30d)" icon={DollarSign}>
<input type="number" value={draft.budgetTotal} onChange={e => set({ budgetTotal: +e.target.value })} className={inputCls} />
</Field>
<Field label="Start date" icon={Calendar}>
<input type="date" value={draft.startAt} onChange={e => set({ startAt: e.target.value })} className={inputCls} />
</Field>
<Field label="End date" icon={Calendar}>
<input type="date" value={draft.endAt} onChange={e => set({ endAt: e.target.value })} className={inputCls} />
</Field>
</div>
{/* Spend guardrails (spec §3/§4) — hard caps + alert thresholds before activation */}
<div className="pt-4 border-t border-zinc-100 dark:border-white/5">
<div className="flex items-center gap-1.5 mb-3">
<ShieldAlert size={14} className="text-amber-500" />
<h4 className="text-xs font-bold text-zinc-700 dark:text-zinc-300">Spend guardrails</h4>
<span className="text-[11px] text-zinc-400"> hard caps & alerts enforced before activation</span>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<Field label="Weekly cap" icon={Gauge}>
<input type="number" value={g.weeklyCap} onChange={e => setG({ weeklyCap: +e.target.value })} className={inputCls} />
</Field>
<Field label="Monthly cap" icon={Gauge}>
<input type="number" value={g.monthlyCap} onChange={e => setG({ monthlyCap: +e.target.value })} className={inputCls} />
</Field>
<Field label="CPL threshold" icon={DollarSign}>
<input type="number" value={g.cplThreshold} onChange={e => setG({ cplThreshold: +e.target.value })} className={inputCls} />
</Field>
<Field label="Alert at (% of cap)" icon={Bell}>
<input type="number" min="1" max="100" value={g.alertPct} onChange={e => setG({ alertPct: +e.target.value })} className={inputCls} />
</Field>
</div>
<div className="mt-3 space-y-2">
<ToggleRow label="Auto-pause when daily cap is reached" on={g.pauseOnDailyCap} onToggle={() => setG({ pauseOnDailyCap: !g.pauseOnDailyCap })} />
<ToggleRow label={`Auto-pause when cost-per-lead exceeds $${g.cplThreshold}`} on={g.pauseOnCplBreach} onToggle={() => setG({ pauseOnCplBreach: !g.pauseOnCplBreach })} />
<ToggleRow label="Anomaly detection — pause on a sudden spend spike" on={g.anomalyDetection} onToggle={() => setG({ anomalyDetection: !g.anomalyDetection })} />
</div>
</div>
</div>
)}
{step === 3 && (
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-xs text-zinc-500 flex items-center gap-1">
<Image size={13} className="text-amber-500" /> Attach an approved creative for {getPlatform(draft.platform)?.short || 'this platform'} AI drafts are approval-gated.
</p>
<Btn size="sm" variant="outline" icon={Sparkles} onClick={genVariants} disabled={generating}>
{generating ? 'Drafting…' : 'Generate with AI'}
</Btn>
</div>
{variants.length > 0 && (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
{variants.map((v, i) => {
const pm = POLICY_META[v.policy.status] || POLICY_META.review;
return (
<div key={i} className="rounded-xl border border-zinc-200 dark:border-white/10 p-3 flex flex-col">
<div className="flex items-center justify-between mb-1">
<span className="text-[10px] font-bold uppercase tracking-wide text-zinc-400">Variant {i + 1}</span>
<span className={`text-[10px] font-bold ${pm.color}`}>{pm.label}</span>
</div>
<p className="font-bold text-sm text-zinc-900 dark:text-white">{v.headline}</p>
<p className="text-xs text-zinc-500 mt-1 flex-1">{v.body}</p>
<Btn size="sm" variant="outline" icon={Plus} className="mt-2" disabled={v.policy.status === 'blocked'} onClick={() => applyVariant(v)}>Use this</Btn>
</div>
);
})}
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{creativesForPlatform.length === 0 && variants.length === 0 && (
<p className="text-xs text-zinc-400 sm:col-span-2 py-4 text-center">No creatives yet for this platform generate one with AI above.</p>
)}
{creativesForPlatform.map(c => {
const pm = POLICY_META[c.policyStatus] || POLICY_META.review;
const selected = draft.creativeId === c.id;
return (
<button key={c.id} type="button" onClick={() => set({ creativeId: c.id })}
className={`text-left p-3 rounded-xl border-2 transition-all ${selected ? 'border-amber-500 bg-amber-500/5' : 'border-zinc-200 dark:border-white/10 hover:border-amber-300'}`}>
<div className="flex items-center justify-between mb-1">
<span className="inline-flex items-center gap-1 text-[10px] font-bold">
{c.aiGenerated && <Sparkles size={10} className="text-purple-500" />}
<span className={pm.color}>{pm.label}</span>
</span>
{selected && <Check size={15} className="text-amber-500" />}
</div>
<p className="font-bold text-sm text-zinc-900 dark:text-white">{c.headline}</p>
<p className="text-xs text-zinc-500 mt-0.5 line-clamp-2">{c.body}</p>
</button>
);
})}
</div>
</div>
)}
{step === 4 && (
<div className="space-y-3">
<p className="text-xs text-zinc-500 flex items-center gap-1">
<ClipboardList size={13} className="text-amber-500" /> Choose the lead form this campaign sends to. Optional leads can use the default capture form.
</p>
{bp?.formQuestions && (
<div className="rounded-xl bg-zinc-50 dark:bg-white/5 p-3">
<p className="text-[10px] font-bold uppercase tracking-wide text-zinc-400 mb-2">Recommended questions · {bp.name}</p>
<div className="flex flex-wrap gap-1.5">
{bp.formQuestions.map(q => (
<span key={q} className="text-[11px] px-2 py-0.5 rounded-full bg-white dark:bg-white/10 border border-zinc-200 dark:border-white/10 text-zinc-600 dark:text-zinc-300">{q}</span>
))}
</div>
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<button type="button" onClick={() => set({ formId: '' })}
className={`text-left p-3 rounded-xl border-2 transition-all ${!draft.formId ? 'border-amber-500 bg-amber-500/5' : 'border-zinc-200 dark:border-white/10 hover:border-amber-300'}`}>
<div className="flex items-center justify-between">
<span className="font-bold text-sm text-zinc-900 dark:text-white">Default capture form</span>
{!draft.formId && <Check size={15} className="text-amber-500" />}
</div>
<p className="text-[11px] text-zinc-400 mt-0.5">Public landing-page form with consent capture</p>
</button>
{formsForPlatform.map(f => {
const selected = draft.formId === f.id;
return (
<button key={f.id} type="button" onClick={() => set({ formId: f.id })}
className={`text-left p-3 rounded-xl border-2 transition-all ${selected ? 'border-amber-500 bg-amber-500/5' : 'border-zinc-200 dark:border-white/10 hover:border-amber-300'}`}>
<div className="flex items-center justify-between">
<span className="font-bold text-sm text-zinc-900 dark:text-white truncate">{f.title}</span>
{selected && <Check size={15} className="text-amber-500 shrink-0" />}
</div>
<p className="text-[11px] text-zinc-400 mt-0.5">{f.fields.length} fields mapped · {f.status}</p>
</button>
);
})}
</div>
</div>
)}
{step === 5 && (
<div className="space-y-4">
<Field label="Offer">
<input value={draft.offer || ''} onChange={e => set({ offer: e.target.value })} className={inputCls} />
</Field>
<Field label="Mapped nurture playbook" icon={Workflow}>
<input value={draft.playbook} onChange={e => set({ playbook: e.target.value })} className={inputCls} />
</Field>
<p className="text-[11px] text-zinc-500 flex items-center gap-1">
<Workflow size={12} /> New leads from this campaign will auto-start the mapped playbook within the speed-to-lead SLA.
</p>
</div>
)}
{step === 6 && (
<div className="space-y-2 text-sm">
{[
['Blueprint', bp?.name], ['Campaign', draft.name],
['Platform', getPlatform(draft.platform)?.name], ['Objective', draft.objective?.replace(/_/g, ' ')],
['Service area', draft.serviceArea], ['Budget', `$${draft.budgetDaily}/day · $${draft.budgetTotal} total`],
['Schedule', `${draft.startAt}${draft.endAt}`],
['Spend caps', `$${g.weeklyCap.toLocaleString()}/wk · $${g.monthlyCap.toLocaleString()}/mo`],
['CPL guardrail', `alert > $${g.cplThreshold} · notify at ${g.alertPct}% of cap`],
['Auto-pause rules', [g.pauseOnDailyCap && 'daily cap', g.pauseOnCplBreach && 'CPL breach', g.anomalyDetection && 'anomaly'].filter(Boolean).join(', ') || 'none'],
['Creative', selectedCreative?.headline], ['Lead form', selectedForm?.title || 'Default capture form'],
['Playbook', draft.playbook],
].map(([k, v]) => (
<div key={k} className="flex justify-between gap-3 py-1.5 border-b border-zinc-100 dark:border-white/5">
<span className="text-zinc-500">{k}</span>
<span className="font-semibold text-zinc-800 dark:text-zinc-100 text-right">{v || '—'}</span>
</div>
))}
<div className="pt-2">
<CopyableLink url={buildUtm(draft, { creative: selectedCreative }).url} />
</div>
<p className="text-[11px] text-amber-600 dark:text-amber-400/80 pt-2 flex items-center gap-1">
<FileCheck2 size={12} /> Saved as a draft with <b>pending</b> approval it cannot publish until creatives and consent text are approved.
</p>
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between mt-5 pt-4 border-t border-zinc-100 dark:border-white/5">
<Btn variant="ghost" onClick={() => (step === 0 ? onClose() : setStep(step - 1))}>
{step === 0 ? 'Cancel' : 'Back'}
</Btn>
{step < STEPS.length - 1
? <Btn icon={ChevronRight} disabled={!canNext} onClick={() => setStep(step + 1)}>Next</Btn>
: <Btn icon={Check} onClick={finish}>Create draft</Btn>}
</div>
</Card>
);
}
const inputCls = 'w-full rounded-xl border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 px-3 py-2 text-base sm:text-sm text-zinc-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-amber-500';
function Field({ label, hint, icon: Icon, children }) {
return (
<label className="block">
<span className="text-xs font-semibold text-zinc-600 dark:text-zinc-300 mb-1.5 flex items-center gap-1.5">
{Icon && <Icon size={13} className="text-amber-500" />} {label}
{hint && <span className="font-normal text-zinc-400">· {hint}</span>}
</span>
{children}
</label>
);
}
const copyToClipboard = async (text, label = 'Copied') => {
try { await navigator.clipboard.writeText(text); toast.success(label); }
catch { toast.error('Could not copy — select and copy manually'); }
};
function CopyableLink({ url }) {
return (
<div className="rounded-xl bg-zinc-50 dark:bg-white/5 p-3">
<div className="flex items-center justify-between gap-2 mb-1">
<span className="text-[10px] font-bold uppercase tracking-wide text-zinc-400 flex items-center gap-1"><Link2 size={11} className="text-amber-500" /> Tracked link (UTM)</span>
<Btn size="sm" variant="ghost" icon={Copy} onClick={() => copyToClipboard(url, 'Tracked link copied')}>Copy</Btn>
</div>
<code className="block text-[11px] font-mono text-blue-600 dark:text-blue-400 break-all">{url}</code>
</div>
);
}
function ToggleRow({ label, on, onToggle }) {
return (
<button type="button" onClick={onToggle}
className="w-full flex items-center justify-between gap-3 px-3 py-2 rounded-xl bg-zinc-50 dark:bg-white/5 hover:bg-zinc-100 dark:hover:bg-white/10 transition-colors text-left">
<span className="text-xs text-zinc-700 dark:text-zinc-200">{label}</span>
<span className={`shrink-0 inline-flex items-center gap-1 px-2 py-1 rounded-lg text-[11px] font-semibold ${on ? 'text-emerald-600 dark:text-emerald-400 bg-emerald-500/10' : 'text-zinc-400 bg-zinc-200/60 dark:bg-white/5'}`}>
<Power size={12} /> {on ? 'On' : 'Off'}
</span>
</button>
);
}
@@ -0,0 +1,458 @@
/**
* ComplianceCenter pending creative/consent/campaign approvals, blocked claims,
* review-disclosure checks, and an AI audit + conversion-sync log (spec §8, §10.1).
* An unapproved campaign cannot publish and a risky message cannot send.
*/
import React, { useState, useMemo } from 'react';
import {
ShieldCheck, AlertTriangle, Ban, CheckCircle2, Clock, RefreshCw,
FileWarning, Wand2, ArrowUpRight, FileText, History, ChevronDown, Lock,
Megaphone, Image, MessageSquare, Download, Search, ScrollText,
Quote, ShieldAlert, Bot,
} from 'lucide-react';
import { useSocialAds } from '../SocialAdsContext.jsx';
import { PLATFORMS } from '../data/platforms.js';
import { PlatformBadge, Card, SectionHeader, StatusChip, Btn } from '../components/ui.jsx';
import { toast } from 'sonner';
const fmtDate = (iso) => iso ? new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : '';
const fmtDateTime = (iso) => iso ? new Date(iso).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : '';
const AUDIT_CAT = {
campaign: { label: 'Campaign', color: 'text-blue-500', icon: Megaphone },
creative: { label: 'Creative', color: 'text-purple-500', icon: Image },
consent: { label: 'Consent', color: 'text-emerald-500', icon: FileText },
ai: { label: 'AI', color: 'text-amber-500', icon: Wand2 },
conversion: { label: 'Conversion', color: 'text-cyan-500', icon: ArrowUpRight },
message: { label: 'Message', color: 'text-pink-500', icon: MessageSquare },
};
const csvCell = (v) => { const s = String(v ?? '').replace(/"/g, '""'); return /[",\n]/.test(s) ? `"${s}"` : s; };
const TYPE_LABEL = {
creative: 'Creative', consent_text: 'Consent text',
review_disclosure: 'Review disclosure', campaign: 'Campaign publish',
};
const SYNC_ICON = {
synced: { icon: CheckCircle2, color: 'text-emerald-500' },
queued: { icon: Clock, color: 'text-amber-500' },
failed: { icon: AlertTriangle, color: 'text-red-500' },
};
export default function ComplianceCenter() {
const { approvals, resolveApproval, conversionEvents, retryConversion, leadForms, consentVersions, approveConsentVersion, blockConsentVersion, anomalies, conversations, campaigns, creatives, reviews, setReviewPermission, setReviewDisclosure } = useSocialAds();
const pending = approvals.filter(a => a.status === 'pending');
const blocked = approvals.filter(a => a.status === 'blocked');
const resolved = approvals.filter(a => a.status === 'approved');
// Unified audit log (spec §12 H2) derived live from every compliance-
// relevant data source so it never drifts from the underlying records.
const auditEntries = useMemo(() => {
const campById = Object.fromEntries(campaigns.map(c => [c.id, c]));
const creById = Object.fromEntries(creatives.map(c => [c.id, c]));
const formById = Object.fromEntries(leadForms.map(f => [f.id, f]));
const out = [];
for (const a of approvals) {
const cat = a.type === 'consent_text' ? 'consent' : a.type === 'campaign' ? 'campaign' : 'creative';
const cre = a.type === 'creative' ? creById[a.refId] : null;
const form = a.type === 'consent_text' ? formById[a.refId] : null;
const camp = a.type === 'campaign' ? campById[a.refId] : (cre ? campById[cre.campaignId] : form ? campById[form.campaignId] : null);
const action = a.status === 'approved' ? 'Approved' : a.status === 'blocked' ? 'Blocked' : 'Submitted for review';
out.push({
id: `aud_apr_${a.id}`, ts: a.submittedAt, category: cat, action, detail: a.title + (a.flags?.length ? `${a.flags.join('; ')}` : ''),
actor: a.reviewer || (a.flags?.length ? 'AI compliance reviewer' : 'system'),
platform: camp?.platform || cre?.platform || form?.platform || '', campaignId: camp?.id || '', serviceType: camp?.serviceType || '',
});
}
for (const v of consentVersions) {
const form = formById[v.formId];
const camp = form ? campById[form.campaignId] : null;
out.push({
id: `aud_cv_${v.id}`, ts: v.createdAt, category: 'consent', action: `Consent v${v.version} ${v.status}`,
detail: `${form?.title || v.formId}: "${v.text.slice(0, 60)}…"`, actor: v.reviewer || 'system',
platform: form?.platform || camp?.platform || '', campaignId: camp?.id || '', serviceType: camp?.serviceType || '',
});
}
for (const e of conversionEvents) {
out.push({
id: `aud_ce_${e.id}`, ts: e.syncedAt || e.submittedAt, category: 'conversion', action: `${e.eventName} · ${e.syncStatus}`,
detail: `dedup ${e.dedupKey}${e.value ? ` · $${e.value.toLocaleString()}` : ''}`, actor: 'system',
platform: e.platform, campaignId: '', serviceType: '',
});
}
for (const an of anomalies) {
const camp = campById[an.campaignId];
out.push({
id: `aud_an_${an.id}`, ts: an.detectedAt, category: 'ai', action: `Anomaly: ${an.type}${an.status !== 'open' ? ` · ${an.status}` : ''}`,
detail: `${camp?.name || an.campaignId}${an.metric}`, actor: 'AI anomaly detector',
platform: camp?.platform || '', campaignId: an.campaignId, serviceType: camp?.serviceType || '',
});
}
for (const c of conversations.filter(cv => cv.status !== 'new')) {
out.push({
id: `aud_cv2_${c.id}`, ts: c.receivedAt, category: 'message', action: `${c.classification.type} · ${c.status}`,
detail: `${c.author}: "${c.text.slice(0, 50)}…"`, actor: 'AI triage',
platform: c.platform, campaignId: '', serviceType: '',
});
}
return out.map(e => ({ ...e, campaignName: campById[e.campaignId]?.name || '' }))
.sort((a, b) => String(b.ts).localeCompare(String(a.ts)));
}, [approvals, consentVersions, conversionEvents, anomalies, conversations, campaigns, creatives, leadForms]);
const [auditCat, setAuditCat] = useState('all');
const [auditPlatform, setAuditPlatform] = useState('all');
const [auditSearch, setAuditSearch] = useState('');
const filteredAudit = auditEntries.filter(e =>
(auditCat === 'all' || e.category === auditCat) &&
(auditPlatform === 'all' || e.platform === auditPlatform) &&
(!auditSearch || `${e.action} ${e.detail} ${e.actor}`.toLowerCase().includes(auditSearch.toLowerCase())),
);
const exportAudit = () => {
const headers = ['Timestamp', 'Category', 'Action', 'Detail', 'Actor', 'Source', 'Campaign', 'Service type'];
const lines = [headers.join(',')];
for (const e of filteredAudit) {
lines.push([e.ts, AUDIT_CAT[e.category]?.label || e.category, e.action, e.detail, e.actor, e.platform, e.campaignName, e.serviceType].map(csvCell).join(','));
}
const blob = new Blob([lines.join('\n')], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = 'ad-engine-audit-log.csv'; a.click();
URL.revokeObjectURL(url);
toast.success(`Exported ${filteredAudit.length} audit entries to CSV`);
};
// Reviews used in ads (spec §11 review/testimonial disclosure).
const usedReviews = reviews.filter(r => r.usedIn);
const campName = (id) => campaigns.find(c => c.id === id)?.name || id;
// AI quality metrics (spec §8 "AI audit queue", §10 AI quality).
const aiMetrics = useMemo(() => {
const aiCreatives = creatives.filter(c => c.aiGenerated);
const corrections = creatives.reduce((s, c) => s + (c.versions?.length || 0), 0);
const blockedResp = approvals.filter(a => a.status === 'blocked').length;
const handoffs = conversations.filter(c => c.classification.type === 'complaint' && c.status !== 'new').length;
const totalConv = conversations.length || 1;
const contained = conversations.filter(c => c.classification.type !== 'complaint').length;
const avgRating = reviews.length ? reviews.reduce((s, r) => s + r.rating, 0) / reviews.length : 0;
return {
aiPending: aiCreatives.filter(c => c.approvalStatus === 'pending').length,
containment: contained / totalConv,
handoffs, blockedResp, corrections,
sentiment: avgRating,
};
}, [creatives, approvals, conversations, reviews]);
return (
<div className="space-y-6">
<SectionHeader icon={ShieldCheck} title="Compliance Center" description="Approval workflow, blocked claims, consent versions, and the AI / conversion audit log. Nothing risky publishes or sends until resolved.">
<div className="flex items-center gap-2">
<StatusChip status="pending" label={`${pending.length} pending`} />
{blocked.length > 0 && <StatusChip status="blocked" label={`${blocked.length} blocked`} />}
</div>
</SectionHeader>
{/* Blocked first — must resolve */}
{blocked.length > 0 && (
<Card title="Blocked — resolve before publish" icon={Ban} subtitle="Risky claims detected by the compliance reviewer.">
<div className="space-y-2">
{blocked.map(a => <ApprovalRow key={a.id} item={a} onResolve={resolveApproval} />)}
</div>
</Card>
)}
{/* Pending queue */}
<Card title="Approval queue" icon={Clock} subtitle="Creative, consent text, review disclosure, and campaign publish approvals.">
{pending.length === 0
? <p className="text-sm text-zinc-500 py-4 text-center">Nothing pending all clear.</p>
: <div className="space-y-2">{pending.map(a => <ApprovalRow key={a.id} item={a} onResolve={resolveApproval} />)}</div>}
</Card>
{/* Consent text versions (spec §8) */}
<Card title="Consent text versions" icon={FileText} subtitle="Tenant-approved SMS/email consent language per form. One version is live at a time; changes need re-approval.">
<div className="space-y-3">
{leadForms.map(form => {
const versions = consentVersions.filter(v => v.formId === form.id);
if (versions.length === 0) return null;
return <ConsentFormBlock key={form.id} form={form} versions={versions} onApprove={approveConsentVersion} onBlock={blockConsentVersion} />;
})}
</div>
</Card>
{/* Review/testimonial usage + FTC disclosure (spec §8, §11) */}
<Card title="Review usage & disclosure" icon={Quote} subtitle="Reviews used in ads need written usage permission and an FTC disclosure — otherwise the review creative is blocked.">
<div className="space-y-2">
{usedReviews.length === 0 && <p className="text-sm text-zinc-500 py-3 text-center">No reviews are currently used in ad creative.</p>}
{usedReviews.map(r => {
const cleared = r.usagePermission === 'granted' && r.disclosureStatus === 'compliant';
return (
<div key={r.id} className={`p-3 rounded-xl border ${cleared ? 'border-zinc-200 dark:border-white/10 bg-zinc-50 dark:bg-white/[0.02]' : 'border-red-200 dark:border-red-500/20 bg-red-50/60 dark:bg-red-500/5'}`}>
<div className="flex flex-col sm:flex-row sm:items-start gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap mb-1">
<PlatformBadge platformId={r.platform} size="sm" showName={false} />
<span className="text-amber-500 text-xs font-mono">{'★'.repeat(r.rating)}</span>
<span className="text-[11px] text-zinc-400">used in {campName(r.usedIn)}</span>
{cleared
? <span className="inline-flex items-center gap-1 text-[10px] font-bold uppercase tracking-wider text-emerald-600 dark:text-emerald-400 bg-emerald-500/10 px-1.5 py-0.5 rounded"><ShieldCheck size={10} /> Cleared to use</span>
: <span className="inline-flex items-center gap-1 text-[10px] font-bold uppercase tracking-wider text-red-600 dark:text-red-400 bg-red-500/10 px-1.5 py-0.5 rounded"><ShieldAlert size={10} /> Creative blocked</span>}
</div>
<p className="text-[11px] text-zinc-600 dark:text-zinc-300 italic">"{r.text}"</p>
<div className="flex items-center gap-3 mt-1.5 text-[11px]">
<span className={`inline-flex items-center gap-1 ${r.usagePermission === 'granted' ? 'text-emerald-600 dark:text-emerald-400' : r.usagePermission === 'denied' ? 'text-red-500' : 'text-amber-600 dark:text-amber-400'}`}>
<ShieldCheck size={12} /> Permission: {r.usagePermission}
</span>
<span className={`inline-flex items-center gap-1 ${r.disclosureStatus === 'compliant' ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-500'}`}>
<FileText size={12} /> FTC disclosure: {r.disclosureStatus}
</span>
</div>
</div>
<div className="flex items-center gap-1.5 shrink-0">
{r.usagePermission !== 'granted'
? <Btn size="sm" variant="success" icon={CheckCircle2} onClick={() => setReviewPermission(r.id, 'granted')}>Grant permission</Btn>
: <Btn size="sm" variant="danger" icon={Ban} onClick={() => setReviewPermission(r.id, 'denied')}>Revoke</Btn>}
{r.disclosureStatus !== 'compliant' && <Btn size="sm" variant="outline" icon={FileText} onClick={() => setReviewDisclosure(r.id, 'compliant')}>Add disclosure</Btn>}
</div>
</div>
</div>
);
})}
</div>
<p className="text-[11px] text-zinc-400 mt-3 flex items-start gap-1"><AlertTriangle size={12} className="mt-0.5 shrink-0" /> Per FTC and platform guidance, testimonials in ads require documented permission and clear disclosure. Unapproved review creative cannot publish.</p>
</Card>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Conversion sync log */}
<Card title="Conversion sync log" icon={ArrowUpRight} subtitle="Events sent back to ad platforms (deduped).">
<div className="space-y-1.5">
{conversionEvents.map(e => {
const meta = SYNC_ICON[e.syncStatus];
const Icon = meta.icon;
return (
<div key={e.id} className="flex items-center gap-2 px-2.5 py-2 rounded-lg bg-zinc-50 dark:bg-white/5">
<Icon size={14} className={meta.color} />
<PlatformBadge platformId={e.platform} size="sm" showName={false} />
<div className="min-w-0 flex-1">
<p className="text-xs font-semibold text-zinc-700 dark:text-zinc-200 truncate">{e.eventName}</p>
<p className="text-[10px] text-zinc-400 font-mono truncate">dedup: {e.dedupKey}</p>
</div>
{e.value > 0 && <span className="text-[11px] font-mono text-emerald-600 dark:text-emerald-400">${e.value.toLocaleString()}</span>}
{e.syncStatus === 'failed'
? <Btn size="sm" variant="danger" icon={RefreshCw} onClick={() => retryConversion(e.id)}>Retry</Btn>
: <StatusChip status={e.syncStatus} />}
</div>
);
})}
</div>
</Card>
{/* AI audit & quality (spec §8 "AI audit queue", §10 AI quality) */}
<Card title="AI audit & quality" icon={Bot} subtitle="AI containment, handoffs, blocked copy, and human corrections.">
<div className="grid grid-cols-3 gap-2 mb-3">
<AiStat value={`${Math.round(aiMetrics.containment * 100)}%`} label="Containment" color="text-emerald-500" />
<AiStat value={aiMetrics.handoffs} label="Human handoffs" color="text-amber-500" />
<AiStat value={aiMetrics.blockedResp} label="Blocked copy" color="text-red-500" />
<AiStat value={aiMetrics.corrections} label="Human corrections" color="text-blue-500" />
<AiStat value={aiMetrics.sentiment.toFixed(1)} label="Sentiment ★" color="text-purple-500" />
<AiStat value={aiMetrics.aiPending} label="AI items pending" color="text-amber-500" />
</div>
<div className="flex items-start gap-2 px-2.5 py-2 rounded-lg bg-purple-50 dark:bg-purple-500/10 mb-2">
<Wand2 size={14} className="text-purple-500 mt-0.5 shrink-0" />
<p className="text-[11px] text-zinc-600 dark:text-zinc-300">
All AI copy required human approval <b>0</b> auto-published. {aiMetrics.aiPending > 0 ? `${aiMetrics.aiPending} AI-generated item(s) still awaiting review in the queue above.` : 'No AI items pending.'}
</p>
</div>
<p className="text-[10px] font-bold uppercase tracking-wider text-zinc-400 mb-1.5">Recently approved</p>
<div className="space-y-1.5">
{resolved.map(a => (
<div key={a.id} className="flex items-center gap-2 px-2.5 py-2 rounded-lg bg-zinc-50 dark:bg-white/5">
<CheckCircle2 size={14} className="text-emerald-500 shrink-0" />
<div className="min-w-0 flex-1">
<p className="text-xs font-semibold text-zinc-700 dark:text-zinc-200 truncate">{a.title}</p>
<p className="text-[10px] text-zinc-400">{TYPE_LABEL[a.type]} · approved by {a.reviewer}</p>
</div>
<StatusChip status="approved" />
</div>
))}
</div>
</Card>
</div>
{/* Audit log + export (spec §12 H2) */}
<Card title="Audit log" icon={ScrollText} subtitle="Campaign, consent, message, AI, and conversion events — filter and export."
action={<Btn size="sm" variant="outline" icon={Download} onClick={exportAudit}>Export CSV</Btn>}>
{/* Filters */}
<div className="flex flex-wrap items-center gap-2 mb-3">
<div className="flex flex-wrap items-center gap-1.5">
{['all', ...Object.keys(AUDIT_CAT)].map(c => (
<button key={c} onClick={() => setAuditCat(c)}
className={`px-2.5 py-1 rounded-lg text-[11px] font-semibold transition-colors ${auditCat === c ? 'bg-amber-500 text-white' : 'bg-zinc-100 dark:bg-white/5 text-zinc-600 dark:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-white/10'}`}>
{c === 'all' ? 'All' : AUDIT_CAT[c].label}
</button>
))}
</div>
<span className="w-px h-5 bg-zinc-200 dark:bg-white/10 mx-1 hidden sm:block" />
<select value={auditPlatform} onChange={e => setAuditPlatform(e.target.value)}
className="rounded-lg border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 px-2 py-1.5 text-xs text-zinc-700 dark:text-zinc-200 focus:outline-none focus:ring-2 focus:ring-amber-500">
<option value="all">All sources</option>
{PLATFORMS.map(p => <option key={p.id} value={p.id}>{p.short}</option>)}
</select>
<div className="relative flex-1 min-w-[140px]">
<Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-zinc-400" />
<input value={auditSearch} onChange={e => setAuditSearch(e.target.value)} placeholder="Search audit log…"
className="w-full rounded-lg border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 pl-8 pr-2 py-1.5 text-xs text-zinc-700 dark:text-zinc-200 focus:outline-none focus:ring-2 focus:ring-amber-500" />
</div>
<span className="text-[11px] text-zinc-400 ml-auto">{filteredAudit.length} entries</span>
</div>
{/* Table */}
<div className="overflow-x-auto max-h-96 overflow-y-auto">
<table className="w-full text-xs">
<thead className="sticky top-0 bg-white dark:bg-zinc-900">
<tr className="text-left text-[10px] uppercase tracking-wider text-zinc-400 border-b border-zinc-100 dark:border-white/5">
<th className="py-2 pr-2 font-bold">When</th>
<th className="py-2 px-2 font-bold">Category</th>
<th className="py-2 px-2 font-bold">Action</th>
<th className="py-2 px-2 font-bold">Detail</th>
<th className="py-2 px-2 font-bold">Actor</th>
<th className="py-2 pl-2 font-bold">Source</th>
</tr>
</thead>
<tbody>
{filteredAudit.length === 0 && <tr><td colSpan={6} className="py-4 text-center text-zinc-400">No audit entries match these filters.</td></tr>}
{filteredAudit.map(e => {
const cat = AUDIT_CAT[e.category] || AUDIT_CAT.campaign;
const CIcon = cat.icon;
return (
<tr key={e.id} className="border-b border-zinc-50 dark:border-white/5 align-top">
<td className="py-2 pr-2 text-zinc-400 whitespace-nowrap">{fmtDateTime(e.ts)}</td>
<td className="py-2 px-2">
<span className={`inline-flex items-center gap-1 font-semibold ${cat.color}`}>{CIcon && <CIcon size={12} />}{cat.label}</span>
</td>
<td className="py-2 px-2 font-semibold text-zinc-700 dark:text-zinc-200 whitespace-nowrap">{e.action}</td>
<td className="py-2 px-2 text-zinc-500 max-w-[280px] truncate" title={e.detail}>{e.detail}</td>
<td className="py-2 px-2 text-zinc-500 whitespace-nowrap">{e.actor}</td>
<td className="py-2 pl-2">{e.platform ? <PlatformBadge platformId={e.platform} size="sm" showName={false} /> : <span className="text-zinc-300 dark:text-zinc-600"></span>}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</Card>
</div>
);
}
function AiStat({ value, label, color }) {
return (
<div className="rounded-lg bg-zinc-50 dark:bg-white/5 p-2 text-center">
<p className={`text-base font-mono font-bold ${color}`}>{value}</p>
<p className="text-[9px] uppercase tracking-wider text-zinc-400 leading-tight">{label}</p>
</div>
);
}
const CONSENT_CHIP = { approved: 'approved', pending: 'pending', superseded: 'merged', blocked: 'blocked' };
function ConsentFormBlock({ form, versions, onApprove, onBlock }) {
const [showHistory, setShowHistory] = useState(false);
const sorted = [...versions].sort((a, b) => b.version - a.version);
const live = sorted.find(v => v.status === 'approved');
const pending = sorted.filter(v => v.status === 'pending');
const history = sorted.filter(v => v.status === 'superseded' || v.status === 'blocked');
return (
<div className="rounded-xl border border-zinc-200 dark:border-white/10 p-3">
<div className="flex items-center justify-between gap-2 mb-2">
<div className="flex items-center gap-2 min-w-0">
<PlatformBadge platformId={form.platform} size="sm" showName={false} />
<h4 className="font-bold text-sm text-zinc-900 dark:text-white truncate">{form.title}</h4>
</div>
{live && <span className="inline-flex items-center gap-1 text-[10px] font-bold uppercase tracking-wider text-emerald-600 dark:text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded-full shrink-0"><Lock size={10} /> Live v{live.version}</span>}
</div>
{/* Live version */}
{live && <ConsentVersionView v={live} live />}
{/* Pending versions awaiting approval */}
{pending.map(v => (
<div key={v.id} className="mt-2 rounded-lg border border-amber-200 dark:border-amber-500/20 bg-amber-50/60 dark:bg-amber-500/5 p-2.5">
<div className="flex items-center justify-between gap-2 mb-1">
<span className="text-[10px] font-bold uppercase tracking-wider text-amber-600 dark:text-amber-400 flex items-center gap-1"><AlertTriangle size={11} /> v{v.version} pending approval</span>
<div className="flex items-center gap-1.5">
<Btn size="sm" variant="success" icon={CheckCircle2} onClick={() => onApprove(v.id)}>Approve</Btn>
<Btn size="sm" variant="danger" icon={Ban} onClick={() => onBlock(v.id)}>Block</Btn>
</div>
</div>
<ConsentVersionView v={v} />
</div>
))}
{/* History */}
{history.length > 0 && (
<div className="mt-2">
<button onClick={() => setShowHistory(s => !s)} className="text-[11px] text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200 flex items-center gap-1">
<History size={12} /> {history.length} previous version{history.length > 1 ? 's' : ''}
<ChevronDown size={12} className={`transition-transform ${showHistory ? 'rotate-180' : ''}`} />
</button>
{showHistory && (
<div className="mt-2 space-y-2 pl-2 border-l-2 border-zinc-100 dark:border-white/10">
{history.map(v => <ConsentVersionView key={v.id} v={v} muted />)}
</div>
)}
</div>
)}
</div>
);
}
function ConsentVersionView({ v, live, muted }) {
return (
<div className={muted ? 'opacity-70' : ''}>
<div className="flex items-center gap-2 flex-wrap mb-1">
<span className="text-[10px] font-mono font-bold text-zinc-500">v{v.version}</span>
<span className="text-[10px] text-zinc-400">· {v.channel}</span>
{!live && <StatusChip status={CONSENT_CHIP[v.status] || 'draft'} label={v.status} />}
<span className="text-[10px] text-zinc-400 ml-auto">{v.reviewer ? `${v.reviewer} · ` : ''}{fmtDate(v.createdAt)}</span>
</div>
<p className="text-[11px] text-zinc-600 dark:text-zinc-300 italic leading-relaxed">"{v.text}"</p>
<div className="flex items-center gap-3 mt-1 text-[10px] text-zinc-400 flex-wrap">
<span>Opt-out: {v.optOut}</span>
{v.privacyPolicyUrl && <span className="font-mono truncate">Privacy: {v.privacyPolicyUrl}</span>}
</div>
</div>
);
}
function ApprovalRow({ item, onResolve }) {
const isBlocked = item.status === 'blocked';
return (
<div className={`flex flex-col sm:flex-row sm:items-center gap-3 p-3 rounded-xl ${isBlocked ? 'bg-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-500/20' : 'bg-zinc-50 dark:bg-white/5'}`}>
<div className="flex items-start gap-2 min-w-0 flex-1">
<FileWarning size={16} className={isBlocked ? 'text-red-500 mt-0.5' : 'text-amber-500 mt-0.5'} />
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[10px] font-bold uppercase tracking-wide px-1.5 py-0.5 rounded bg-zinc-200 dark:bg-white/10 text-zinc-500">{TYPE_LABEL[item.type]}</span>
<h4 className="font-semibold text-sm text-zinc-800 dark:text-zinc-100">{item.title}</h4>
</div>
{item.flags?.length > 0 && (
<ul className="mt-1 space-y-0.5">
{item.flags.map((f, i) => <li key={i} className="text-[11px] text-amber-600 dark:text-amber-400/80 flex items-start gap-1"><AlertTriangle size={11} className="mt-0.5 shrink-0" />{f}</li>)}
</ul>
)}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<Btn size="sm" variant="success" icon={CheckCircle2} disabled={isBlocked} onClick={() => onResolve(item.id, 'approved')}>Approve</Btn>
<Btn size="sm" variant="danger" icon={Ban} onClick={() => onResolve(item.id, 'blocked')}>{isBlocked ? 'Keep blocked' : 'Block'}</Btn>
</div>
</div>
);
}
@@ -0,0 +1,322 @@
/**
* ConnectedAccounts platform cards with connect/disconnect, token + webhook health,
* permission scopes, last sync, and a webhook test button (spec §4, §8).
*
* Capability badges make the spec §11 non-negotiable visible: the UI honestly shows
* which platforms are ingest-only / report-only / full-publish, so nobody assumes
* automation that doesn't exist.
*/
import React, { useState } from 'react';
import { createPortal } from 'react-dom';
import { Plug, RefreshCw, Webhook, KeyRound, Clock, AlertTriangle, CheckCircle2, AlarmClock, ShieldCheck, ShieldAlert, Upload, Download, X, FileUp } from 'lucide-react';
import { useSocialAds } from '../SocialAdsContext.jsx';
import { PLATFORMS, CAPABILITY, getPlatform } from '../data/platforms.js';
import { Card, StatusChip, CapabilityBadge, SectionHeader, Btn } from '../components/ui.jsx';
import { toast } from 'sonner';
const ALL_CAPS = [
CAPABILITY.INGEST, CAPABILITY.REPORT, CAPABILITY.CONVERSION_SYNC,
CAPABILITY.DRAFT_BUILDER, CAPABILITY.FULL_PUBLISH,
];
const TOKEN_META = {
healthy: { icon: CheckCircle2, color: 'text-emerald-500', label: 'Healthy' },
expiring: { icon: AlertTriangle, color: 'text-amber-500', label: 'Expiring soon' },
revoked: { icon: AlertTriangle, color: 'text-red-500', label: 'Revoked' },
none: { icon: KeyRound, color: 'text-zinc-400', label: 'No token' },
};
function fmtDate(iso) {
if (!iso) return '—';
return new Date(iso).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
}
// Permission/scope status from the granted scopes (spec §8 "permission status").
function permState(acc) {
if (acc?.status === 'connected') return acc.scopes?.length ? 'granted' : 'insufficient';
if (acc?.status === 'action_required') return 'insufficient';
return 'none';
}
const PERM_META = {
granted: { icon: ShieldCheck, color: 'text-emerald-600 dark:text-emerald-400', label: 'Granted' },
insufficient: { icon: ShieldAlert, color: 'text-amber-600 dark:text-amber-400', label: 'Insufficient' },
none: { icon: KeyRound, color: 'text-zinc-400', label: 'Not connected' },
};
export default function ConnectedAccounts() {
const { accounts, toggleAccount, testWebhook, reconnectAccount, importLeads } = useSocialAds();
const accountByPlatform = Object.fromEntries(accounts.map(a => [a.platform, a]));
const [importFor, setImportFor] = useState(null); // account being manually imported into
const connectedCount = accounts.filter(a => a.status === 'connected').length;
// Token-expiration / failure alerts (spec §8 MVP, §11, §12 A1 "token failure displays alert").
const tokenAlerts = accounts.filter(a => a.tokenStatus === 'expiring' || a.tokenStatus === 'revoked' || a.status === 'action_required');
return (
<div>
<SectionHeader
icon={Plug}
title="Connected Accounts"
description="Manage platform connections, token health, and webhooks. Secrets are stored outside the app DB."
>
<StatusChip status="connected" label={`${connectedCount}/${PLATFORMS.length} connected`} />
</SectionHeader>
{/* Token-expiration / failure alerts (spec §8 MVP, §11, §12 A1) */}
{tokenAlerts.length > 0 && (
<div className="mb-5 rounded-xl border border-red-200 dark:border-red-500/30 bg-red-50 dark:bg-red-500/10 p-4">
<div className="flex items-center gap-2 mb-2 text-sm font-semibold text-red-700 dark:text-red-400">
<AlarmClock size={18} className="animate-pulse shrink-0" />
{tokenAlerts.length} account token{tokenAlerts.length > 1 ? 's' : ''} need attention leads can be lost while a connection is down.
</div>
<div className="space-y-1.5">
{tokenAlerts.map(a => {
const p = getPlatform(a.platform);
const PIcon = p?.icon;
const reason = a.tokenStatus === 'revoked' ? 'token revoked' : a.tokenStatus === 'expiring' ? `token expiring ${a.tokenExpiresAt}` : 'action required';
return (
<div key={a.id} className="flex items-center gap-2 px-2.5 py-2 rounded-lg bg-white/70 dark:bg-white/5">
{PIcon && <span className="shrink-0" style={{ color: p.color }}><PIcon size={15} /></span>}
<span className="text-xs font-semibold text-zinc-700 dark:text-zinc-200">{p?.short || a.platform}</span>
<span className="text-[11px] text-red-600 dark:text-red-400"> {reason}</span>
<div className="ml-auto flex items-center gap-1.5">
<Btn size="sm" variant="outline" icon={Upload} onClick={() => setImportFor(a)}>Manual import</Btn>
<Btn size="sm" variant="primary" icon={RefreshCw} onClick={() => reconnectAccount(a.id)}>Reconnect</Btn>
</div>
</div>
);
})}
</div>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{PLATFORMS.map(p => {
const acc = accountByPlatform[p.id];
const connected = acc?.status === 'connected';
const tokenMeta = TOKEN_META[acc?.tokenStatus] || TOKEN_META.none;
const TokenIcon = tokenMeta.icon;
return (
<Card key={p.id} className="!h-auto">
<div className="flex items-start justify-between gap-2 mb-3">
<div className="flex items-center gap-2.5 min-w-0">
<span className="inline-flex items-center justify-center rounded-xl p-2.5 shrink-0" style={{ backgroundColor: `${p.color}1A`, color: p.color }}>
<p.icon size={22} />
</span>
<div className="min-w-0">
<h3 className="font-bold text-zinc-900 dark:text-white truncate">{p.name}</h3>
<p className="text-[11px] text-zinc-500 dark:text-zinc-400 truncate">{acc?.externalAccountId || 'Not connected'}</p>
</div>
</div>
<StatusChip status={acc?.status || 'disconnected'} />
</div>
<p className="text-xs text-zinc-500 dark:text-zinc-400 mb-3 line-clamp-2">{p.role}</p>
{/* Capability matrix */}
<div className="flex flex-wrap gap-1.5 mb-3">
{ALL_CAPS.map(cap => (
<CapabilityBadge key={cap} capability={cap} active={p.capabilities.includes(cap)} />
))}
</div>
{/* Health rows */}
<div className="space-y-1.5 text-xs border-t border-zinc-100 dark:border-white/5 pt-3 mb-3">
<div className="flex items-center justify-between">
<span className="flex items-center gap-1.5 text-zinc-500"><TokenIcon size={13} className={tokenMeta.color} /> Token</span>
<span className={`font-semibold ${tokenMeta.color}`}>{tokenMeta.label}{acc?.tokenExpiresAt ? ` · ${acc.tokenExpiresAt}` : ''}</span>
</div>
<div className="flex items-center justify-between">
<span className="flex items-center gap-1.5 text-zinc-500"><Webhook size={13} /> Webhook</span>
<span className="font-semibold text-zinc-600 dark:text-zinc-300 capitalize">{(acc?.webhookStatus || 'not configured').replace(/_/g, ' ')}</span>
</div>
<div className="flex items-center justify-between">
<span className="flex items-center gap-1.5 text-zinc-500"><Clock size={13} /> Last sync</span>
<span className="font-semibold text-zinc-600 dark:text-zinc-300">{fmtDate(acc?.lastSyncAt)}</span>
</div>
</div>
{/* Permissions / scopes (spec §8 "permission status", §7 scopes_json) */}
{(() => {
const perm = permState(acc);
const pm = PERM_META[perm];
const PermIcon = pm.icon;
return (
<div className="border-t border-zinc-100 dark:border-white/5 pt-3 mb-3">
<div className="flex items-center justify-between text-xs mb-1.5">
<span className="flex items-center gap-1.5 text-zinc-500"><KeyRound size={13} /> Permissions</span>
<span className={`flex items-center gap-1 font-semibold ${pm.color}`}>{PermIcon && <PermIcon size={13} />} {pm.label}</span>
</div>
{acc?.scopes?.length > 0 ? (
<div className="flex flex-wrap gap-1">
{acc.scopes.map(s => (
<span key={s} className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-zinc-100 dark:bg-white/10 text-zinc-600 dark:text-zinc-300">{s}</span>
))}
</div>
) : (
<p className="text-[11px] text-amber-600 dark:text-amber-400/80">
{perm === 'none' ? 'No permissions granted — connect to authorize scopes.' : 'Missing required scopes — reconnect to re-grant.'}
</p>
)}
</div>
);
})()}
{acc?.cautions && (
<p className="text-[11px] text-amber-600 dark:text-amber-400/80 mb-3 flex items-start gap-1">
<AlertTriangle size={12} className="mt-0.5 shrink-0" /> {p.cautions}
</p>
)}
<div className="flex items-center gap-2">
<Btn
size="sm"
variant={connected ? 'outline' : 'primary'}
onClick={() => toggleAccount(acc?.id)}
className="flex-1"
>
{connected ? 'Disconnect' : 'Connect'}
</Btn>
<Btn size="sm" variant="ghost" icon={RefreshCw} onClick={() => testWebhook(acc?.id)} disabled={!connected}>
Test lead
</Btn>
<Btn size="sm" variant="ghost" icon={Upload} onClick={() => setImportFor(acc)} disabled={!acc} title="Manually import leads from a CSV">
Import
</Btn>
</div>
</Card>
);
})}
</div>
{importFor && (
<ManualImportModal
account={importFor}
onClose={() => setImportFor(null)}
onImport={(rows) => { importLeads(importFor.platform, rows); setImportFor(null); }}
/>
)}
</div>
);
}
// Manual lead import (spec §11 fallback)
function splitCsvLine(line) {
const out = []; let cur = ''; let q = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (q) {
if (ch === '"') { if (line[i + 1] === '"') { cur += '"'; i++; } else q = false; }
else cur += ch;
} else if (ch === '"') q = true;
else if (ch === ',') { out.push(cur); cur = ''; }
else cur += ch;
}
out.push(cur);
return out;
}
function parseLeadsCsv(text) {
const lines = text.trim().split(/\r?\n/).filter(l => l.trim());
if (!lines.length) return [];
const header = splitCsvLine(lines[0]).map(h => h.trim().toLowerCase());
const idx = (names) => header.findIndex(h => names.includes(h));
const cols = {
name: idx(['name', 'full_name', 'full name', 'contact']),
phone: idx(['phone', 'phone_number', 'phone number', 'mobile']),
email: idx(['email', 'email_address', 'e-mail']),
address: idx(['address', 'street_address', 'street', 'property']),
service: idx(['service', 'service_type', 'servicetype', 'type']),
consent: idx(['consent', 'opt_in', 'optin', 'sms_consent']),
};
const hasHeader = [cols.name, cols.phone, cols.email, cols.address].some(i => i >= 0);
const dataLines = hasHeader ? lines.slice(1) : lines;
const pick = (c, i) => (i >= 0 ? (c[i] || '').trim() : '');
return dataLines.map(line => {
const c = splitCsvLine(line);
return hasHeader
? { name: pick(c, cols.name), phone: pick(c, cols.phone), email: pick(c, cols.email), address: pick(c, cols.address), service: pick(c, cols.service), consent: pick(c, cols.consent) }
: { name: (c[0] || '').trim(), phone: (c[1] || '').trim(), email: (c[2] || '').trim(), address: (c[3] || '').trim(), service: (c[4] || '').trim(), consent: (c[5] || '').trim() };
}).filter(r => r.name || r.phone || r.email);
}
const SAMPLE_CSV = `name,phone,email,address,service,consent
John Carter,(972) 555-0190,john.carter@example.com,"410 Oak St, Plano, TX 75023",storm,yes
Maria Gomez,(469) 555-0177,maria.g@example.com,"88 Elm Dr, Allen, TX",replacement,yes
Sam Two Rivers,(214) 555-0162,sam.tr@example.com,"7 Birch Ct, Frisco, TX",emergency,no`;
function ManualImportModal({ account, onClose, onImport }) {
const [text, setText] = useState('');
const platform = getPlatform(account.platform);
const rows = parseLeadsCsv(text);
const withConsent = rows.filter(r => /^(y|yes|true|1)$/i.test(r.consent)).length;
const onFile = (e) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => setText(String(reader.result || ''));
reader.readAsText(file);
};
const downloadTemplate = () => {
const blob = new Blob(['name,phone,email,address,service,consent\n'], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = 'lead-import-template.csv'; a.click();
URL.revokeObjectURL(url);
toast.success('Template downloaded');
};
return createPortal(
<div className="fixed inset-0 z-[1000] flex items-center justify-center p-4" onClick={onClose}>
<div className="absolute inset-0 bg-black/50 backdrop-blur-[1px]" />
<div onClick={e => e.stopPropagation()} className="relative w-full max-w-lg max-h-[90vh] overflow-y-auto rounded-2xl bg-white dark:bg-zinc-900 shadow-2xl border border-zinc-200 dark:border-white/10 animate-in zoom-in-95">
<div className="sticky top-0 bg-white/95 dark:bg-zinc-900/95 backdrop-blur border-b border-zinc-200 dark:border-white/10 px-5 py-3 flex items-center justify-between gap-3">
<div className="flex items-center gap-2 min-w-0">
{platform && <span className="shrink-0" style={{ color: platform.color }}><platform.icon size={18} /></span>}
<div className="min-w-0">
<h3 className="font-bold text-sm text-zinc-900 dark:text-white truncate">Manual lead import {platform?.short || account.platform}</h3>
<p className="text-[11px] text-zinc-400">Fallback for when a webhook or token is down</p>
</div>
</div>
<button onClick={onClose} className="p-1.5 rounded-lg text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200 hover:bg-zinc-100 dark:hover:bg-white/10 shrink-0"><X size={18} /></button>
</div>
<div className="p-5 space-y-3">
<p className="text-[11px] text-zinc-500 dark:text-zinc-400">Paste CSV or upload a file exported from the platform. Columns: <span className="font-mono">name, phone, email, address, service, consent</span>. Rows without explicit consent import as manual-follow-up only.</p>
<div className="flex flex-wrap items-center gap-2">
<label className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-xl border border-zinc-200 dark:border-white/15 text-xs font-semibold text-zinc-700 dark:text-zinc-200 hover:bg-zinc-100 dark:hover:bg-white/5 cursor-pointer">
<FileUp size={14} /> Upload CSV
<input type="file" accept=".csv,text/csv" onChange={onFile} className="hidden" />
</label>
<Btn size="sm" variant="ghost" onClick={() => setText(SAMPLE_CSV)}>Load sample</Btn>
<Btn size="sm" variant="ghost" icon={Download} onClick={downloadTemplate}>Template</Btn>
</div>
<textarea value={text} onChange={e => setText(e.target.value)} rows={7} placeholder="name,phone,email,address,service,consent&#10;Jane Doe,(972) 555-0101,jane@example.com,..."
className="w-full rounded-lg border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 px-2.5 py-2 text-xs font-mono text-zinc-700 dark:text-zinc-200 focus:outline-none focus:ring-2 focus:ring-amber-500" />
{rows.length > 0 && (
<div className="rounded-lg bg-zinc-50 dark:bg-white/[0.02] border border-zinc-200 dark:border-white/10 p-2.5 text-[11px] text-zinc-600 dark:text-zinc-300">
<b>{rows.length}</b> lead{rows.length > 1 ? 's' : ''} parsed · <b>{withConsent}</b> with consent · {rows.length - withConsent} manual-follow-up only.
<div className="mt-1.5 space-y-0.5 max-h-24 overflow-y-auto">
{rows.slice(0, 5).map((r, i) => <div key={i} className="truncate text-zinc-500"> {r.name || '(no name)'} {r.phone || r.email || '—'} {r.service ? `· ${r.service}` : ''}</div>)}
{rows.length > 5 && <div className="text-zinc-400">and {rows.length - 5} more</div>}
</div>
</div>
)}
</div>
<div className="sticky bottom-0 bg-white/95 dark:bg-zinc-900/95 backdrop-blur border-t border-zinc-200 dark:border-white/10 px-5 py-3 flex justify-end gap-2">
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
<Btn variant="primary" icon={Upload} disabled={rows.length === 0} onClick={() => onImport(rows)}>Import {rows.length || ''} lead{rows.length === 1 ? '' : 's'}</Btn>
</div>
</div>
</div>,
document.body,
);
}
@@ -0,0 +1,383 @@
/**
* CreativeStudio AI draft variants by platform, character-limit + compliance flags,
* version history, and approve / block / publish (spec §4, §6, §8).
*
* Generation is the mock AI assistant (ai/creativeGenerator). Every variant AI or
* hand-typed is scanned by the bannedClaims guardrail; blocked copy cannot be approved.
*/
import React, { useState, useMemo, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { Wand2, Sparkles, CheckCircle2, AlertTriangle, Ban, ShieldCheck, Plus, ExternalLink, PenLine, X, Image, Upload, ImageOff, History, ChevronDown, Undo2 } from 'lucide-react';
import { useSocialAds } from '../SocialAdsContext.jsx';
import { PLATFORMS, getPlatform } from '../data/platforms.js';
import { BLUEPRINTS } from '../data/blueprints.js';
import { generateVariants, CHAR_LIMITS } from '../ai/creativeGenerator.js';
import { scanCopy } from '../ai/bannedClaims.js';
import { Card, StatusChip, SectionHeader, Btn, PlatformBadge } from '../components/ui.jsx';
const SERVICE_TYPES = [
{ id: 'storm', label: 'Storm inspection' },
{ id: 'replacement', label: 'Roof replacement' },
{ id: 'emergency', label: 'Emergency leak' },
{ id: 'commercial', label: 'Commercial bid' },
{ id: 'referral', label: 'Review / referral' },
{ id: 'maintenance', label: 'Maintenance' },
];
const POLICY_META = {
clear: { icon: CheckCircle2, color: 'text-emerald-500', label: 'Policy clear' },
review: { icon: AlertTriangle, color: 'text-amber-500', label: 'Needs review' },
blocked: { icon: Ban, color: 'text-red-500', label: 'Blocked' },
};
export default function CreativeStudio() {
const { creatives, campaigns, mediaAssets, setCreativeApproval, addCreative, updateCreative, revertCreative, addMediaAsset } = useSocialAds();
const navigate = useNavigate();
const fileRef = useRef(null);
const [platform, setPlatform] = useState('meta');
const [serviceType, setServiceType] = useState('storm');
const [city, setCity] = useState('Plano');
const [variants, setVariants] = useState([]);
const [generating, setGenerating] = useState(false);
const [editor, setEditor] = useState(null); // null | { id, platform, headline, body, description }
const generate = () => {
setGenerating(true);
setTimeout(() => {
setVariants(generateVariants({ platformId: platform, serviceType, city, count: 5 }));
setGenerating(false);
}, 650);
};
const openNew = () => setEditor({ id: null, platform, headline: '', body: '', description: '', mediaAssetId: '' });
const openEdit = (c) => setEditor({ id: c.id, platform: c.platform, headline: c.headline, body: c.body, description: c.description || '', mediaAssetId: c.mediaAssetId || '' });
const saveCreative = ({ id, platform: p, headline, body, description, mediaAssetId, policy }) => {
const payload = { platform: p, headline, body, description, mediaAssetId: mediaAssetId || null, policyStatus: policy.status, policyFlags: policy.flags.map(f => f.message) };
if (id) updateCreative(id, payload);
else addCreative({ ...payload, aiGenerated: false });
setEditor(null);
};
const onUploadMedia = (e) => {
const file = e.target.files?.[0];
if (!file) return;
// In-memory preview only object URLs reset on reload (no localStorage/backend).
const url = URL.createObjectURL(file);
addMediaAsset({ name: file.name, kind: 'uploaded', url, gradient: ['#71717a', '#3f3f46'] });
e.target.value = '';
};
// Resolve the landing page a creative's ad would link to: prefer its campaign's
// blueprint, then the service type it was drafted for, then any blueprint that
// runs on the creative's platform.
const previewUrl = (c) => {
const camp = c.campaignId && campaigns.find(x => x.id === c.campaignId);
const bp = (camp && BLUEPRINTS.find(b => b.id === camp.blueprintId))
|| (c.serviceType && BLUEPRINTS.find(b => b.serviceType === c.serviceType))
|| BLUEPRINTS.find(b => b.bestChannels.includes(c.platform))
|| BLUEPRINTS[0];
// Pass the attached media + headline so the landing page previews the actual creative.
const media = mediaAssets.find(m => m.id === c.mediaAssetId);
const img = media?.url || media?.photo;
const extra = (img ? `&img=${encodeURIComponent(img)}` : '') + (c.headline ? `&hl=${encodeURIComponent(c.headline)}` : '');
return `/ads/lp?c=${bp.id}&src=${c.platform}${extra}`;
};
return (
<div className="space-y-6">
<SectionHeader icon={Wand2} title="Creative Studio" description="AI drafts platform-aware ad copy from approved tenant knowledge. All output requires human approval before publish.">
<Btn variant="outline" icon={PenLine} onClick={openNew}>Write your own</Btn>
<StatusChip status="review" label={`${creatives.filter(c => c.approvalStatus === 'pending').length} pending`} />
</SectionHeader>
{editor && (
<CreativeEditor key={editor.id || 'new'} initial={editor} mediaAssets={mediaAssets} onCancel={() => setEditor(null)} onSave={saveCreative} />
)}
{/* Media library (spec §8 / §7 media_asset_id) */}
<Card title="Media library" icon={Image} subtitle="Reusable image assets to attach to creatives."
action={<><input ref={fileRef} type="file" accept="image/*" className="hidden" onChange={onUploadMedia} /><Btn size="sm" variant="outline" icon={Upload} onClick={() => fileRef.current?.click()}>Upload</Btn></>}>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-3">
{mediaAssets.map(a => <MediaThumb key={a.id} asset={a} />)}
</div>
</Card>
{/* Generator panel */}
<Card title="AI Creative Assistant" icon={Sparkles} subtitle="Generate 5 variants with platform tone + character limits.">
<div className="grid grid-cols-1 sm:grid-cols-4 gap-3 mb-4">
<label className="block sm:col-span-1">
<span className="text-xs font-semibold text-zinc-600 dark:text-zinc-300 mb-1.5 block">Platform</span>
<select value={platform} onChange={e => setPlatform(e.target.value)} className={inputCls}>
{PLATFORMS.map(p => <option key={p.id} value={p.id}>{p.short}</option>)}
</select>
</label>
<label className="block sm:col-span-2">
<span className="text-xs font-semibold text-zinc-600 dark:text-zinc-300 mb-1.5 block">Campaign type</span>
<select value={serviceType} onChange={e => setServiceType(e.target.value)} className={inputCls}>
{SERVICE_TYPES.map(s => <option key={s.id} value={s.id}>{s.label}</option>)}
</select>
</label>
<label className="block">
<span className="text-xs font-semibold text-zinc-600 dark:text-zinc-300 mb-1.5 block">City merge</span>
<input value={city} onChange={e => setCity(e.target.value)} className={inputCls} />
</label>
</div>
<div className="flex items-center justify-between gap-3">
<p className="text-[11px] text-zinc-400 flex items-center gap-1">
<ShieldCheck size={12} /> {getPlatform(platform)?.copyGuidance}
</p>
<Btn icon={Sparkles} onClick={generate} disabled={generating}>{generating ? 'Drafting…' : 'Generate variants'}</Btn>
</div>
{variants.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 mt-4">
{variants.map((v, i) => {
const pm = POLICY_META[v.policy.status];
const PIcon = pm.icon;
return (
<div key={i} className="rounded-xl border border-zinc-200 dark:border-white/10 p-3 flex flex-col">
<div className="flex items-center justify-between mb-2">
<span className="text-[10px] font-bold uppercase tracking-wide text-zinc-400">Variant {i + 1}</span>
<span className={`inline-flex items-center gap-1 text-[10px] font-bold ${pm.color}`}><PIcon size={12} /> {pm.label}</span>
</div>
<p className="font-bold text-sm text-zinc-900 dark:text-white">{v.headline}</p>
<p className="text-xs text-zinc-600 dark:text-zinc-300 mt-1 flex-1">{v.body}</p>
{v.description && <p className="text-[11px] text-zinc-400 mt-1 italic">{v.description}</p>}
<div className="text-[10px] text-zinc-400 mt-2 flex flex-wrap gap-x-3">
<span>H {v.charCounts.headline.used}/{v.charCounts.headline.limit}</span>
<span>B {v.charCounts.body.used}/{v.charCounts.body.limit}</span>
</div>
{v.policy.flags.length > 0 && (
<ul className="mt-2 space-y-0.5">
{v.policy.flags.map(f => (
<li key={f.id} className="text-[10px] text-amber-600 dark:text-amber-400/80 flex items-start gap-1"><AlertTriangle size={10} className="mt-0.5 shrink-0" /> {f.message}</li>
))}
</ul>
)}
<Btn size="sm" variant="outline" icon={Plus} className="mt-3"
disabled={v.policy.status === 'blocked'}
onClick={() => addCreative({ platform, serviceType, headline: v.headline, body: v.body, description: v.description, aiGenerated: true, policyStatus: v.policy.status, policyFlags: v.policy.flags.map(f => f.message) })}>
Save to library
</Btn>
</div>
);
})}
</div>
)}
</Card>
{/* Creative library */}
<div>
<h3 className="text-sm font-bold text-zinc-700 dark:text-zinc-300 mb-3">Creative library</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{creatives.map(c => {
const pm = POLICY_META[c.policyStatus] || POLICY_META.review;
const PIcon = pm.icon;
const media = mediaAssets.find(m => m.id === c.mediaAssetId);
return (
<Card key={c.id} className="!h-auto" pad="p-4">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<PlatformBadge platformId={c.platform} size="sm" />
{c.aiGenerated && <span className="inline-flex items-center gap-0.5 text-[10px] font-bold text-purple-500"><Sparkles size={10} /> AI</span>}
</div>
<StatusChip status={c.approvalStatus} />
</div>
{media && (
<div className="mb-2">
<MediaThumb asset={media} />
</div>
)}
<p className="font-bold text-sm text-zinc-900 dark:text-white">{c.headline}</p>
<p className="text-xs text-zinc-600 dark:text-zinc-300 mt-1">{c.body}</p>
{c.description && <p className="text-[11px] text-zinc-400 mt-1 italic">{c.description}</p>}
<div className={`mt-2 inline-flex items-center gap-1 text-[11px] font-semibold ${pm.color}`}><PIcon size={13} /> {pm.label}</div>
{c.policyFlags?.length > 0 && (
<ul className="mt-1 space-y-0.5">
{c.policyFlags.map((f, i) => <li key={i} className="text-[10px] text-amber-600 dark:text-amber-400/80 flex items-start gap-1"><AlertTriangle size={10} className="mt-0.5 shrink-0" />{f}</li>)}
</ul>
)}
<div className="flex flex-wrap items-center gap-2 mt-3 pt-3 border-t border-zinc-100 dark:border-white/5">
<Btn size="sm" variant="success" icon={CheckCircle2} disabled={c.policyStatus === 'blocked' || c.approvalStatus === 'approved'} onClick={() => setCreativeApproval(c.id, 'approved')}>Approve</Btn>
<Btn size="sm" variant="danger" icon={Ban} onClick={() => setCreativeApproval(c.id, 'blocked')}>Block</Btn>
<Btn size="sm" variant="ghost" icon={PenLine} onClick={() => openEdit(c)}>Edit</Btn>
<Btn size="sm" variant="ghost" icon={ExternalLink} className="ml-auto"
title="Open the public lead-capture page this creative's ad links to"
onClick={() => navigate(previewUrl(c))}>
Preview form
</Btn>
{c.policyStatus === 'blocked' && <span className="text-[10px] text-red-500 font-semibold">Cannot publish until resolved</span>}
</div>
<VersionHistory creative={c} mediaAssets={mediaAssets} onRevert={revertCreative} />
</Card>
);
})}
</div>
</div>
</div>
);
}
const inputCls = 'w-full rounded-xl border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 px-3 py-2 text-base sm:text-sm text-zinc-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-amber-500';
function CharCount({ used, limit }) {
if (!limit) return null;
const over = used > limit;
const near = !over && used > limit * 0.85;
return <span className={`text-[10px] font-mono ${over ? 'text-red-500 font-bold' : near ? 'text-amber-500' : 'text-zinc-400'}`}>{used}/{limit}</span>;
}
// Media tile shows the asset's photo (uploaded object-URL or a topic-matched image)
// over its gradient, which stays as a graceful fallback if the image can't load.
function MediaThumb({ asset, selected, onClick }) {
const [err, setErr] = useState(false);
const src = asset.url || asset.photo;
const showImg = src && !err;
const tile = (
<>
<div className={`relative w-full h-20 rounded-lg overflow-hidden border ${selected ? 'border-amber-500 ring-2 ring-amber-500/40' : 'border-zinc-200 dark:border-white/10'}`}
style={{ background: `linear-gradient(135deg, ${asset.gradient?.[0] || '#71717a'}, ${asset.gradient?.[1] || '#3f3f46'})` }}>
{showImg
? <img src={src} alt={asset.name} loading="lazy" onError={() => setErr(true)} className="w-full h-full object-cover" />
: <div className="w-full h-full flex items-center justify-center"><Image size={20} className="text-white/80" /></div>}
{selected && <span className="absolute top-1 right-1 bg-amber-500 text-white rounded-full p-0.5"><CheckCircle2 size={12} /></span>}
</div>
<p className="text-[10px] text-zinc-500 dark:text-zinc-400 truncate mt-1">{asset.name}</p>
</>
);
return onClick
? <button type="button" onClick={onClick} className="block text-left w-full">{tile}</button>
: <div className="w-full">{tile}</div>;
}
const fmtWhen = (iso) => {
if (!iso) return '';
try { return new Date(iso).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }); }
catch { return iso; }
};
// Version history (spec §7/§8) prior copy versions with one-click revert.
function VersionHistory({ creative, mediaAssets = [], onRevert }) {
const [open, setOpen] = useState(false);
const versions = creative.versions || [];
if (versions.length === 0) return null;
return (
<div className="mt-3 pt-3 border-t border-zinc-100 dark:border-white/5">
<button onClick={() => setOpen(o => !o)}
className="flex items-center gap-1 text-[11px] font-semibold text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-200 transition-colors">
<History size={12} /> Version history ({versions.length})
<ChevronDown size={13} className={`transition-transform ${open ? 'rotate-180' : ''}`} />
</button>
{open && (
<ul className="mt-2 space-y-1.5">
{versions.slice().reverse().map(ver => {
const pm = POLICY_META[ver.policyStatus] || POLICY_META.review;
const media = mediaAssets.find(m => m.id === ver.mediaAssetId);
return (
<li key={ver.v} className="flex items-start justify-between gap-2 rounded-lg bg-zinc-50 dark:bg-white/5 px-2.5 py-1.5">
<div className="min-w-0">
<p className="text-[11px] font-semibold text-zinc-700 dark:text-zinc-200 truncate">v{ver.v} · {ver.headline}</p>
<p className="text-[10px] text-zinc-400 truncate">{ver.body}</p>
<p className="text-[10px] text-zinc-400">{fmtWhen(ver.savedAt)} · <span className={pm.color}>{pm.label}</span>{media ? ` · ${media.name}` : ''}</p>
</div>
<Btn size="sm" variant="ghost" icon={Undo2} onClick={() => onRevert(creative.id, ver.v)}>Revert</Btn>
</li>
);
})}
</ul>
)}
</div>
);
}
// Manual create/edit (spec §8/B2 "manual edit") platform-aware char limits + a live
// compliance scan so hand-written copy is held to the same guardrail as AI drafts.
function CreativeEditor({ initial, mediaAssets = [], onCancel, onSave }) {
const [platform, setPlatform] = useState(initial.platform);
const [headline, setHeadline] = useState(initial.headline);
const [body, setBody] = useState(initial.body);
const [description, setDescription] = useState(initial.description);
const [mediaAssetId, setMediaAssetId] = useState(initial.mediaAssetId || '');
const limits = CHAR_LIMITS[platform] || CHAR_LIMITS.meta;
const policy = useMemo(() => scanCopy(headline, body, description), [headline, body, description]);
const pm = POLICY_META[policy.status];
const PIcon = pm.icon;
const over = (limits.headline && headline.length > limits.headline)
|| (limits.body && body.length > limits.body)
|| (limits.description && description.length > limits.description);
const valid = headline.trim() && body.trim() && policy.status !== 'blocked' && !over;
const labelCls = 'text-xs font-semibold text-zinc-600 dark:text-zinc-300 mb-1.5 flex items-center justify-between gap-2';
return (
<Card title={initial.id ? 'Edit creative' : 'Write your own creative'} icon={PenLine}
subtitle="Platform-aware character limits + live compliance scan. Editing resets approval to pending."
action={<button onClick={onCancel} className="p-1 rounded-lg text-zinc-400 hover:bg-zinc-100 dark:hover:bg-white/10"><X size={18} /></button>}>
<div className="space-y-3">
<div className="grid grid-cols-1 sm:grid-cols-[160px_1fr] gap-3">
<label className="block">
<span className="text-xs font-semibold text-zinc-600 dark:text-zinc-300 mb-1.5 block">Platform</span>
<select value={platform} onChange={e => setPlatform(e.target.value)} className={inputCls}>
{PLATFORMS.map(p => <option key={p.id} value={p.id}>{p.short}</option>)}
</select>
</label>
<label className="block">
<span className={labelCls}>Headline <CharCount used={headline.length} limit={limits.headline} /></span>
<input value={headline} onChange={e => setHeadline(e.target.value)} className={inputCls} placeholder="Short, benefit-led headline" />
</label>
</div>
<label className="block">
<span className={labelCls}>Body <CharCount used={body.length} limit={limits.body} /></span>
<textarea rows={3} value={body} onChange={e => setBody(e.target.value)} className={`${inputCls} resize-none`} placeholder="Explain the offer clearly — no exaggerated or unverified claims." />
</label>
{limits.description > 0 && (
<label className="block">
<span className={labelCls}>Description <CharCount used={description.length} limit={limits.description} /></span>
<input value={description} onChange={e => setDescription(e.target.value)} className={inputCls} placeholder="Optional supporting line" />
</label>
)}
{/* Media attach (spec §7 media_asset_id) */}
<div>
<span className="text-xs font-semibold text-zinc-600 dark:text-zinc-300 mb-1.5 block">Media <span className="font-normal text-zinc-400">· optional</span></span>
<div className="grid grid-cols-3 sm:grid-cols-5 gap-2">
<button type="button" onClick={() => setMediaAssetId('')} className="block text-left w-full">
<div className={`h-20 rounded-lg border flex items-center justify-center ${!mediaAssetId ? 'border-amber-500 ring-2 ring-amber-500/40 text-amber-500' : 'border-zinc-200 dark:border-white/10 text-zinc-400'}`}>
<ImageOff size={18} />
</div>
<p className="text-[10px] text-zinc-500 dark:text-zinc-400 truncate mt-1">No media</p>
</button>
{mediaAssets.map(a => (
<MediaThumb key={a.id} asset={a} selected={mediaAssetId === a.id} onClick={() => setMediaAssetId(a.id)} />
))}
</div>
</div>
<div className={`flex items-start gap-1.5 text-xs font-semibold ${pm.color}`}>
<PIcon size={14} className="mt-0.5 shrink-0" /> {pm.label}
</div>
{policy.flags.length > 0 && (
<ul className="space-y-0.5 -mt-1">
{policy.flags.map(f => <li key={f.id} className="text-[11px] text-amber-600 dark:text-amber-400/80 flex items-start gap-1"><AlertTriangle size={11} className="mt-0.5 shrink-0" /> {f.message}</li>)}
</ul>
)}
<div className="flex flex-wrap items-center justify-end gap-2 pt-3 border-t border-zinc-100 dark:border-white/5">
{policy.status === 'blocked' && <span className="text-[11px] text-red-500 font-semibold mr-auto">Resolve blocked claims before saving</span>}
{over && policy.status !== 'blocked' && <span className="text-[11px] text-red-500 font-semibold mr-auto">Trim copy under the platform limit</span>}
<Btn variant="ghost" onClick={onCancel}>Cancel</Btn>
<Btn icon={CheckCircle2} disabled={!valid}
onClick={() => onSave({ id: initial.id, platform, headline: headline.trim(), body: body.trim(), description: description.trim(), mediaAssetId, policy })}>
{initial.id ? 'Save changes' : 'Save to library'}
</Btn>
</div>
</div>
</Card>
);
}
@@ -0,0 +1,273 @@
/**
* LeadFormMapper editable builder for platform field CRM field mapping, custom
* fields, required flags, transform + validation rules, consent text, privacy URL, the
* thank-you CTA, and live/draft status (spec §4, §7 lead_forms + field_mappings, §8).
*
* Edits write straight to the module state; consent/thank-you/mapping are no longer a
* static display. A test lead would land in the CRM fields shown in the target column.
*/
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
ClipboardList, ArrowRight, Lock, ShieldCheck, FileText, CheckCircle2,
ExternalLink, Plus, Trash2, Power, Sparkles, AlertTriangle, X, FlaskConical,
} from 'lucide-react';
import { useSocialAds } from '../SocialAdsContext.jsx';
import { BLUEPRINTS, BLUEPRINT_BY_ID } from '../data/blueprints.js';
import { suggestQuestions, SENSITIVE_EXCLUDED } from '../ai/questionOptimizer.js';
import { runTestLead } from '../data/testLead.js';
import { Card, SectionHeader, PlatformBadge, StatusChip, Btn } from '../components/ui.jsx';
const TRANSFORMS = ['none', 'titlecase', 'lowercase', 'e164', 'bool', 'int', 'date'];
const inputCls = 'w-full rounded-lg border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 px-2.5 py-1.5 text-base sm:text-sm text-zinc-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-amber-500';
export default function LeadFormMapper() {
const {
leadForms, campaigns, updateLeadForm, updateFormField,
addFormField, removeFormField, toggleFormStatus, addLeadForm,
} = useSocialAds();
const navigate = useNavigate();
const [activeId, setActiveId] = useState(leadForms[0]?.id);
const [suggestions, setSuggestions] = useState(null);
const [test, setTest] = useState(null);
const form = leadForms.find(f => f.id === activeId) || leadForms[0];
// The public preview mirrors what a lead sees: the form's campaign decides the
// blueprint headline/offer (?c); the form's platform sets the ad source (?src).
const campaign = campaigns.find(c => c.id === form?.campaignId);
const blueprintId = campaign?.blueprintId || BLUEPRINTS[0].id;
const previewUrl = `/ads/lp?c=${blueprintId}&src=${form?.platform}`;
const serviceType = campaign?.serviceType || BLUEPRINT_BY_ID[blueprintId]?.serviceType || 'storm';
const selectForm = (id) => { setActiveId(id); setSuggestions(null); setTest(null); };
const runTest = () => setTest(runTestLead(form));
const createForm = () => { const f = addLeadForm(); selectForm(f.id); };
const optimize = () => setSuggestions(suggestQuestions(form, serviceType));
const addSuggestion = (q) => {
addFormField(form.id, { source: q.source, target: q.target, transform: q.transform, required: false, validation: q.validation });
setSuggestions(prev => prev.filter(s => s.source !== q.source));
};
const addAllSuggestions = () => { suggestions.forEach(addSuggestion); setSuggestions([]); };
if (!form) return null;
return (
<div>
<SectionHeader icon={ClipboardList} title="Lead Form Mapper" description="Map platform fields to CRM fields, edit consent + thank-you, and set live/draft status.">
<Btn variant="outline" icon={Plus} onClick={createForm}>New form</Btn>
<Btn variant="outline" icon={ExternalLink} onClick={() => navigate(previewUrl)} title="Open the public lead-capture page for the selected form">
Preview
</Btn>
</SectionHeader>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-4">
{/* Form list */}
<div className="lg:col-span-1 space-y-2">
{leadForms.map(f => (
<button key={f.id} onClick={() => selectForm(f.id)}
className={`w-full text-left p-3 rounded-xl border-2 transition-all ${f.id === activeId ? 'border-amber-500 bg-amber-500/5' : 'border-zinc-200 dark:border-white/10 hover:border-amber-300'}`}>
<div className="flex items-center justify-between mb-1">
<PlatformBadge platformId={f.platform} size="sm" showName={false} />
<StatusChip status={f.status === 'live' ? 'active' : 'draft'} label={f.status} />
</div>
<p className="font-bold text-sm text-zinc-900 dark:text-white truncate">{f.title}</p>
<p className="text-[11px] text-zinc-400">{f.fields.length} fields mapped</p>
</button>
))}
</div>
{/* Editable detail */}
<div className="lg:col-span-3 space-y-4">
<Card icon={ClipboardList} title={form.title}
subtitle={`Platform form ID: ${form.platformFormId}`}
action={
<button onClick={() => toggleFormStatus(form.id)} title="Toggle live / draft"
className={`shrink-0 inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs font-semibold transition-colors ${form.status === 'live' ? 'text-emerald-600 dark:text-emerald-400 bg-emerald-500/10' : 'text-zinc-500 bg-zinc-100 dark:bg-white/5'}`}>
<Power size={13} /> {form.status === 'live' ? 'Live' : 'Draft'}
</button>
}>
{/* Title + platform */}
<div className="grid grid-cols-1 sm:grid-cols-[1fr_auto] gap-2 items-end mb-4">
<label className="block">
<span className="text-[11px] font-semibold text-zinc-500 mb-1 block">Form title</span>
<input value={form.title} onChange={e => updateLeadForm(form.id, { title: e.target.value })} className={inputCls} />
</label>
<span className="text-[11px] text-zinc-400 pb-2">Maps to: name, phone, email, address, role, urgency, consent</span>
</div>
{/* AI question optimizer (spec §6) */}
<div className="rounded-xl border border-purple-200 dark:border-purple-500/20 bg-purple-50/60 dark:bg-purple-500/5 p-3 mb-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<Sparkles size={15} className="text-purple-500 shrink-0" />
<p className="text-xs text-zinc-600 dark:text-zinc-300">Suggest 24 qualifying questions to raise lead quality.</p>
</div>
<Btn size="sm" variant="outline" icon={Sparkles} onClick={optimize}>Optimize questions</Btn>
</div>
{form.fields.length >= 6 && (
<p className="text-[11px] text-amber-600 dark:text-amber-400/80 mt-2 flex items-start gap-1">
<AlertTriangle size={12} className="mt-0.5 shrink-0" /> This form already has {form.fields.length} fields keep it low-friction; add only what improves routing.
</p>
)}
{suggestions && (
<div className="mt-3">
{suggestions.length === 0 ? (
<p className="text-[11px] text-zinc-500">No new qualifying questions this form is well-covered.</p>
) : (
<>
<div className="flex items-center justify-between mb-1.5">
<span className="text-[10px] font-bold uppercase tracking-wider text-purple-500">{suggestions.length} suggested</span>
<div className="flex items-center gap-1.5">
<Btn size="sm" variant="ghost" icon={Plus} onClick={addAllSuggestions}>Add all</Btn>
<button onClick={() => setSuggestions(null)} className="p-1 rounded-lg text-zinc-400 hover:bg-zinc-100 dark:hover:bg-white/10"><X size={15} /></button>
</div>
</div>
<ul className="space-y-1.5">
{suggestions.map(q => (
<li key={q.source} className="flex items-start justify-between gap-2 rounded-lg bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-white/10 px-2.5 py-1.5">
<div className="min-w-0">
<p className="text-xs font-semibold text-zinc-800 dark:text-zinc-100">{q.label}</p>
<p className="text-[10px] text-zinc-400 truncate"> <span className="font-mono text-blue-600 dark:text-blue-400">{q.target}</span> · {q.rationale}</p>
</div>
<Btn size="sm" variant="outline" icon={Plus} onClick={() => addSuggestion(q)}>Add</Btn>
</li>
))}
</ul>
</>
)}
<p className="text-[10px] text-zinc-400 mt-2 flex items-start gap-1">
<ShieldCheck size={11} className="mt-0.5 shrink-0 text-emerald-500" /> Guardrail: never requests sensitive data ({SENSITIVE_EXCLUDED.join(', ')}); capped at 4 to avoid over-friction.
</p>
</div>
)}
</div>
{/* Field mappings */}
<div className="space-y-2">
{form.fields.map(f => (
<FieldEditor key={f.source} field={f}
onChange={patch => updateFormField(form.id, f.source, patch)}
onRemove={() => removeFormField(form.id, f.source)} />
))}
</div>
<div className="flex flex-wrap items-center gap-2 mt-3">
<Btn size="sm" variant="ghost" icon={Plus} onClick={() => addFormField(form.id)}>Add field</Btn>
<Btn size="sm" variant="outline" icon={FlaskConical} onClick={runTest}>Send test lead</Btn>
</div>
{test && <TestLeadResult result={test} onClose={() => setTest(null)} onRerun={runTest} />}
</Card>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card title="Consent text" icon={Lock} subtitle="Edited consent should be re-reviewed by Compliance">
<textarea rows={4} value={form.consentText} onChange={e => updateLeadForm(form.id, { consentText: e.target.value })}
className={`${inputCls} resize-none`} />
<label className="block mt-2">
<span className="text-[11px] font-semibold text-zinc-500 mb-1 flex items-center gap-1"><ShieldCheck size={12} /> Privacy policy URL</span>
<input value={form.privacyPolicyUrl} onChange={e => updateLeadForm(form.id, { privacyPolicyUrl: e.target.value })} className={inputCls} />
</label>
</Card>
<Card title="Thank-you screen" icon={FileText} subtitle="Shown after submission">
<textarea rows={4} value={form.thankYou} onChange={e => updateLeadForm(form.id, { thankYou: e.target.value })}
className={`${inputCls} resize-none`} />
<p className="text-[11px] text-zinc-400 mt-2">Consent proof (text, URL, timestamp, channel) is stored with every submission before any marketing automation runs.</p>
</Card>
</div>
</div>
</div>
</div>
);
}
function FieldEditor({ field, onChange, onRemove }) {
return (
<div className="rounded-xl border border-zinc-200 dark:border-white/10 p-3">
<div className="flex items-center justify-between gap-2 mb-2">
<span className="text-[10px] font-bold uppercase tracking-wider text-zinc-400">Field mapping</span>
<div className="flex items-center gap-1.5">
<button onClick={() => onChange({ required: !field.required })}
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[10px] font-bold ${field.required ? 'text-emerald-600 dark:text-emerald-400 bg-emerald-500/10' : 'text-zinc-400 bg-zinc-100 dark:bg-white/5'}`}>
<CheckCircle2 size={11} /> {field.required ? 'Required' : 'Optional'}
</button>
<button onClick={onRemove} title="Remove field" className="text-zinc-400 hover:text-red-500 p-1"><Trash2 size={14} /></button>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<Labeled label="Platform field">
<input value={field.source} onChange={e => onChange({ source: e.target.value })} className={`${inputCls} font-mono`} placeholder="e.g. full_name" />
</Labeled>
<Labeled label="CRM target" icon={ArrowRight}>
<input value={field.target} onChange={e => onChange({ target: e.target.value })} className={`${inputCls} font-mono`} placeholder="e.g. contact.name" />
</Labeled>
<Labeled label="Transform">
<select value={field.transform || 'none'} onChange={e => onChange({ transform: e.target.value })} className={inputCls}>
{TRANSFORMS.map(t => <option key={t} value={t}>{t}</option>)}
</select>
</Labeled>
<Labeled label="Validation rule">
<input value={field.validation || ''} onChange={e => onChange({ validation: e.target.value })} className={inputCls} placeholder="e.g. email, phone, required" />
</Labeled>
</div>
</div>
);
}
function Labeled({ label, icon: Icon, children }) {
return (
<label className="block">
<span className="text-[11px] font-semibold text-zinc-500 mb-1 flex items-center gap-1">
{Icon && <Icon size={11} className="text-amber-500" />} {label}
</span>
{children}
</label>
);
}
// Test-lead CRM mapping preview (spec phased C "Test lead maps correctly into CRM fields").
function TestLeadResult({ result, onClose, onRerun }) {
return (
<div className="mt-4 rounded-xl border border-zinc-200 dark:border-white/10 bg-zinc-50 dark:bg-white/5 p-3">
<div className="flex flex-wrap items-center justify-between gap-2 mb-2">
<div className="flex items-center gap-2">
<FlaskConical size={15} className="text-amber-500" />
<span className="text-xs font-bold text-zinc-700 dark:text-zinc-200">Test lead CRM mapping</span>
{result.ok
? <StatusChip status="active" label="Maps correctly" />
: <StatusChip status="blocked" label={`${result.issues} issue${result.issues > 1 ? 's' : ''}`} />}
</div>
<div className="flex items-center gap-1.5">
<Btn size="sm" variant="ghost" icon={FlaskConical} onClick={onRerun}>Re-run</Btn>
<button onClick={onClose} className="p-1 rounded-lg text-zinc-400 hover:bg-zinc-100 dark:hover:bg-white/10"><X size={15} /></button>
</div>
</div>
<div className="space-y-1.5">
{result.rows.map(r => (
<div key={r.source} className={`rounded-lg px-2.5 py-2 border ${r.valid ? 'border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900' : 'border-red-200 dark:border-red-500/30 bg-red-50 dark:bg-red-500/10'}`}>
<div className="flex items-start gap-2">
{r.valid
? <CheckCircle2 size={14} className="text-emerald-500 shrink-0 mt-0.5" />
: <AlertTriangle size={14} className="text-red-500 shrink-0 mt-0.5" />}
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5 text-[11px]">
<code className="font-mono text-zinc-500 dark:text-zinc-400">{r.source}</code>
<span className="text-zinc-400">{r.raw}</span>
{r.transform !== 'none' && <span className="text-[10px] px-1 py-0.5 rounded bg-zinc-100 dark:bg-white/10 text-zinc-500">{r.transform}</span>}
<ArrowRight size={11} className="text-amber-500" />
<code className="font-mono text-blue-600 dark:text-blue-400">{r.target}</code>
<span className="font-semibold text-zinc-700 dark:text-zinc-200">= {r.value || '∅'}</span>
{r.required && <span className="text-[9px] uppercase font-bold text-zinc-400">req</span>}
</div>
{!r.valid && <p className="text-[10px] text-red-500 mt-0.5">{r.issue}</p>}
</div>
</div>
</div>
))}
</div>
<p className="text-[10px] text-zinc-400 mt-2">Simulated payload a real webhook lead would resolve into these CRM fields before consent + triage.</p>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More