social ads engine module screens

This commit is contained in:
Mayur Shinde
2026-06-12 13:22:03 +05:30
parent ed87e097f1
commit e53c38ce59
31 changed files with 5406 additions and 2 deletions
@@ -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
+10
View File
@@ -47,6 +47,8 @@ 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';
// ... (existing imports)
const ProtectedRoute = ({ children, allowedRoles }) => {
@@ -78,11 +80,19 @@ function App() {
<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>
} />
<Route
path="/chat-assistant"
element={
+5 -2
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, Sparkles
HardHat, ShieldCheck, Sparkles, Megaphone
} from 'lucide-react';
import PageTransition from './PageTransition';
@@ -112,7 +112,8 @@ const Layout = () => {
}, []);
// Determine standard layout vs full screen for Public pages
const isPublic = ['/', '/login', '/estimate'].includes(location.pathname);
const isPublic = ['/', '/login', '/estimate'].includes(location.pathname)
|| location.pathname.startsWith('/ads/');
if (isPublic) {
return (
@@ -158,6 +159,7 @@ const Layout = () => {
{ 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" },
];
case 'ADMIN':
return [
@@ -180,6 +182,7 @@ const Layout = () => {
{ 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" },
];
case 'CONTRACTOR':
case 'SUBCONTRACTOR':
@@ -0,0 +1,231 @@
/**
* 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 } from 'react-router-dom';
import {
ShieldCheck, CheckCircle2, ArrowRight, 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 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>
</div>
</div>
);
}
return (
<div className="min-h-[100dvh] w-full bg-zinc-50 dark:bg-[#09090b]">
{/* 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,434 @@
/**
* 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,
conversionEvents as seedConversions,
approvals as seedApprovals,
analyticsDaily as seedAnalytics,
} from './data/seed.js';
import { getPublicLeads, subscribeToPublicLeads } from './data/sessionStore.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 [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' };
}));
}, []);
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));
}, []);
// ── 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 ─────────────────────────────────────────────────────────────
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 ✓');
}, []);
const value = useMemo(() => ({
accounts, campaigns, creatives, mediaAssets, leadForms, leads, nurtureRules,
audiences, conversionEvents, approvals, analyticsDaily,
toggleAccount, testWebhook,
createCampaign, setCampaignStatus, publishCampaign, completeManualSetup,
addCreative, setCreativeApproval, updateCreative, revertCreative, addMediaAsset,
updateLeadForm, updateFormField, addFormField, removeFormField, toggleFormStatus, addLeadForm,
assignLead, mergeLead, setLeadStatus, advanceLead,
toggleRule, updateRule, addRule, removeRule, moveRule, addRuleFromSuggestion,
refreshAudience, resolveApproval, retryConversion,
}), [
accounts, campaigns, creatives, mediaAssets, leadForms, leads, nurtureRules,
audiences, conversionEvents, approvals, analyticsDaily,
toggleAccount, testWebhook, createCampaign, setCampaignStatus, publishCampaign, completeManualSetup,
addCreative, setCreativeApproval, updateCreative, revertCreative, addMediaAsset,
updateLeadForm, updateFormField, addFormField, removeFormField, toggleFormStatus, addLeadForm,
assignLead, mergeLead, setLeadStatus, advanceLead,
toggleRule, updateRule, addRule, removeRule, moveRule, addRuleFromSuggestion,
refreshAudience, resolveApproval, retryConversion,
]);
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,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,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,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');
}
+416
View File
@@ -0,0 +1,416 @@
/**
* 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' },
];
// ── 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,139 @@
/**
* 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) {
const buckets = {};
for (const r of rows) {
const k = r[key];
(buckets[k] ||= []).push(r);
}
return Object.entries(buckets).map(([k, rs]) => ({ key: k, ...metrics(totals(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,183 @@
/**
* 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 } from 'react';
import {
BarChart3, DollarSign, Users, CalendarCheck, TrendingUp, Percent, Eye, Trophy,
Zap, FileText, Crown, Activity,
} 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, timeSeries, liveMetrics } 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`;
export default function AttributionDashboard() {
const { analyticsDaily, campaigns, leads } = useSocialAds();
const [view, setView] = useState('platform'); // platform | campaign
// Spend/ROAS = platform-reported; leadwon + rates = live CRM events (spec §6, §10).
const reported = useMemo(() => metrics(totals(analyticsDaily)), [analyticsDaily]);
const live = useMemo(() => liveMetrics(leads), [leads]);
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(() => metricsBy(analyticsDaily, view === 'platform' ? 'platform' : 'campaignId'), [analyticsDaily, view]);
const revenueSeries = useMemo(() => {
// Merge revenue + spend per date for a dual-line chart.
const rev = timeSeries(analyticsDaily, 'revenue');
const spend = timeSeries(analyticsDaily, 'spend');
return rev.map((r, i) => ({ date: r.date, revenue: r.value, spend: spend[i]?.value || 0 }));
}, [analyticsDaily]);
const campName = (id) => campaigns.find(c => c.id === id)?.name || id;
// 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 rounded-lg overflow-hidden border border-zinc-200 dark:border-white/10">
{['platform', 'campaign'].map(v => (
<button key={v} onClick={() => setView(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 */}
<div className="flex items-center gap-2 text-[11px] text-emerald-600 dark:text-emerald-400 -mb-2">
<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>
{/* 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}
tickFormatter={(k) => view === 'platform' ? (getPlatform(k)?.short || k) : campName(k).slice(0, 8)} />
<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={(k) => view === 'platform' ? getPlatform(k)?.name : campName(k)} />
<Bar dataKey="cpl" radius={[6, 6, 0, 0]} barSize={36}>
{bySource.map((s, i) => <Cell key={i} fill={view === 'platform' ? (getPlatform(s.key)?.color || '#3b82f6') : '#3b82f6'} />)}
</Bar>
</BarChart>
</ResponsiveContainer>
</Card>
{/* ROAS leaderboard */}
<Card title="ROAS leaderboard" subtitle="Ranked by return — not lead count" icon={Trophy}>
<div className="space-y-2">
{ranked.map((s, i) => (
<div key={s.key} className="flex items-center gap-3 px-3 py-2 rounded-lg bg-zinc-50 dark:bg-white/5">
<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">{campName(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>
{/* Detail table */}
<Card title={`Performance by ${view}`} 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} className="border-b border-zinc-50 dark:border-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">{campName(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>
);
}
@@ -0,0 +1,88 @@
/**
* AudienceSegments reusable audiences with consent count, suppression count,
* export/sync status, and refresh (spec §4, §8). Exports/syncs only permitted,
* consented, suppression-filtered contacts the count math makes that visible.
*/
import React from 'react';
import { Users, RefreshCw, Download, Upload, ShieldCheck, ShieldX, UserCheck } from 'lucide-react';
import { useSocialAds } from '../SocialAdsContext.jsx';
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',
};
export default function AudienceSegments() {
const { audiences, refreshAudience } = useSocialAds();
const exportCsv = (a) => toast.success(`Exported ${a.consentCount.toLocaleString()} consented contacts from "${a.name}" to CSV`);
const syncAudience = (a) => toast.promise(new Promise(r => setTimeout(r, 800)), {
loading: `Syncing "${a.name}" to platform…`,
success: `Synced ${a.consentCount.toLocaleString()} contacts (suppression list applied)`,
error: 'Sync failed',
});
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." />
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{audiences.map(a => {
const eligiblePct = Math.round((a.consentCount / a.totalCount) * 100);
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 */}
<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</span>
<span className="font-mono font-semibold">{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 flex-wrap items-center justify-between gap-2 pt-3 border-t border-zinc-100 dark:border-white/5">
<span className="text-[10px] text-zinc-400">Updated {a.lastCount}</span>
<div className="flex items-center gap-1.5 ml-auto">
<Btn size="sm" variant="ghost" icon={RefreshCw} onClick={() => refreshAudience(a.id)}>Refresh</Btn>
<Btn size="sm" variant="outline" icon={Download} onClick={() => exportCsv(a)}>CSV</Btn>
<Btn size="sm" variant="primary" icon={Upload} onClick={() => syncAudience(a)}>Sync</Btn>
</div>
</div>
</Card>
);
})}
</div>
</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 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>
);
}
@@ -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,132 @@
/**
* 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 from 'react';
import {
ShieldCheck, AlertTriangle, Ban, CheckCircle2, Clock, RefreshCw,
FileWarning, Wand2, ArrowUpRight,
} from 'lucide-react';
import { useSocialAds } from '../SocialAdsContext.jsx';
import { PlatformBadge, Card, SectionHeader, StatusChip, Btn } from '../components/ui.jsx';
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 } = 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');
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>
<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>
{/* Recently resolved + AI audit */}
<Card title="AI audit & recent approvals" icon={Wand2} subtitle="Human corrections and resolved items.">
<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" />
<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 className="flex items-start gap-2 px-2.5 py-2 rounded-lg bg-purple-50 dark:bg-purple-500/10 mt-2">
<Wand2 size={14} className="text-purple-500 mt-0.5" />
<p className="text-[11px] text-zinc-600 dark:text-zinc-300">
AI guardrail summary: <b>2</b> creatives auto-flagged this week (1 scarcity, 1 price claim), <b>0</b> auto-published. All AI copy required human approval.
</p>
</div>
</div>
</Card>
</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,120 @@
/**
* 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 from 'react';
import { Plug, RefreshCw, Webhook, KeyRound, Clock, AlertTriangle, CheckCircle2 } from 'lucide-react';
import { useSocialAds } from '../SocialAdsContext.jsx';
import { PLATFORMS, CAPABILITY } from '../data/platforms.js';
import { Card, StatusChip, CapabilityBadge, SectionHeader, Btn } from '../components/ui.jsx';
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' });
}
export default function ConnectedAccounts() {
const { accounts, toggleAccount, testWebhook } = useSocialAds();
const accountByPlatform = Object.fromEntries(accounts.map(a => [a.platform, a]));
const connectedCount = accounts.filter(a => a.status === 'connected').length;
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>
<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>
{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>
</div>
</Card>
);
})}
</div>
</div>
);
}
@@ -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>
);
}
@@ -0,0 +1,180 @@
/**
* MarketingHubHome KPIs, campaign status, leads by source, SLA breaches,
* recommended actions, and the source-to-close funnel (spec §8).
*/
import React, { useMemo } from 'react';
import {
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell,
AreaChart, Area, CartesianGrid,
} from 'recharts';
import {
DollarSign, Users, CalendarCheck, TrendingUp, AlertTriangle,
Megaphone, Zap, ArrowRight, Inbox, ClipboardCheck,
} from 'lucide-react';
import { useSocialAds } from '../SocialAdsContext.jsx';
import { totals, metrics, metricsBy, timeSeries, liveMetrics, liveFunnel } from '../engine/attribution.js';
import { countBreaches } from '../data/sla.js';
import { Card, KpiTile, PlatformBadge, StatusChip, SectionHeader, Btn } from '../components/ui.jsx';
const money = (n) => `$${Math.round(n).toLocaleString('en-US')}`;
const pct = (n) => `${Math.round(n * 100)}%`;
const FUNNEL_COLORS = ['#3b82f6', '#8b5cf6', '#06b6d4', '#f59e0b', '#10b981'];
export default function MarketingHubHome({ onNavigate }) {
const { analyticsDaily, campaigns, leads, approvals, accounts } = useSocialAds();
// Spend/ROAS reporting comes from the platform-reported series; leadwon metrics
// come live from CRM events (spec §6: analytics based on events, not impressions).
const reported = useMemo(() => metrics(totals(analyticsDaily)), [analyticsDaily]);
const live = useMemo(() => liveMetrics(leads), [leads]);
const m = useMemo(() => ({
spend: reported.spend,
leads: live.leads,
appointments: live.appointments,
revenue: live.revenue,
cpl: live.leads ? reported.spend / live.leads : 0,
roas: reported.spend ? live.revenue / reported.spend : 0,
showRate: live.showRate,
closeRate: live.closeRate,
}), [reported, live]);
const bySource = useMemo(() => metricsBy(analyticsDaily, 'platform'), [analyticsDaily]);
const funnelData = useMemo(() => liveFunnel(leads), [leads]);
const spendSeries = useMemo(() => timeSeries(analyticsDaily, 'spend'), [analyticsDaily]);
const newLeads = leads.filter(l => l.status === 'new');
const slaBreaches = countBreaches(leads);
const pendingApprovals = approvals.filter(a => a.status === 'pending').length;
const blockedItems = approvals.filter(a => a.status === 'blocked').length;
const tokenAlerts = accounts.filter(a => a.tokenStatus === 'expiring' || a.tokenStatus === 'revoked');
const recommendations = [
slaBreaches > 0 && { icon: Zap, color: 'text-red-500', text: `${slaBreaches} lead(s) past the speed-to-lead SLA — contact now before they go cold.`, to: 'inbox' },
blockedItems > 0 && { icon: AlertTriangle, color: 'text-red-500', text: `${blockedItems} creative blocked by compliance — resolve before publish.`, to: 'compliance' },
tokenAlerts.length > 0 && { icon: AlertTriangle, color: 'text-amber-500', text: `${tokenAlerts.length} account token(s) expiring or revoked — reconnect to avoid lost leads.`, to: 'accounts' },
pendingApprovals > 0 && { icon: ClipboardCheck, color: 'text-amber-500', text: `${pendingApprovals} item(s) pending approval in the Compliance Center.`, to: 'compliance' },
].filter(Boolean);
return (
<div className="space-y-6">
{/* KPI row */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4">
<KpiTile label="Ad Spend (7d)" value={money(m.spend)} icon={DollarSign} color="amber" sub={`ROAS ${m.roas.toFixed(1)}×`} />
<KpiTile label="Leads" value={m.leads} icon={Users} color="blue" sub={`CPL ${money(m.cpl)}`} />
<KpiTile label="Appointments" value={m.appointments} icon={CalendarCheck} color="purple" sub={`Show rate ${pct(m.showRate)}`} />
<KpiTile label="Revenue (won)" value={money(m.revenue)} icon={TrendingUp} color="emerald" sub={`Close rate ${pct(m.closeRate)}`} />
</div>
{/* Recommended actions */}
{recommendations.length > 0 && (
<Card title="Recommended actions" icon={Zap} subtitle="AI surfaces what needs attention — every action routes to a human review.">
<div className="space-y-2">
{recommendations.map((r, i) => {
const Icon = r.icon;
return (
<button
key={i}
onClick={() => onNavigate?.(r.to)}
className="w-full flex items-center gap-3 text-left px-3 py-2.5 rounded-xl bg-zinc-50 dark:bg-white/5 hover:bg-zinc-100 dark:hover:bg-white/10 transition-colors group"
>
<Icon size={18} className={`${r.color} shrink-0`} />
<span className="text-sm text-zinc-700 dark:text-zinc-200 flex-1">{r.text}</span>
<ArrowRight size={16} className="text-zinc-400 group-hover:translate-x-0.5 transition-transform" />
</button>
);
})}
</div>
</Card>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
{/* Spend trend */}
<Card title="Spend trend" subtitle="Daily ad spend across sources" icon={DollarSign} className="lg:col-span-2">
<ResponsiveContainer width="100%" height={220}>
<AreaChart data={spendSeries} margin={{ top: 5, right: 10, left: -10, bottom: 0 }}>
<defs>
<linearGradient id="spendGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#f59e0b" stopOpacity={0.4} />
<stop offset="100%" stopColor="#f59e0b" stopOpacity={0} />
</linearGradient>
</defs>
<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) => [money(v), 'Spend']} />
<Area type="monotone" dataKey="value" stroke="#f59e0b" strokeWidth={2} fill="url(#spendGrad)" />
</AreaChart>
</ResponsiveContainer>
</Card>
{/* Source-to-close funnel */}
<Card title="Source-to-close funnel" subtitle="Leads → won jobs" icon={TrendingUp}>
<ResponsiveContainer width="100%" height={220}>
<BarChart layout="vertical" data={funnelData} margin={{ top: 0, right: 24, left: 10, bottom: 0 }}>
<XAxis type="number" hide />
<YAxis type="category" dataKey="stage" width={90} tick={{ fill: '#71717a', fontSize: 11, fontWeight: 600 }} axisLine={false} tickLine={false} />
<Tooltip cursor={{ fill: 'transparent' }} contentStyle={{ background: 'rgba(24,24,27,0.95)', border: '1px solid #3f3f46', borderRadius: 12, fontSize: 12 }} />
<Bar dataKey="value" radius={[0, 6, 6, 0]} barSize={26} label={{ position: 'right', fill: '#a1a1aa', fontSize: 11 }}>
{funnelData.map((_, i) => <Cell key={i} fill={FUNNEL_COLORS[i]} />)}
</Bar>
</BarChart>
</ResponsiveContainer>
</Card>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Leads by source */}
<Card title="Leads by source" subtitle="Volume & cost per lead" icon={Users}>
<div className="space-y-2">
{bySource.sort((a, b) => b.leads - a.leads).map(s => (
<div key={s.key} className="flex items-center justify-between px-3 py-2 rounded-lg bg-zinc-50 dark:bg-white/5">
<PlatformBadge platformId={s.key} />
<div className="flex items-center gap-4 text-xs">
<span className="text-zinc-500 dark:text-zinc-400">{s.leads} leads</span>
<span className="font-mono font-semibold text-zinc-700 dark:text-zinc-200 w-16 text-right">{money(s.cpl)} CPL</span>
</div>
</div>
))}
</div>
</Card>
{/* Active campaigns */}
<Card title="Campaign status" subtitle={`${campaigns.filter(c => c.status === 'active').length} active`} icon={Megaphone}
action={<Btn size="sm" variant="ghost" icon={ArrowRight} onClick={() => onNavigate?.('campaigns')}>All</Btn>}>
<div className="space-y-2">
{campaigns.slice(0, 5).map(c => (
<div key={c.id} className="flex items-center justify-between px-3 py-2 rounded-lg bg-zinc-50 dark:bg-white/5 gap-2">
<div className="flex items-center gap-2 min-w-0">
<PlatformBadge platformId={c.platform} showName={false} />
<span className="text-sm text-zinc-700 dark:text-zinc-200 truncate">{c.name}</span>
</div>
<StatusChip status={c.status} />
</div>
))}
</div>
</Card>
</div>
{/* New leads quick peek */}
<Card title="New leads awaiting action" icon={Inbox} subtitle={`${newLeads.length} unassigned`}
action={<Btn size="sm" variant="primary" icon={ArrowRight} onClick={() => onNavigate?.('inbox')}>Open inbox</Btn>}>
{newLeads.length === 0 ? (
<p className="text-sm text-zinc-500 py-4 text-center">No new leads you're all caught up.</p>
) : (
<div className="space-y-2">
{newLeads.map(l => (
<div key={l.id} className="flex items-center justify-between px-3 py-2 rounded-lg bg-zinc-50 dark:bg-white/5 gap-2">
<div className="flex items-center gap-2 min-w-0">
<PlatformBadge platformId={l.platform} showName={false} />
<span className="text-sm font-medium text-zinc-800 dark:text-zinc-100 truncate">{l.name}</span>
{l.triage.urgency === 'high' && <StatusChip status="blocked" label="Urgent" />}
</div>
<span className="text-xs text-zinc-400">{l.triage.serviceType}</span>
</div>
))}
</div>
)}
</Card>
</div>
);
}
@@ -0,0 +1,266 @@
/**
* UnifiedLeadInbox all inbound ad leads by source with AI triage, duplicate
* warnings, consent proof, appointment status, and one-click assign/schedule
* (spec §4, §8). No lead remains unassigned; SLA breaches surface as urgent.
*/
import React, { useState, useMemo, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Inbox, Sparkles, AlertTriangle, Copy, ShieldCheck, ShieldAlert,
UserPlus, Merge, Phone, Mail, MapPin, Filter, CalendarCheck, Eye,
FileText, Trophy, CheckCircle2, Timer, AlarmClock, ExternalLink,
} from 'lucide-react';
import { useSocialAds } from '../SocialAdsContext.jsx';
import { PLATFORMS } from '../data/platforms.js';
import { LIFECYCLE } from '../engine/attribution.js';
import { slaState, fmtClock, countBreaches } from '../data/sla.js';
import { Card, SectionHeader, PlatformBadge, StatusChip, Btn } from '../components/ui.jsx';
import { toast } from 'sonner';
const REPS = [
{ id: 'rep_jordan', name: 'Jordan (Field)' },
{ id: 'rep_sam', name: 'Sam (Inside sales)' },
{ id: 'rep_dispatch', name: 'On-call dispatch' },
];
// Next lifecycle step button label + icon (spec §5 closed loop).
const NEXT_STEP = {
lead: { to: 'appointment', label: 'Schedule appt', icon: CalendarCheck },
appointment: { to: 'showed', label: 'Mark showed', icon: Eye },
showed: { to: 'estimate', label: 'Send estimate', icon: FileText },
estimate: { to: 'won', label: 'Mark won', icon: Trophy },
};
// Demo default deal size by service type (used when sending an estimate / winning).
const DEFAULT_VALUE = { commercial: 85000, replacement: 28000, storm: 24000, emergency: 12000, maintenance: 6500, referral: 0 };
const STAGE_LABEL = { lead: 'Lead', appointment: 'Appointment', showed: 'Showed', estimate: 'Estimate', won: 'Won', lost: 'Lost' };
const URGENCY = {
high: 'text-red-500 bg-red-500/10',
medium: 'text-amber-500 bg-amber-500/10',
low: 'text-zinc-500 bg-zinc-500/10',
};
function QualityBar({ value }) {
const color = value >= 75 ? 'bg-emerald-500' : value >= 50 ? 'bg-amber-500' : 'bg-red-500';
return (
<div className="flex items-center gap-1.5">
<div className="w-14 h-1.5 rounded-full bg-zinc-200 dark:bg-zinc-700 overflow-hidden">
<div className={`h-full ${color}`} style={{ width: `${value}%` }} />
</div>
<span className="text-[10px] font-mono text-zinc-500">{value}</span>
</div>
);
}
export default function UnifiedLeadInbox() {
const { leads, assignLead, mergeLead, advanceLead } = useSocialAds();
const navigate = useNavigate();
const [filter, setFilter] = useState('all');
const [platformFilter, setPlatformFilter] = useState('all');
// Ticking clock drives the live speed-to-lead SLA countdowns (spec §10).
const [nowMs, setNowMs] = useState(() => Date.now());
useEffect(() => {
const id = setInterval(() => setNowMs(Date.now()), 1000);
return () => clearInterval(id);
}, []);
const visible = useMemo(() => leads.filter(l => {
if (filter === 'new' && l.status !== 'new') return false;
if (filter === 'duplicates' && !l.duplicateOf) return false;
if (filter === 'urgent' && l.triage.urgency !== 'high') return false;
if (filter === 'breached' && !slaState(l, nowMs).breached) return false;
if (platformFilter !== 'all' && l.platform !== platformFilter) return false;
return true;
}), [leads, filter, platformFilter, nowMs]);
const breaches = countBreaches(leads, nowMs);
const counts = {
all: leads.length,
new: leads.filter(l => l.status === 'new').length,
urgent: leads.filter(l => l.triage.urgency === 'high').length,
duplicates: leads.filter(l => l.duplicateOf).length,
breached: breaches,
};
return (
<div>
<SectionHeader icon={Inbox} title="Unified Lead Inbox" description="Every ad lead across sources, with AI triage, consent proof, and one-click routing into a nurture playbook.">
<Btn size="sm" variant="outline" icon={ExternalLink} onClick={() => navigate('/ads/lp')}>Preview public form</Btn>
</SectionHeader>
{/* Speed-to-lead SLA breach banner (spec §11 "Lost leads": breach creates task/alert) */}
{breaches > 0 && (
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2 mb-4 rounded-xl border border-red-200 dark:border-red-500/30 bg-red-50 dark:bg-red-500/10 px-4 py-3">
<div className="flex items-center gap-2 text-sm text-red-700 dark:text-red-400">
<AlarmClock size={18} className="animate-pulse shrink-0" />
<span><b>{breaches}</b> lead{breaches > 1 ? 's' : ''} past the speed-to-lead SLA contact now before they go cold.</span>
</div>
<Btn size="sm" variant="danger" icon={AlertTriangle}
onClick={() => { setFilter('breached'); toast.error(`${breaches} SLA-breach task${breaches > 1 ? 's' : ''} created for managers`); }}>
Create {breaches} task{breaches > 1 ? 's' : ''}
</Btn>
</div>
)}
{/* Filters */}
<div className="flex flex-wrap items-center gap-2 mb-4">
{[['all', 'All'], ['new', 'New'], ['urgent', 'Urgent'], ['breached', 'SLA breach'], ['duplicates', 'Duplicates']].map(([id, label]) => (
<button key={id} onClick={() => setFilter(id)}
className={`px-3 py-1.5 rounded-lg text-xs font-semibold transition-colors ${filter === 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>
))}
<span className="w-px h-5 bg-zinc-200 dark:bg-white/10 mx-1" />
<Filter size={14} className="text-zinc-400" />
<select value={platformFilter} onChange={e => setPlatformFilter(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>
<div className="space-y-3">
{visible.length === 0 && <p className="text-sm text-zinc-500 text-center py-8">No leads match this filter.</p>}
{visible.map(l => {
const assignedRep = REPS.find(r => r.id === l.assignedTo);
const sla = slaState(l, nowMs);
return (
<Card key={l.id} className={`!h-auto ${l.status === 'merged' ? 'opacity-50' : ''} ${sla.breached ? 'ring-1 ring-red-400/50' : ''}`} pad="p-4">
<div className="flex flex-col lg:flex-row lg:items-center gap-4">
{/* Identity */}
<div className="flex items-start gap-3 min-w-0 lg:w-64 shrink-0">
<PlatformBadge platformId={l.platform} showName={false} />
<div className="min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<h4 className="font-bold text-sm text-zinc-900 dark:text-white truncate">{l.name}</h4>
{l.duplicateOf && <span className="inline-flex items-center gap-0.5 text-[10px] font-bold text-amber-500"><Copy size={10} /> Dup</span>}
</div>
<div className="text-[11px] text-zinc-500 space-y-0.5 mt-0.5">
{l.phone && <p className="flex items-center gap-1 truncate"><Phone size={10} /> {l.phone}</p>}
{l.email && <p className="flex items-center gap-1 truncate"><Mail size={10} /> {l.email}</p>}
<p className="flex items-center gap-1 truncate"><MapPin size={10} /> {l.address}</p>
</div>
</div>
</div>
{/* AI triage */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 mb-1">
<Sparkles size={12} className="text-purple-500" />
<span className="text-[10px] font-bold uppercase tracking-wider text-purple-500">AI triage</span>
<span className="text-[10px] text-zinc-400">({Math.round(l.triage.confidence * 100)}% conf.)</span>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs font-mono px-1.5 py-0.5 rounded bg-zinc-100 dark:bg-white/10 text-zinc-700 dark:text-zinc-200">{l.triage.serviceType}</span>
<span className={`text-[10px] font-bold uppercase px-1.5 py-0.5 rounded ${URGENCY[l.triage.urgency]}`}>{l.triage.urgency}</span>
<span className="text-xs text-zinc-500">{l.triage.role.replace(/_/g, ' ')}</span>
<QualityBar value={l.triage.quality} />
</div>
{l.triage.confidence < 0.6 && (
<p className="text-[10px] text-amber-600 dark:text-amber-400/80 mt-1 flex items-center gap-1"><AlertTriangle size={10} /> Low confidence review before auto-nurture.</p>
)}
</div>
{/* Consent + lifecycle */}
<div className="flex flex-col items-start gap-1.5 lg:w-44 shrink-0">
{l.consent
? <span className="inline-flex items-center gap-1 text-[11px] font-semibold text-emerald-600 dark:text-emerald-400"><ShieldCheck size={13} /> Consent on file</span>
: <span className="inline-flex items-center gap-1 text-[11px] font-semibold text-red-500"><ShieldAlert size={13} /> No consent</span>}
{sla.eligible && <SlaTimer sla={sla} />}
<StageStepper stage={l.stage} />
{assignedRep && <span className="text-[11px] text-zinc-500"> {assignedRep.name}{l.firstContactSec != null && ` · ${fmtClock(l.firstContactSec)} to contact`}</span>}
</div>
{/* Actions */}
<div className="flex items-center gap-2 shrink-0">
{l.duplicateOf && l.status !== 'merged' && (
<Btn size="sm" variant="outline" icon={Merge} onClick={() => mergeLead(l.id)}>Merge</Btn>
)}
{l.status === 'new' && !l.duplicateOf && (
<AssignMenu onAssign={(rep) => assignLead(l.id, rep)} disabled={!l.consent} />
)}
{l.status !== 'new' && l.status !== 'merged' && !l.duplicateOf && (
l.stage === 'won'
? <span className="inline-flex items-center gap-1 text-xs font-bold text-emerald-600 dark:text-emerald-400"><CheckCircle2 size={14} /> ${l.dealValue.toLocaleString()}</span>
: NEXT_STEP[l.stage] && (() => {
const step = NEXT_STEP[l.stage];
const value = step.to === 'won' || step.to === 'estimate'
? (l.dealValue || DEFAULT_VALUE[l.triage.serviceType] || 20000) : 0;
const premium = step.to === 'won' && l.triage.quality >= 80;
return <Btn size="sm" variant="primary" icon={step.icon} onClick={() => advanceLead(l.id, step.to, { value, premium })}>{step.label}</Btn>;
})()
)}
</div>
</div>
{!l.consent && l.status === 'new' && (
<p className="text-[11px] text-amber-600 dark:text-amber-400/80 mt-2 flex items-center gap-1">
<ShieldAlert size={12} /> Missing consent proof restricted to manual/transactional follow-up only.
</p>
)}
</Card>
);
})}
</div>
</div>
);
}
function SlaTimer({ sla }) {
const ratio = Math.min(1, sla.elapsed / sla.target);
const danger = sla.breached || sla.remaining < sla.target * 0.25;
const color = sla.breached ? 'text-red-500' : danger ? 'text-amber-500' : 'text-emerald-500';
const bar = sla.breached ? 'bg-red-500' : danger ? 'bg-amber-500' : 'bg-emerald-500';
const Icon = sla.breached ? AlarmClock : Timer;
return (
<div className="w-full" title="Speed-to-lead SLA — time to first contact">
<div className={`flex items-center gap-1 text-[11px] font-semibold ${color}`}>
<Icon size={13} className={sla.breached ? 'animate-pulse' : ''} />
{sla.breached
? <span>SLA breach {fmtClock(sla.remaining)}</span>
: <span>{fmtClock(sla.remaining)} to SLA</span>}
</div>
<div className="w-full h-1 rounded-full bg-zinc-200 dark:bg-zinc-700 overflow-hidden mt-0.5">
<div className={`h-full ${bar} transition-all duration-1000`} style={{ width: `${ratio * 100}%` }} />
</div>
</div>
);
}
function StageStepper({ stage }) {
if (stage === 'lost') return <StatusChip status="merged" label="Lost" />;
const rank = LIFECYCLE.indexOf(stage);
return (
<div className="flex items-center gap-1" title={`Stage: ${STAGE_LABEL[stage]}`}>
{LIFECYCLE.map((s, i) => (
<React.Fragment key={s}>
<span className={`w-2 h-2 rounded-full ${i <= rank ? 'bg-emerald-500' : 'bg-zinc-300 dark:bg-zinc-700'}`} />
{i < LIFECYCLE.length - 1 && <span className={`w-2.5 h-px ${i < rank ? 'bg-emerald-500' : 'bg-zinc-300 dark:bg-zinc-700'}`} />}
</React.Fragment>
))}
<span className="text-[10px] font-semibold text-zinc-500 ml-1">{STAGE_LABEL[stage]}</span>
</div>
);
}
function AssignMenu({ onAssign, disabled }) {
const [open, setOpen] = useState(false);
return (
<div className="relative">
<Btn size="sm" variant="primary" icon={UserPlus} disabled={disabled} onClick={() => setOpen(o => !o)}>Assign</Btn>
{open && !disabled && (
<div className="absolute right-0 mt-1 w-44 rounded-xl border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 shadow-xl z-20 p-1">
{REPS.map(r => (
<button key={r.id} onClick={() => { onAssign(r.id); 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">
{r.name}
</button>
))}
</div>
)}
</div>
);
}