social ads engine module screens
This commit is contained in:
@@ -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]AoA6`/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=;_<W>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
@@ -47,6 +47,8 @@ import UserDetailsPage from './pages/UserDetailsPage';
|
|||||||
import LeadVerificationPage from './pages/LeadVerification/LeadVerificationPage';
|
import LeadVerificationPage from './pages/LeadVerification/LeadVerificationPage';
|
||||||
import CustomerEstimateWizard from './modules/estimateWizard/CustomerEstimateWizard';
|
import CustomerEstimateWizard from './modules/estimateWizard/CustomerEstimateWizard';
|
||||||
import EstimateWizardAdmin from './modules/estimateWizard/EstimateWizardAdmin';
|
import EstimateWizardAdmin from './modules/estimateWizard/EstimateWizardAdmin';
|
||||||
|
import SocialAdsEngine from './modules/socialAdsEngine/SocialAdsEngine';
|
||||||
|
import PublicAdLanding from './modules/socialAdsEngine/PublicAdLanding';
|
||||||
|
|
||||||
// ... (existing imports)
|
// ... (existing imports)
|
||||||
const ProtectedRoute = ({ children, allowedRoles }) => {
|
const ProtectedRoute = ({ children, allowedRoles }) => {
|
||||||
@@ -78,11 +80,19 @@ function App() {
|
|||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
{/* Public DIY Roofing Estimate Wizard — homeowner-facing, no auth, chrome-less */}
|
{/* Public DIY Roofing Estimate Wizard — homeowner-facing, no auth, chrome-less */}
|
||||||
<Route path="/estimate" element={<CustomerEstimateWizard />} />
|
<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={
|
<Route path="/estimate-wizard/admin" element={
|
||||||
<ProtectedRoute allowedRoles={['OWNER', 'ADMIN']}>
|
<ProtectedRoute allowedRoles={['OWNER', 'ADMIN']}>
|
||||||
<EstimateWizardAdmin />
|
<EstimateWizardAdmin />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
} />
|
} />
|
||||||
|
{/* Social Media + Advertisement Engine — self-contained admin module (Owner/Admin) */}
|
||||||
|
<Route path="/marketing" element={
|
||||||
|
<ProtectedRoute allowedRoles={['OWNER', 'ADMIN']}>
|
||||||
|
<SocialAdsEngine />
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
<Route
|
<Route
|
||||||
path="/chat-assistant"
|
path="/chat-assistant"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
LayoutDashboard, Map, Calendar, LogOut, User, Home, MessageSquare,
|
LayoutDashboard, Map, Calendar, LogOut, User, Home, MessageSquare,
|
||||||
ChevronLeft, ChevronRight, Sun, Moon, Trophy, Users, Briefcase,
|
ChevronLeft, ChevronRight, Sun, Moon, Trophy, Users, Briefcase,
|
||||||
FileText, Menu, X, Calculator, PlusCircle, ClipboardList, Zap, LayoutGrid, Settings, CloudLightning,
|
FileText, Menu, X, Calculator, PlusCircle, ClipboardList, Zap, LayoutGrid, Settings, CloudLightning,
|
||||||
HardHat, ShieldCheck, Sparkles
|
HardHat, ShieldCheck, Sparkles, Megaphone
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
import PageTransition from './PageTransition';
|
import PageTransition from './PageTransition';
|
||||||
@@ -112,7 +112,8 @@ const Layout = () => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Determine standard layout vs full screen for Public pages
|
// 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) {
|
if (isPublic) {
|
||||||
return (
|
return (
|
||||||
@@ -158,6 +159,7 @@ const Layout = () => {
|
|||||||
{ to: "/owner/settings", icon: Settings, label: "Org Settings" },
|
{ to: "/owner/settings", icon: Settings, label: "Org Settings" },
|
||||||
...commonItems,
|
...commonItems,
|
||||||
{ to: "/estimate-wizard/admin", icon: Settings, label: "Wizard Admin" },
|
{ to: "/estimate-wizard/admin", icon: Settings, label: "Wizard Admin" },
|
||||||
|
{ to: "/marketing", icon: Megaphone, label: "Ad Engine" },
|
||||||
];
|
];
|
||||||
case 'ADMIN':
|
case 'ADMIN':
|
||||||
return [
|
return [
|
||||||
@@ -180,6 +182,7 @@ const Layout = () => {
|
|||||||
{ to: "/admin/settings", icon: Settings, label: "Org Settings" },
|
{ to: "/admin/settings", icon: Settings, label: "Org Settings" },
|
||||||
...commonItems,
|
...commonItems,
|
||||||
{ to: "/estimate-wizard/admin", icon: Settings, label: "Wizard Admin" },
|
{ to: "/estimate-wizard/admin", icon: Settings, label: "Wizard Admin" },
|
||||||
|
{ to: "/marketing", icon: Megaphone, label: "Ad Engine" },
|
||||||
];
|
];
|
||||||
case 'CONTRACTOR':
|
case 'CONTRACTOR':
|
||||||
case 'SUBCONTRACTOR':
|
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 (8–11am)', 'Midday (11am–2pm)', 'Afternoon (2–5pm)', '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 & 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: ~2–5 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 2–4 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 form’s 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');
|
||||||
|
}
|
||||||
@@ -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];
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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; lead→won + 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 > ${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 2–4 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; lead→won 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user