From e53c38ce59f9efe44c1165a4710c5d8555b7374a Mon Sep 17 00:00:00 2001 From: Mayur Shinde Date: Fri, 12 Jun 2026 13:22:03 +0530 Subject: [PATCH] social ads engine module screens --- LynkedUpPro_Social_Ads_Engine_Build_Spec.pdf | 284 ++++++ src/App.jsx | 10 + src/components/Layout.jsx | 7 +- .../socialAdsEngine/PublicAdLanding.jsx | 231 +++++ .../socialAdsEngine/SocialAdsContext.jsx | 434 ++++++++++ .../socialAdsEngine/SocialAdsEngine.jsx | 116 +++ .../socialAdsEngine/ai/bannedClaims.js | 28 + .../socialAdsEngine/ai/briefGenerator.js | 104 +++ .../socialAdsEngine/ai/creativeGenerator.js | 105 +++ .../socialAdsEngine/ai/nurtureAdvisor.js | 43 + .../socialAdsEngine/ai/questionOptimizer.js | 63 ++ src/modules/socialAdsEngine/components/ui.jsx | 164 ++++ .../socialAdsEngine/data/blueprints.js | 113 +++ src/modules/socialAdsEngine/data/platforms.js | 115 +++ .../socialAdsEngine/data/publishChecklist.js | 98 +++ src/modules/socialAdsEngine/data/seed.js | 416 +++++++++ .../socialAdsEngine/data/sessionStore.js | 108 +++ src/modules/socialAdsEngine/data/sla.js | 69 ++ src/modules/socialAdsEngine/data/testLead.js | 68 ++ src/modules/socialAdsEngine/data/utm.js | 67 ++ .../socialAdsEngine/engine/attribution.js | 139 +++ .../screens/AdToNurtureRules.jsx | 191 +++++ .../screens/AttributionDashboard.jsx | 183 ++++ .../screens/AudienceSegments.jsx | 88 ++ .../screens/CampaignBuilder.jsx | 810 ++++++++++++++++++ .../screens/ComplianceCenter.jsx | 132 +++ .../screens/ConnectedAccounts.jsx | 120 +++ .../screens/CreativeStudio.jsx | 383 +++++++++ .../screens/LeadFormMapper.jsx | 273 ++++++ .../screens/MarketingHubHome.jsx | 180 ++++ .../screens/UnifiedLeadInbox.jsx | 266 ++++++ 31 files changed, 5406 insertions(+), 2 deletions(-) create mode 100644 LynkedUpPro_Social_Ads_Engine_Build_Spec.pdf create mode 100644 src/modules/socialAdsEngine/PublicAdLanding.jsx create mode 100644 src/modules/socialAdsEngine/SocialAdsContext.jsx create mode 100644 src/modules/socialAdsEngine/SocialAdsEngine.jsx create mode 100644 src/modules/socialAdsEngine/ai/bannedClaims.js create mode 100644 src/modules/socialAdsEngine/ai/briefGenerator.js create mode 100644 src/modules/socialAdsEngine/ai/creativeGenerator.js create mode 100644 src/modules/socialAdsEngine/ai/nurtureAdvisor.js create mode 100644 src/modules/socialAdsEngine/ai/questionOptimizer.js create mode 100644 src/modules/socialAdsEngine/components/ui.jsx create mode 100644 src/modules/socialAdsEngine/data/blueprints.js create mode 100644 src/modules/socialAdsEngine/data/platforms.js create mode 100644 src/modules/socialAdsEngine/data/publishChecklist.js create mode 100644 src/modules/socialAdsEngine/data/seed.js create mode 100644 src/modules/socialAdsEngine/data/sessionStore.js create mode 100644 src/modules/socialAdsEngine/data/sla.js create mode 100644 src/modules/socialAdsEngine/data/testLead.js create mode 100644 src/modules/socialAdsEngine/data/utm.js create mode 100644 src/modules/socialAdsEngine/engine/attribution.js create mode 100644 src/modules/socialAdsEngine/screens/AdToNurtureRules.jsx create mode 100644 src/modules/socialAdsEngine/screens/AttributionDashboard.jsx create mode 100644 src/modules/socialAdsEngine/screens/AudienceSegments.jsx create mode 100644 src/modules/socialAdsEngine/screens/CampaignBuilder.jsx create mode 100644 src/modules/socialAdsEngine/screens/ComplianceCenter.jsx create mode 100644 src/modules/socialAdsEngine/screens/ConnectedAccounts.jsx create mode 100644 src/modules/socialAdsEngine/screens/CreativeStudio.jsx create mode 100644 src/modules/socialAdsEngine/screens/LeadFormMapper.jsx create mode 100644 src/modules/socialAdsEngine/screens/MarketingHubHome.jsx create mode 100644 src/modules/socialAdsEngine/screens/UnifiedLeadInbox.jsx diff --git a/LynkedUpPro_Social_Ads_Engine_Build_Spec.pdf b/LynkedUpPro_Social_Ads_Engine_Build_Spec.pdf new file mode 100644 index 0000000..3f83463 --- /dev/null +++ b/LynkedUpPro_Social_Ads_Engine_Build_Spec.pdf @@ -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++:m@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#j3;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+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<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'UtFopagpQl%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(qjdmgBqAFI@id]%(p,ZJ*)g>to39?1eu$A!fIJ1fe`0onC#h`eLGs))Y&)nF5qH9\^u>K2N.QbH[_qboVX$I0'!P6_6NY`]`7C-AcLg[+P^"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%rI-eH\p:2SplEUsJ`Ln/6_@.CWYYB!nt3#q_*#4P:$O@;Qni%=$endstream +endobj +21 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 4402 +>> +stream +Gatm>gQL;L&q0LUi,LZ;-sh#?<,L01il"UFNakjCh-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>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\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_E`&K*M8)@1UTmA"Z%8N,FL/T`*Opkm%nfX8OGJLf-6`Jfb^_?Rp_i*b:%cN1keqD<*9GVZm]6#&L/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-%RaeO=.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=`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<.kp!A`nKU8T0Z+)90(9[d1O_Ju.3#bt<@^k[:Ke/"8tB5=i*a&[XtTQ;)T!IiQ$M'>S6+6i.BE^T,,:*6XX4.5!tXr2LdSME6.M92`0eEgH,>nOhDOKP'sD]A>Xof,Y4@Hhqedurm"i7I)Bi8>"_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&*tUsu^:]\D.L9N1f+=+mXiF:e,s;efC^irL[KN$'aXfM6`td3e=k`T'O^gd5.;C[&''+qP<g?\>qm/D#hpt%tN(2UD9$chip6[S8>PuGH!A@Fk2Ge*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+cY=MN*PuQ'FUtsN;\Q;a=2uZCDL04gcf#EmAj$*:.sLG1EBt!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%_hP*)r`OjIiSE*\7=mc,Fa.*R,bKm201n'@ukj#e)S'ThDnTZ\SWo\k.:?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'.)+aEVc:Y:]*Hm1I$@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)^/>]B7M00k.kWQ].N6)m%B)M(dNGQnk9'fZlCT,=_hNC^t)KOG^r1<,bj?[$`9&tE$Ce09"T+V.4tl-lY!$D(u=_("kBR?rX?HK_Cg,l[e!E/\<>MM4Xs`!`NM!<"5MHJR*K?'CNV(.LOVPW8MG[PS>E`8lC:pG>JL@`&A=*OA`F<$56]3O`RVYoin9?>63oQQfLd&1^*c?8>@D7W]X!HQ@+@M@OI41gRfK"CWg[>`)GDC$IT4q1F5s:>YfTc>dFgc?QKg+BSe"HG;i5`afj?i(,l+Ma2_[f?J]8GL+>5@L[L!FtnaH4W!k0o'!D(0Nm]Lkej8)m'BYZ'l=O8$;^2VK%p_Y?J@U21)/]]a%%p#/_/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^+\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(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\rZ)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$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`Ai-'6B^eo];])0%6ehS9rQB6&GN8"pR"8qJ(k)GudcFfA[ge`s2'n/NY,J'WTAOLd\6AhHJ+U=oQ5M0J/_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:hKi+\h48jO$c@H_#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`"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(Y1eS7CEio5/No<8h21e#?GNdGNum0E7]-H<6phthXIhqr;$c1`<0h3Wo+RtS;^r^ZSLAm)t?\n3b%cGAhIp0F5.)4#-)(O,O'k6mp]4-mUUDQ<>^Ddr;65B0hj-RV$%kgNkt(L>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@`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\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=mIWTk*);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#XsaJLLF_)/]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][+9g\_kf36?NL<.@FF]4ib!kOY@tmd^D2urt/Zoh"-1i^t6aMP6[t\/go:O:$#f1#=mHXT,N0FBN14\="=j8m0N&4`nlopE-l^BB-J._k1@l"*r@^%q&+L(/O8A]MH#bjG3nQPt9ZA6QcO(r&bks:_p^Q%6>%o+2K>2'778UV9kToU$hUSMa01-9su&NA1#Gdio/)?80JGin:DQHppD(Y'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\\p]X&7hii2&Opb1)d9hOH.l67Yq#T)F.71l?]Xo$TYs/;\@'CR.!ds0LgiY^F/if4mJEgI:.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,(/\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!2gDMpadF*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_JgjJn07tN,A/5>cAMef&I5a+P.Q-)CP7rSGR@K&,HMP]\BqjWZHKhsRrD7b6dCaoJ(lo)eabX&31g.[!e/=;T!ltr[rETCEjA>5A?F@LUi>[$E"nk.#nnAbTI)Mqp0=pHqrH4R%)_lC'N3*`b@?^%p.?/JtPY1iJ&DYW?t/,QJDWg^Ee\$dci8^?$BD@^:kZgR5T!AJA1%LdrWYbGa_[[]b7W8LIJgbGPBKCL5RY="F(EF3rFJ*Pkm\^VM);*_0`G37iSijW4%R*M_EJc]WqWH5>b\[[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.2MQmiToN`'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<<'&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(qknY^/-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!io5`@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%Loumd0Rcg*>8aWZUN8+4MYA-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.^C2kendstream +endobj +25 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 1719 +>> +stream +Gatm$5"ESPLincNnp091W$h]m>)p.;B4;!/iN$u);#]2)aH[#_T^X-#a[s3cEEBeBa]-&V;gqLdqfC&Uk3reA`llX:VkrakDLh@b*o7mSu+CKq*R:BbC"kX>G@s&=iFWBDO_04htXqB^8hUBCpr?0FgbcOYX_39-&%>i"oOc(\;+qk60'8!+H&qSkM#5/Cm/2S9gEMEWm*qBU-2l*_TNs(?X^odOWFH8Se=\2+U\SCdd((C-^h&$OesOOk5=p.5gf>bN39=(,@N7)EMQ0haQ?Q"rdnXXk,0)tQVuoF;l(C2H^mAOWl)&:[G:6baN9',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?*5oPcRm`NBleTq'cUZr'&rZ5Kj.@G?orcWQ4ikUkF6!L&f1]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/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@RPO.*!;'"@$/[(: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!bGXIqE$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*`j4s(!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-:?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$,@EF&jmLR(-fUj&=VL.nHTJVW;!_Z9K#eTLRXn,>+oOc#Ju?ajQd9apD(#$pTu1+SN@&gsWk=0UZtO\upc834.kg3pMSQhGgr<:Ui<'_S-2M(X7%>NN^ri/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-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*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`b0PmBXc5Zle2=2>+Z]B6kkI)*Ypm"k:PPbROIrbi^U,4KZMKA^C[X\q(I:.!3O_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+ROSce?:8P;WJFMJ-38%9T2ZIF!A_WTRht_UN)0APMB(M5+.i?Rkhna^]mXKE/F=3*X5Ho=iT`?eboo6P*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;k`p<(&P"r[a$A^kfk_[,_I?hgWtHZ%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_:^)\itcKVkc/4u>-"=>CED"rU!`tqRaVf4eI'eSDGo\nCcR7)?_p4Lcm);'_-?Sh6j]EkY'1SNg!$^&c(^?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-ZFEis^FiX"d-\6>4\!pA!]/jGld?p,j@D_1fYi`e"WS>VuZ[eL8c/GhBU0AC)jRf0Yo+c63LhkJP7!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"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"?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<*$,Tr75rbFZJ`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>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*:Ish:S.U^,?RjF*i])bf<4qAtS=Io/IR&2Io=VAY"u`u-$TD<1fcPq%d,c34-iOkeL.l71KZ?A6OgBqp(8%#;J"[rWBGaTCW(JGePN>8Z/"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[Ai6``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;endstream +endobj +29 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 4331 +>> +stream +Gb!#_>Beg[&q9SYQq)'rVK`b#SN]&oSb4$OF^o(]%$?XiRNbiH[%mTm3s1QejU5c[kin\/r"E;,nd=(LrC@h[hIHpV>]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'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&=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)$)=SoJ5Oaf_r/,=BW*U88I(@U5:f,LN0>F%LDm6tkeoA]b>?s47X["a-D=8[E>B,U(Z:0sX\=![k%cJ#nPZNQK$^k]r#28;$7)UXip>I+$,aDX+6%fEE7>h&kJb.qrMR+rnmS&@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=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?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,+j4CHjHD@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'[]fL`iMo+"!r'4,hE?:1CID4OCC*5:lU0@TgK<)9o?+g'C"q?Eqs61sb^S?BCVJ=kgY=?-Z:S$b:H-?U>`eu`49Pu_7#@.UaaktQXU2i`EPT/&Y77d`+NCeSsZL_G\cDRY2d+-(X1k;$+E3p"15(.O[=oMBpiYocmD/#="Eq_!jd3>hKst'=nU)t!e!J0.4OVYcOeNE5-Jp=AJ8qJ2.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'imNQQoZp7qQssW'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]_l1B$;U@IBp9Z[Oc.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<_eKZg7;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(-jjYCFW:Y32mX4,D@6R<@qaTq?C_PFoXFF##-XoqGj_GJ3$%4oX9OR-KBmkhlm*`0[HSJBl;l[Q?>^^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]kTpQhTGPrU^6OV,8QcI:goV*Y_3nQW9G-]OgM@srDZVk"rFC:OM%l:"VIEUIW8]nY3I\+MroRteD2X0q!p7`R3Z#,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'j0FioGQ.r.hGK/%c\El_V/g&ae"lWM'OI-[sb[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 +[] +% ReportLab generated PDF document -- digest (opensource) + +/Info 17 0 R +/Root 16 0 R +/Size 31 +>> +startxref +37864 +%%EOF diff --git a/src/App.jsx b/src/App.jsx index f24f9bf..66026c9 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -47,6 +47,8 @@ import UserDetailsPage from './pages/UserDetailsPage'; import LeadVerificationPage from './pages/LeadVerification/LeadVerificationPage'; import CustomerEstimateWizard from './modules/estimateWizard/CustomerEstimateWizard'; import EstimateWizardAdmin from './modules/estimateWizard/EstimateWizardAdmin'; +import SocialAdsEngine from './modules/socialAdsEngine/SocialAdsEngine'; +import PublicAdLanding from './modules/socialAdsEngine/PublicAdLanding'; // ... (existing imports) const ProtectedRoute = ({ children, allowedRoles }) => { @@ -78,11 +80,19 @@ function App() { } /> {/* Public DIY Roofing Estimate Wizard β€” homeowner-facing, no auth, chrome-less */} } /> + {/* Public ad lead-capture landing page β€” homeowner-facing, no auth, chrome-less */} + } /> } /> + {/* Social Media + Advertisement Engine β€” self-contained admin module (Owner/Admin) */} + + + + } /> { }, []); // Determine standard layout vs full screen for Public pages - const isPublic = ['/', '/login', '/estimate'].includes(location.pathname); + const isPublic = ['/', '/login', '/estimate'].includes(location.pathname) + || location.pathname.startsWith('/ads/'); if (isPublic) { return ( @@ -158,6 +159,7 @@ const Layout = () => { { to: "/owner/settings", icon: Settings, label: "Org Settings" }, ...commonItems, { to: "/estimate-wizard/admin", icon: Settings, label: "Wizard Admin" }, + { to: "/marketing", icon: Megaphone, label: "Ad Engine" }, ]; case 'ADMIN': return [ @@ -180,6 +182,7 @@ const Layout = () => { { to: "/admin/settings", icon: Settings, label: "Org Settings" }, ...commonItems, { to: "/estimate-wizard/admin", icon: Settings, label: "Wizard Admin" }, + { to: "/marketing", icon: Megaphone, label: "Ad Engine" }, ]; case 'CONTRACTOR': case 'SUBCONTRACTOR': diff --git a/src/modules/socialAdsEngine/PublicAdLanding.jsx b/src/modules/socialAdsEngine/PublicAdLanding.jsx new file mode 100644 index 0000000..ad5b684 --- /dev/null +++ b/src/modules/socialAdsEngine/PublicAdLanding.jsx @@ -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 ( +
+
+ +

+ Thanks{submitted.name ? `, ${submitted.name.split(' ')[0]}` : ''}! +

+

+ We received your request and our team has been notified. A rep will reach out shortly to confirm your free inspection window. +

+ + Need it sooner? Call (972) 555-0100 + +

+ Your information is kept private and used only to contact you about this request. +

+
+
+ ); + } + + return ( +
+ {/* Sponsored banner β€” simulates the ad context */} +
+ + Sponsored Β· {platform?.name} ad +
+ +
+ {/* Hero */} +
+
+
+ +
+ LynkedUpPro +
+

{headline}{/[.?!]$/.test(headline) ? '' : '.'}

+

{offerText} Β· Free, no-obligation.

+
+ + {/* Creative media (when previewing a creative with an attached image) */} + {heroImg && !imgErr && ( + setImgErr(true)} + className="w-full h-44 sm:h-56 object-cover rounded-2xl mb-6 shadow-lg" /> + )} + + {/* Form */} +
+
+ + set({ name: e.target.value })} placeholder="Jane Homeowner" /> + + + set({ phone: e.target.value })} placeholder="(972) 555-0100" /> + + + set({ email: e.target.value })} placeholder="you@example.com" /> + + + set({ address: e.target.value })} placeholder="123 Main St, Plano, TX" /> + +
+ + +
+ {SERVICE_OPTIONS.map(s => ( + + ))} +
+
+ +
+ + + + + + +
+ + {isEmergencyLike && ( + + )} + + {/* Consent */} + + + + +

+ Your info is used only to contact you about this request. +

+
+ +

+ Consent & privacy compliant Β· TCPA / CAN-SPAM aware +

+
+
+ ); +} + +function Field({ label, required, children }) { + return ( + + ); +} diff --git a/src/modules/socialAdsEngine/SocialAdsContext.jsx b/src/modules/socialAdsEngine/SocialAdsContext.jsx new file mode 100644 index 0000000..63911bb --- /dev/null +++ b/src/modules/socialAdsEngine/SocialAdsContext.jsx @@ -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 {children}; +} + +export function useSocialAds() { + const ctx = useContext(SocialAdsContext); + if (!ctx) throw new Error('useSocialAds must be used within SocialAdsProvider'); + return ctx; +} diff --git a/src/modules/socialAdsEngine/SocialAdsEngine.jsx b/src/modules/socialAdsEngine/SocialAdsEngine.jsx new file mode 100644 index 0000000..b2a5e76 --- /dev/null +++ b/src/modules/socialAdsEngine/SocialAdsEngine.jsx @@ -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 ( + +
+ {/* Header */} +
+
+
+
+ +
+
+

+ Social Ads Engine +

+

+ Closed-loop acquisition β€” campaign β†’ lead β†’ nurture β†’ job β†’ attribution +

+
+
+ + {/* Tab bar */} +
+ {TABS.map(t => { + const Icon = t.icon; + const isActive = t.id === active; + return ( + + ); + })} +
+
+
+ + {/* Active screen β€” `isolate` creates a stacking context so the cards' + internal z-indexes stay contained below the sticky header above. */} +
+ + + + + +
+
+
+ ); +} diff --git a/src/modules/socialAdsEngine/ai/bannedClaims.js b/src/modules/socialAdsEngine/ai/bannedClaims.js new file mode 100644 index 0000000..176e7a8 --- /dev/null +++ b/src/modules/socialAdsEngine/ai/bannedClaims.js @@ -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 }; +} diff --git a/src/modules/socialAdsEngine/ai/briefGenerator.js b/src/modules/socialAdsEngine/ai/briefGenerator.js new file mode 100644 index 0000000..a531b95 --- /dev/null +++ b/src/modules/socialAdsEngine/ai/briefGenerator.js @@ -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', +]; diff --git a/src/modules/socialAdsEngine/ai/creativeGenerator.js b/src/modules/socialAdsEngine/ai/creativeGenerator.js new file mode 100644 index 0000000..a299ee7 --- /dev/null +++ b/src/modules/socialAdsEngine/ai/creativeGenerator.js @@ -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), + }; + }); +} diff --git a/src/modules/socialAdsEngine/ai/nurtureAdvisor.js b/src/modules/socialAdsEngine/ai/nurtureAdvisor.js new file mode 100644 index 0000000..43458fb --- /dev/null +++ b/src/modules/socialAdsEngine/ai/nurtureAdvisor.js @@ -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] }; +} diff --git a/src/modules/socialAdsEngine/ai/questionOptimizer.js b/src/modules/socialAdsEngine/ai/questionOptimizer.js new file mode 100644 index 0000000..fa887be --- /dev/null +++ b/src/modules/socialAdsEngine/ai/questionOptimizer.js @@ -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; +} diff --git a/src/modules/socialAdsEngine/components/ui.jsx b/src/modules/socialAdsEngine/components/ui.jsx new file mode 100644 index 0000000..5badded --- /dev/null +++ b/src/modules/socialAdsEngine/components/ui.jsx @@ -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 ( + +
+ {(title || action) && ( +
+
+ {Icon && } +
+ {title &&

{title}

} + {subtitle &&

{subtitle}

} +
+
+ {action} +
+ )} + {children} +
+
+ ); +} + +// ── 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 ( + +
+
+

{label}

+

{value}

+ {sub &&

{sub}

} + {typeof trend === 'number' && ( + = 0 ? 'text-emerald-500' : 'text-red-500'}`}> + {trend >= 0 ? 'β–²' : 'β–Ό'} {Math.abs(trend)}% + + )} +
+ {Icon &&
} +
+
+ ); +} + +// ── 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 ( + + + + + {showName && {p.short}} + + ); +} + +// ── 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 ( + + {label || String(status).replace(/_/g, ' ')} + + ); +} + +// ── Capability badge (spec Β§11 β€” honest automation level) ────────────────────── +export function CapabilityBadge({ capability, active }) { + return ( + + {CAPABILITY_LABEL[capability] || capability} + + ); +} + +// ── Section header for screens ───────────────────────────────────────────────── +export function SectionHeader({ icon: Icon, title, description, children }) { + return ( +
+
+ {Icon && ( +
+ +
+ )} +
+

{title}

+ {description &&

{description}

} +
+
+ {children &&
{children}
} +
+ ); +} + +// ── 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 ( + + ); +} diff --git a/src/modules/socialAdsEngine/data/blueprints.js b/src/modules/socialAdsEngine/data/blueprints.js new file mode 100644 index 0000000..d9074e8 --- /dev/null +++ b/src/modules/socialAdsEngine/data/blueprints.js @@ -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])); diff --git a/src/modules/socialAdsEngine/data/platforms.js b/src/modules/socialAdsEngine/data/platforms.js new file mode 100644 index 0000000..c406532 --- /dev/null +++ b/src/modules/socialAdsEngine/data/platforms.js @@ -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); diff --git a/src/modules/socialAdsEngine/data/publishChecklist.js b/src/modules/socialAdsEngine/data/publishChecklist.js new file mode 100644 index 0000000..e5ee09a --- /dev/null +++ b/src/modules/socialAdsEngine/data/publishChecklist.js @@ -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'); +} diff --git a/src/modules/socialAdsEngine/data/seed.js b/src/modules/socialAdsEngine/data/seed.js new file mode 100644 index 0000000..8289925 --- /dev/null +++ b/src/modules/socialAdsEngine/data/seed.js @@ -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 }, +]; diff --git a/src/modules/socialAdsEngine/data/sessionStore.js b/src/modules/socialAdsEngine/data/sessionStore.js new file mode 100644 index 0000000..37797d1 --- /dev/null +++ b/src/modules/socialAdsEngine/data/sessionStore.js @@ -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]; +} diff --git a/src/modules/socialAdsEngine/data/sla.js b/src/modules/socialAdsEngine/data/sla.js new file mode 100644 index 0000000..ec5b713 --- /dev/null +++ b/src/modules/socialAdsEngine/data/sla.js @@ -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')}`; +} diff --git a/src/modules/socialAdsEngine/data/testLead.js b/src/modules/socialAdsEngine/data/testLead.js new file mode 100644 index 0000000..cb519d9 --- /dev/null +++ b/src/modules/socialAdsEngine/data/testLead.js @@ -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 }; +} diff --git a/src/modules/socialAdsEngine/data/utm.js b/src/modules/socialAdsEngine/data/utm.js new file mode 100644 index 0000000..ef94e19 --- /dev/null +++ b/src/modules/socialAdsEngine/data/utm.js @@ -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, + }, + }; +} diff --git a/src/modules/socialAdsEngine/engine/attribution.js b/src/modules/socialAdsEngine/engine/attribution.js new file mode 100644 index 0000000..daf6f46 --- /dev/null +++ b/src/modules/socialAdsEngine/engine/attribution.js @@ -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) })); +} diff --git a/src/modules/socialAdsEngine/screens/AdToNurtureRules.jsx b/src/modules/socialAdsEngine/screens/AdToNurtureRules.jsx new file mode 100644 index 0000000..40d6f59 --- /dev/null +++ b/src/modules/socialAdsEngine/screens/AdToNurtureRules.jsx @@ -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 ( +
+ + Add rule + + + {/* AI next-best-nurture (spec Β§6) */} +
+
+
+ +

Next best nurture β€” pick a sample lead; AI recommends a route. Deterministic rules override AI.

+
+ + + Suggest route +
+ + {advice && (advice.covered ? ( +
+ + Rule #{advice.rule.priority} ({advice.rule.then.playbook}) already routes this lead β€” AI deferred to your deterministic rule. +
+ ) : ( +
+
+ AI recommended path + Add as rule +
+

{advice.rationale}

+
+ + + + + +
+
+ ))} +
+ +
+ {sorted.map(rule => { + const isDefault = rule.priority === 99; + const idx = movable.findIndex(r => r.id === rule.id); + return ( + +
+ {/* Priority + reorder */} +
+ + {isDefault ? 'βˆ—' : rule.priority} + + {!isDefault && ( +
+ + +
+ )} +
+ + {/* WHEN */} +
+ When + {isDefault ? ( +

Any platform Β· any service (catch-all default)

+ ) : ( +
+ + + + +
+ )} +
+ + + + {/* THEN */} +
+ updateRule(rule.id, { then: { playbook: v } })} /> + updateRule(rule.id, { then: { rep: v } })} /> + updateRule(rule.id, { then: { speedToLead: v } })} /> + updateRule(rule.id, { then: { education: v } })} /> + updateRule(rule.id, { then: { scheduling: v } })} /> +
+ + {/* Controls */} +
+ + {!isDefault && ( + + )} +
+
+
+ ); + })} +
+ +

+ Rules evaluate top-down by priority. If no specific rule matches, the default inbound playbook runs and an admin is alerted. +

+
+ ); +} + +function PathBit({ label, value }) { + return ( +
+ {label} + {value} +
+ ); +} + +function ThenField({ icon: Icon, label, value, onChange, options }) { + return ( + + ); +} diff --git a/src/modules/socialAdsEngine/screens/AttributionDashboard.jsx b/src/modules/socialAdsEngine/screens/AttributionDashboard.jsx new file mode 100644 index 0000000..1cd6681 --- /dev/null +++ b/src/modules/socialAdsEngine/screens/AttributionDashboard.jsx @@ -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 ( +
+ +
+ {['platform', 'campaign'].map(v => ( + + ))} +
+
+ + {/* Live banner */} +
+ + Live + β€” lead-to-won metrics update from CRM events as you act in the Lead Inbox. Spend is platform-reported. +
+ + {/* KPI row */} +
+ + + + +
+
+ + + + +
+
+ + + + +
+ + {/* Revenue vs spend */} + + + + + + + [money(v), n === 'revenue' ? 'Revenue' : 'Spend']} /> + + + + + + + +
+ {/* CPL by source */} + + + + + view === 'platform' ? (getPlatform(k)?.short || k) : campName(k).slice(0, 8)} /> + + [money(v), 'CPL']} labelFormatter={(k) => view === 'platform' ? getPlatform(k)?.name : campName(k)} /> + + {bySource.map((s, i) => )} + + + + + + {/* ROAS leaderboard */} + +
+ {ranked.map((s, i) => ( +
+ {i + 1} + {view === 'platform' + ? + : {campName(s.key)}} +
+ {money(s.revenue)} + = 1 ? 'text-emerald-500' : 'text-red-500'}`}>{s.roas.toFixed(1)}Γ— +
+
+ ))} +
+
+
+ + {/* Detail table */} + +
+ + + + + + + + + + + + + + + + {bySource.map(s => ( + + + + + + + + + + + + ))} + +
{view}SpendLeadsCPLApptsShowCloseRevenueROAS
+ {view === 'platform' ? : {campName(s.key)}} + {money(s.spend)}{s.leads}{money(s.cpl)}{s.appointments}{pct(s.showRate)}{pct(s.closeRate)}{money(s.revenue)}= 1 ? 'text-emerald-500' : 'text-red-500'}`}>{s.roas.toFixed(1)}Γ—
+
+
+
+ ); +} diff --git a/src/modules/socialAdsEngine/screens/AudienceSegments.jsx b/src/modules/socialAdsEngine/screens/AudienceSegments.jsx new file mode 100644 index 0000000..5f4b256 --- /dev/null +++ b/src/modules/socialAdsEngine/screens/AudienceSegments.jsx @@ -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 ( +
+ + +
+ {audiences.map(a => { + const eligiblePct = Math.round((a.consentCount / a.totalCount) * 100); + return ( + +
+
+

{a.name}

+

{a.filters}

+
+ + {SEGMENT_LABEL[a.segmentType] || a.segmentType} + +
+ + {/* Count breakdown */} +
+ + + +
+ + {/* Eligible bar */} +
+
+ Eligible to sync + {eligiblePct}% +
+
+
+
+
+ +
+ Updated {a.lastCount} +
+ refreshAudience(a.id)}>Refresh + exportCsv(a)}>CSV + syncAudience(a)}>Sync +
+
+ + ); + })} +
+
+ ); +} + +function Stat({ icon: Icon, label, value, color }) { + return ( +
+ +

{value.toLocaleString()}

+

{label}

+
+ ); +} diff --git a/src/modules/socialAdsEngine/screens/CampaignBuilder.jsx b/src/modules/socialAdsEngine/screens/CampaignBuilder.jsx new file mode 100644 index 0000000..023cc4e --- /dev/null +++ b/src/modules/socialAdsEngine/screens/CampaignBuilder.jsx @@ -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 ( +
+ + {mode === 'list' && ( + <> + setMode('briefing')}>AI brief + { setSeedDraft(null); setMode('building'); }}>New campaign + + )} + + + {mode === 'briefing' && } + {mode === 'building' && } + {mode === 'list' && } + + {mode === 'list' && ( + <> +

+ Blueprint library +

+
+ {BLUEPRINTS.map(bp => ( + +
+

{bp.name}

+ {bp.objective.replace(/_/g, ' ')} +
+

{bp.adCopy}

+
+ {bp.bestChannels.map(c => )} +
+
+

{bp.nurture}

+ +
+
+ ))} +
+ + )} +
+ ); +} + +// ── 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 ( + +
+
+
+
+

AI Campaign Brief

+

Describe the campaign in plain language β€” AI drafts it from approved blueprints. You approve before anything is created.

+
+
+ +
+ +