Merge pull request 'Diy estimate presentation module' (#3) from diy-estimate-presentation-module into revamp
Reviewed-on: AITeam/LynkedUpPro_CRM_Demo_Old#3
This commit is contained in:
@@ -3,3 +3,10 @@ VITE_GROQ_API_KEY=Your-API-Key
|
||||
|
||||
# Weather Widget (OpenWeatherMap)
|
||||
VITE_OPENWEATHER_API_KEY=Your-API-Key
|
||||
|
||||
# --- Payments Module (Stripe Hosted Checkout) ---
|
||||
# Frontend (safe to expose). Until set, the Payments page shows a "configure" notice.
|
||||
VITE_STRIPE_PUBLISHABLE_KEY=pk_test_REPLACE_WITH_YOUR_PUBLISHABLE_KEY
|
||||
# Backend only (Vercel env / .env for `vercel dev`) — NEVER expose these to the client.
|
||||
STRIPE_SECRET_KEY=sk_test_REPLACE_WITH_YOUR_SECRET_KEY
|
||||
STRIPE_WEBHOOK_SECRET=whsec_REPLACE_WITH_YOUR_WEBHOOK_SECRET
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ dist-ssr
|
||||
|
||||
# Reference images / design workflows (local only, not for repo)
|
||||
Estimate_Section_Screens_and_Workflows/
|
||||
|
||||
LynkedUpPro_Customer_Estimate_Presentation_DIY_Wizard_Build_Spec_v1_0.pdf
|
||||
# Claude Code local config (session memory, not for repo)
|
||||
.claude/
|
||||
.vercel
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
%PDF-1.4
|
||||
%“Œ‹ž ReportLab Generated PDF document (opensource)
|
||||
1 0 obj
|
||||
<<
|
||||
/F1 2 0 R /F2 3 0 R
|
||||
>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
4 0 obj
|
||||
<<
|
||||
/Contents 19 0 R /MediaBox [ 0 0 612 792 ] /Parent 18 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
5 0 obj
|
||||
<<
|
||||
/Contents 20 0 R /MediaBox [ 0 0 612 792 ] /Parent 18 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
6 0 obj
|
||||
<<
|
||||
/Contents 21 0 R /MediaBox [ 0 0 612 792 ] /Parent 18 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
7 0 obj
|
||||
<<
|
||||
/Contents 22 0 R /MediaBox [ 0 0 612 792 ] /Parent 18 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
8 0 obj
|
||||
<<
|
||||
/Contents 23 0 R /MediaBox [ 0 0 612 792 ] /Parent 18 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
9 0 obj
|
||||
<<
|
||||
/Contents 24 0 R /MediaBox [ 0 0 612 792 ] /Parent 18 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
10 0 obj
|
||||
<<
|
||||
/Contents 25 0 R /MediaBox [ 0 0 612 792 ] /Parent 18 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
11 0 obj
|
||||
<<
|
||||
/Contents 26 0 R /MediaBox [ 0 0 612 792 ] /Parent 18 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
12 0 obj
|
||||
<<
|
||||
/Contents 27 0 R /MediaBox [ 0 0 612 792 ] /Parent 18 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
13 0 obj
|
||||
<<
|
||||
/Contents 28 0 R /MediaBox [ 0 0 612 792 ] /Parent 18 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
14 0 obj
|
||||
<<
|
||||
/Contents 29 0 R /MediaBox [ 0 0 612 792 ] /Parent 18 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
15 0 obj
|
||||
<<
|
||||
/Contents 30 0 R /MediaBox [ 0 0 612 792 ] /Parent 18 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
16 0 obj
|
||||
<<
|
||||
/PageMode /UseNone /Pages 18 0 R /Type /Catalog
|
||||
>>
|
||||
endobj
|
||||
17 0 obj
|
||||
<<
|
||||
/Author (\(anonymous\)) /CreationDate (D:20260609045909+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260609045909+00'00') /Producer (ReportLab PDF Library - \(opensource\))
|
||||
/Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False
|
||||
>>
|
||||
endobj
|
||||
18 0 obj
|
||||
<<
|
||||
/Count 12 /Kids [ 4 0 R 5 0 R 6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R
|
||||
14 0 R 15 0 R ] /Type /Pages
|
||||
>>
|
||||
endobj
|
||||
19 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1271
|
||||
>>
|
||||
stream
|
||||
Gatm:gN)%,&:N/3m%^"A0E?^6]IBEt@uoHe2:GV99gTI*MP(8RWrB^?OOPS^3pAJrTh&Q-HTB_A!!A*Ck5M[r^kW&14u$FbOsMeJ"=+NI0c@Zb=Kgsf&dSal,@Q*q7Sai++:<GJl\Kd/pI,KYYW/9f^;EKd%-l@K?*T$+O$o(G3o>m<hL4^ZZ\Wjr"fRXG0RhZ@hdm:)/""rb2b:YKnd_P[8f7!F>@EB`(`)e>L^P>aKn0O"*DnKP(Ld,F:`QtQM2*u7_RufXk?2FI/`9Lg.YZbgQ>^?j-mL[VCuAg6;Pf$WXX\@9Y%2^3QQAk+$^UYh)++Pc$Ad9,&:T:23uC2j7AH_[OZ#j<D]m:J9/fkN<ojGkl%W3eeoCh+e(!ZZD3RLe>3;bFD=k2OCF]XtW="u,C,Ne7ML[%IgS>usC7TAu,dGApgj\%n1[,WFE6!ca;0%VNKSp,4]U)gT%\NV1@;_PH0V?@@>E&S]^n75hV-CV6cOYkn;\TR`IbUpl+70!q@V/J2JhS1%q(Gki$QLB*VJtF%Ys]8`PQqG(c<"7<`hU:&7dOq48"u&a?HXh#Z/oq7hnu_t&t,jp?0IQ:#&`@*Y=jMIHqH>tfL&QGVE#uCB(B+<oG7Sr7t_pTj%C\6/CAKlj^">L+TI`F(A1_F95Cb#NIb>PAYCX+[qV6GHdb[H2YkddrTk#iCM$]kOudZ(,ps:&ia%eM&5L6aZ,>UnL11`n%PCU_U;A8YpmdP0_pf."l^%!/e62(`E62%F3klg'&-`ZH"-i=XTH;O4A\JO;0r>&IN_Pg&YR`E_ed?EF=mJ>in+67Fl<</UbR*'u36,sF[)K:B.l5#W9r1LX?4(E*e5`?-6qZ"&MNr(A207F7qYRSF4.OIB-G>hlmiYi4jU.7WjMt%P[+X)CG&R"nC"=2B3Sd$NfP!qpMLeYa\Fh@_l87Cu9G4kcD/!M]\?n\J[f5@uP2#d@[sdtjoQ`WIS3U)TD49j_UP&Gp32`)20ZD6kBVc*%mEs3pX(hlsJJYl(EE0ElgWVuP"rerOEf\PUIpjhd,FKQe@5Cl8)d1H)R&lpXB9Vsen"=Z7jQXqSojXQ3=3c%D3h`(9X"AKGXUtG?BuSp$@f0Ap[QuLeW'UtFo<lF[@aUaNMcjp5h0HSNkNTjTVkhJ%(QafQ5MGXb&dJj&!Cc^$=F(J#Y5^VEh@YVfZn:Q4(5fi$65s9R\GlCG+a&N"Ze92E\>pagpQl%WjndL-I@V5InSej;mq?a5XsD;.g[&R@_rr1o>]5N~>endstream
|
||||
endobj
|
||||
20 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 806
|
||||
>>
|
||||
stream
|
||||
Gatn$9lldX&;KZOMERi-=@#7Zkfllh9P2_k[7R8t"j9:k?PUHRq"7p$:XZ--ci_1EA\d0sdH]On$9he's3+)c=UJdL$C!if"j..,oGZkkpVWcH?\U`V*$Q/[5i?#=%Iq=qAfM)QOl-hSYob5"9^Z=KqPk)l>%2RX2!h!X0(qjdmgB<l(=)6gWlhal+T2f78#Q1eY<S8"rh`@la:f7u#tJMm\O6.Y0SQ=SA\B]B0Rlu'BD#TI\gpEfSd2&[/4Pb6"I>qAFI@id]%(p,ZJ*)g>to39?<ceP16_tdi_`eZA1=>1eu$A!fIJ1fe`0onC#h`eLGs))Y&)nF5qH9\^u>K2N.QbH[_qboVX$I0'!P6_6NY`]`7C-AcLg[+P<ET[13YOm5f`Dm5_^f!CDEA_<jhmo>^"P3N7>gC,/Rk1Va^eAUm3S2Wot%lb:?[R.N8GF`j'/Mi9hPSiGk*nJot[i)Y@Dc`pmGg*gC4\F^DmVA\'mn_Ya_4%\#36j-Yu*kQG[\^@,;1Sg$g+)lp%;[)]_r_CY@Q?`?C`Co#pEc#[$sFu&B*YsG%r<f`k\J^/AfZI8..OK+"`)U=O2N`R[<e_BK"I;jVp6L@cM2^JqJR)s/GeG0(KO2l5k`K%N-Ku0R,Sb:@af6(^]Ke;0\,ooAnk@`l)6r7-B+2o4#QQ?IkdAqe=7u5EL6](Ms*,'h'^tLl1m)Up"Y616sd:!P*A,7p_(c%cQp2C;]-kU>I-eH\p:2SplEUsJ`Ln/6_@.CWYYB!nt3#q_*#4P:$O@;Qni%=$<bnK7%4(Z1o*5%ok@Jo3~>endstream
|
||||
endobj
|
||||
21 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 4402
|
||||
>>
|
||||
stream
|
||||
Gatm>gQL;L&q0LUi,LZ;-sh#?<,L01il"UFNakjC<Vlu_O9?(_-j2Q9o(@@@_:so=PdOQ&&n)%YQbJtRh*<*1r!W<"nQ(I4G@L2;+G*GFX[Fl:`o?EgG]ic?mh\:_k/@"k@ke?90:Ll6oQ^@G4qG]N$\I&R4N$XCNu"G1f.Sg_d**&X].HJ_753]Jk;;m4i/(NB<*U486&AD!Ft<1%^Vnr@EVBQ0r!33aSU<I+]Lb83&5L4QbS`%Q=LAs(Bbb"$4`uqn@d_Kdp:q&]rk"/u@ThtQ&U(']Of4PjPSI)D19m.EGc)0+`K0"*Gu/d[XkKeV&?iAs?5"iQMmnK.?7"UWSbS9c;/Bbd@Zt@Q0!*\sSHtW0_X'(k'5o-W;C`BNUH`U=3qPQW6Cc?>h-O@NXIt+e%nNJj<1&%`hNrLbk,Om&'6iXubKh#Z"G]-NV@]@m>6oVGQ^NqlIHE,'`jq7d_?T&M=Hs:n@s12YJ!\mS6CU[$K.YUHVg<2DY:5`J(2lTU@[ZM2BJ4Cah5rIp5]at]9,/coLg=mP).[+uWI`,b=]BgDg/kH/RmQI>Gutu+57fa0W2*+lW85a,pEX2VKP*$*=teqd`qIVWjj]"GmR44$BTmhQ8m5DM2.[i0@:=o'41t*VZ;PtG9If\Jg#Ym7L7r[e!Z#9KU)R%J^im7H3Fr$uXXhqBR6%kaSZS/'fjchBQ_MRoZ?1TJ@MVcH"BMI9__Ro[AsHA4+ZKniM4`J*>)Cd:ZdLp.=Xp]28>olH&#>"j77MIliJip0;Q"Q7;dCAfVB'J,;`X(HMNt.Gce,8[,:guQKl1,U#2;C1V5p:iq*V2BN,-Nk11?jT*/=8*I`M__WNd;&lE)\C,aJa!X'%T65Bu1$eOF?]PK=2'+=h"adTc^ojNZK1nqWK+@Xu/6moQLA>Huc`7Y)lJW_ch"$&V`NVF$TQ1FFjK7GG[#\"E2Xlt8'a><H.i34KeYI%VK_:.>FTE&n:mD\e3RrrEG-IX'iC8C"jbI!rH#n13^nM=W8TLqKK@qYO%LfH<'\UWd2\9@Zf>^60SZ!a,Os'gp<#KMZM'PFX#Zp>\5\%1#M1X5'hbdR!9o"9N0=c\<J((snH]$jL6=RJP?8kRrX-+Ftos)`N&E?!ZC;Y+=cmBtfD:e@^%C?`//tMDY@'U41"0T-6%O;o0JLr5BZ0_>jJ=q"X._D^+nWL:4LMpodYEi\%uomQZ$]GMd2mpj/#dT#I8BA9sq/)LCHZ!6de-7l0@jW8F(\pg1's_-gbKT]"IpHK",=d+3hhjMCVhF"mel#V/jI(=W(9,h&Z0!rQMdB[V9](%We]!-A(8:Cug3kC+)in>9Dnj=RBMi)e2=:O1V1IM:WqQ9t>HNalM`Pn%EU>f")lkhiD8;@YD:G7=J5JmQb#j2huEKKRrm/E8:knA7a+(K57p`\kY;C$OF_<ESEr?YoQtH-*&O+>E`&K*M8)@1UTmA"Z%8N,FL/T`*Opkm%nfX8OGJLf-6`Jfb^_?Rp_i*b:%cN1keqD<*9GVZm]6#&L/<L^Q3&=d(MOi995T_/Kd:9>Y7l:4`]PCW:PU`D;#0!:55iUJp/QWJoNa"sj!@&_[e"(*Db2X[uJaE50@j,0GT3d[Ek,%CU1$_nO>C6njnI%H7kB"Ya/`M$W`ET0hi)XN5oTBj-@gB"aA"&SPNDo!aYN$aV@<)?H"rfTR]i^K-%Ra<PG(B!tsu(_fdU.cWt3@#<4Hjp,`OgmM-*1.\]%k$gk1N*bbY;*dq1&B<Z^.`7#rOZnJRlL:<-78/+ta@duAUTust?5*(HH./gK8a)^8Gl#p/o*-Hd'1jfXYie!2f'4!'B0#&V,puj%2.qaZ.<mE=@RYVY3t$$F:Um)HhpNB@+XY"Ogq&_1[%LU^$Za?7C*5c8nIF=VfX*)9s*+`dF?"ra1.o9B2AJ6N9s7nlF)Q4VFkj7XnD82Lp8TA.^%5(6:E?X+C)2kB[gb=6<[GkcP3]DI84]6l:qFcZb_'Xc&j3ePbW]$O9e;FW$s]U$bS*f3B_Z-DR5ePoMQX7*jM2W*Uc/G*3dtF$r&8Jm`tWfYo^I,$K1"rnVamoM.(jfr1Sjg/h)gcQ@--6.@%?0k(GSskPc>eO=.S#b0&XMK8JGfcY2GEbH/GTsf8qYoO4WGh80P;B9C+UsbK4Y6eE[b4iZJL7dp04-.7=pk0ZiX3jZ,gOIL!u*;#rTG:BhGqX`U<$%9UK9nn/)3iR/VOO['2!s"#3jC!F$EkqbP+9YlEq;h$]LGth9"23+bVR1m2U2hRg%:NMum>H\":YCgd(@8oDFDL17:eT8+_Vi(h>`]`HrrL?HnHd9n4q07Gji1>"rIa/)R4m-E5CXasaC/u#CDZ04^Y5e<F/=QInOo]tu4r,:`Wrr#Hi^XJiZJscGS%I[sNCEWq;=db%(.AeMH-n;c;pKm*7+Lja'[F$aA<GdfKUAUE)eAq@<U?!n*10bq^.q,>=f<R$LiHIu>=`TB@aqAhEBUVD@)\C)PnP#ePhahM5<_JG(.uRc/kFY4Us[`*in0K7/sY_0Xt!CBaTGMm*VbU@5GLY-&MI-89A;%"&_sSEARJ?9*F@>C=-b5Ok2i@IfVocui(%VnYs3RF3=.f,H\QB<.<dV$2gj@M87<:sI`-.grT9%A3i$NUs-Mcs5'']S1BN0sR3:+'_uO\:;H<"%;=WkOTg/@eBtI]dP<J/O1mu3^^LO-F=[!'=c^+nQo/#&f+/eu>kp!A`nKU<E()$L]oQW5+n).%@C(1qkB%o-:=b""7pRiiN*.6\m>8T0Z+)90(9[d1O_Ju.3#bt<@^k[:Ke/"8tB5<PmNl-H)RU@2-=[W7/090n-%Z4lWGV\AJ)%ron9B^be=>=i*a&[XtTQ;)T!IiQ$M'>S6+6i.BE^T,,:*6XX4.5!tXr2LdSME6.M92`0eEgH,>nOhDOKP'sD]A>Xof,Y4<Bdc43u)Hl/i`"rQPJ<+c#-Toe!*[23omV"f'&M"ZF,M*gf)_O.hs/cPYuCaDma^X3>@Hhqedurm"i7I)B<j;gFU#q)p[%31Q05?'q[[=qPu@:oQVt93SYl@ZdR7eEj,.^1N\n2.L,#)B&F.-lbq)[`2o(-],WSG<BTf^p36T()6(P&1Ki807R$'[j2m]hMdG^hdUY:HIj5I;IoQtHp3li.?*i_t"GQF$CXMtEWC,3R1ho/=qRHa0gngkm1$<RJS!RhrE`ID3AS"\VpKejC!(A82gue@#ZIk*KG]ubk5/D,)HnfKSZcH^*c`#Qf%tD:WNZaHPp::Zge<u)$I2,p[>i8>"_J+VDR>$`/Vn$XoW-)ftd*tWKoa4bR*Or-Wgr^A&H3*Mn:,#noR)l9S=*soC%5Z\(iCb?;6rTCs3a@BdRH:8/8l/113R+`m<>V*;=o@L4+cH/@pU\C"`.O@O65(V4o\uYOpp2a_:9OnME+d9;4t#O+@DL;sBpC"cAQ?\q_E-O])aCor_sH>-%jim;$>M02#4P4Iln6IqhLhm%?!nM-`L.:o>FeJ$ICqPbp-8/pXNU?qoY$9IMn1<&"XFmA2J0#Rb"N$mi?i,:=Z03h;,^_6nNu!?A2u`b\-jS[VnH/&Zt!t-LeRK9[.ajI\\r&<lpTVgKW7ZG8].H2cC(]`-2oE=)J3(.`'Ch@+$^mPQ\[EIOB%P07b?gdohN<50Oq_rTPc9>*tUsu^:]\D.L9N1f+=+mXiF:e,s;efC^irL[KN$'aXfM6`td3e=k`T'O^gd5.;C[&''+qP<<m&>g?\>qm/D#hpt%tN(2UD9$chip6[S8>PuGH!A@F<C^hHp=-pc]Fmb0#sUq)@OARE%J;m6%^1QD?S"*S&@XCi8$CI[1qh+$B^2.],:ggY/BR,dI3WdCe(DO-N7mUI?$OA9Mjb*Ypb_IYZO=08_fmhjQ0M&g\S("hXkFEqb@gA@+9H'p.rTEcU8rT%_\2"$TQJfp"@35+7tdsLba85.3_T==D"!m9g2iin=tR6mu\RN1$TkWC;nb)*5gO&ZSQCBUEp[#fu*>k2Ge*kapJJ\!uq]jb7G\L0Fd`_oH01d$.bB-1,uWJ[Vng%r)91=t?pUQ,DHo'LRTiH-a-X[)BBT_853I>?,XH-/u:W83c'!O<2X4h'sLmZkRk";RuO(5_#9ZSp+jY%89-NPqCe?9_;,g]T'Fe%\OU'[K[bDa1:BV;^]YKA3<LC:\>+cY=MN*PuQ'FUtsN;\Q;a=2uZCDL0<rf::l?]BS:[En/(p\n=(A<a5kglfVMEG0d_`T$PIYBYiKfXuQ'+Wlqf]IF0>4gcf#EmAj$*:.s<sgPeICj,]KeFaqdm<Jc(blDjM'GV>LG1EBt!Iu[kE)*P+H\q.c7h7^IIHtd_*h^U"GXR;1bjNH_YFt2ns:1q@6#^K.>G/t1.\0C"Y8Cej8RX"?ePZb\_~>endstream
|
||||
endobj
|
||||
22 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 4330
|
||||
>>
|
||||
stream
|
||||
Gau`W>BAOW(4OT5^n\B$.?^DnZ$CBVAB-cLlc)\,G9@N]=ApJ<J6'%lI_Q!S-H@=4$)a=f,$mN>%_hP*)r`OjIiSE*\7=mc,Fa.*R,bKm201n'@ukj#e)S'ThDnTZ\SWo\k.<Eb[_O88A(gJ8de0!h3<$edn[l/@J]iqq4*9XB,L"g#%_]KuRHr^reG"Z0Z0SJ&m'GWM`:#?^<$js2M`q']*e0ohJ&e1Jj*RqE/VhABnVJ4/X?#kf96Q-qiNZeVTAE_e@_$I*31t10r1]8*]_TVVXG-Jk%4qNr+$"n3rm1OV[RAS#5?dp.5`W7"gGduj=],&*[MjY>:?dbIp"$Y1D7djDV/+>7`4KBaqs.?Z]u*$>Y1_7O*F5$q4h7DLm@`DKoNu)uL08tOR$h`"h*83(5OKC"6]imjL4Y+JX@*Zl(B`C&-hM^1]YtMpADla_F"[Mch8"Z9/]T*k^+p.ADW]o^Ksf8Wj'.)+aE<fG,#faq=(.J]WB..6Q#%6pFPr\J"3W\br,5Z5g.oOP6m>Vc:Y:]*Hm<gERui/67SJ[B;iJ&)_K&N?@OZ<AUr#mK8Nkl;Ps'tgD$<_bG/Er^[EZ"T\eWC%Y1?a\@j6agnL@:CF^Sp\KX?JWH1"jooAo:k7o$2];5>1I$@0c\,Pu0hbNcB>ZM`'#974omLkk6V8K\0:'oXN75V%Wp&!Y!f6$uQ$;-#.a7qe4Om,m?m'`q*u1Ia_FhrQuWV!MShMS+bMd(=O&nDZ-T.ZeU'SriCKYq"d%Yp.BBEOqQ)^/>]B7M00<c*@mhS.#/!(>k.kWQ].N6)m%B)M(dNGQnk9'fZlCT,=_hNC^t)KOG^r1<,bj?[$`9&tE$Ce09"T+V<B,:s]2X;%u-\(?.=iq`tpV/"iqV(T.Ij:_P;1Wnsrd0NC6U+=.Mr[?Fs_d73&2"Sb9TXrjho/6cTn>.4tl-lY!$D(u=_("kBR?rX?HK_Cg,l[e!E/\<>MM4Xs`!`NM!<"5MHJR*K?'CNV(.LOVPW8MG[PS>E`8lC:pG>JL@`&A=*O<FOm!/GceSc1*m2"rT*D0bu"H,rV1UdBUkbf:8P9CNH'a)Y!':mW9V]#]'=8=he.F%t-4;k!(eJScA.gW8s$Q@P(f''^nE.`#_[2;hSn27[OK\Op>A`F<$56]3O`RVYoin9?>63oQQfLd&1^*c?8>@D7W]X!HQ@+@M@OI41gRfK"CWg[>`)GDC$IT4q1F5s:>YfTc>dFgc?QKg+BSe"HG<eGDipQc*J*3XuV'f!.aiTI_Y1F^:N"'p;t3$<BFU1Ck#`-=f[f*n]'tG8&OO;/?4)QX<Fq0\Uf4XW)rD@WnX7cjqYdi5dG[1QX;Q20]"#*HH+8=d*oh-)/p<*_!jeWT,m0d7f(XP=?+IeiqmGFS(Y//eVr+_Q;7Gf8D9KpQ%dZdA=u<Ik;$Aft7VmSo^cCn&#lpA25[/;q"o6;]\Y)qU=/-?isS:!,K\9#^DPJK@quUV'=>;i5`afj?i(,l+Ma2_[f?J]8<Hp\Mi(I3o2+I$=0DZpK+UYnDYbd"k*a&OrPtG95<gOB2Y5%aU>GL+>5@L[L!FtnaH4W!k0o'!D(0Nm]Lkej8)m'BYZ'l=O8$;<Z2%!1Eu?RF?Kq4.B+fr!BGc"'i_D2AHi>^2VK%p_Y?J@U21)/]]a%%p<kS&0a'&ec$#?en6I5T<Y;DJE9L$g:k/a."?O2Y,qP&&&OXT,@-Bm93Ph*n=!*s$?%>#/_/l6k6Q/R_V8/=UBp:Ms=fPlSRLnh;E75@TksT/Ei80-=`]i8P=Kk3O3>RoEC*/PNBD7oY?&%97JQdgq7\.^>99M6llGI]s`"RTCR(sI]"H/@'UeQ>o^Xn;7[]4LRF'c`2-/(@gF_E^[LkMd6s"KZ%&t<>Y8SoX>/MK5AQr04'#nnUQ%Q=X^+<sB)d*Z35B!%87]W"@#!#*R:/ZTC,raJ8Sk\CO'"'F.Bcu$A:^($aqluY_#Sg=\FGD-kpde%D;"V#b^PA!6Q[Q7BBCr5+uWZhXY^_neHGWJ)^)m3%S2^)6[6X3$,dUX#idpr85"HH>\q.jSY;aHu_<`Gr.,30Z[\?H_C2Vb`QWA7Y7bGV>Z:*1Ce`L1U:hb:55jGC%rZes>LMM'P/17ZVU*l*F2&/fU_%n%.&Ri6/J,o%Y,Mf#o#hqB&)it1X8(B2NY*CITb'/2mdTR];)'ZM3p]\oi(<tnUCWeW(RdE3SaN4R;1Ai^OTQ4ks`Au(9U?pJleI=P+6SIGG3et?/>h3b0.-j6Y5ill_Nc-.1_R<59uS#]G6J2^`MhLbrdnjqW*Y#@Q0V+L<@O)]StGJj[5TS679&"A\X'#e)l"GF&oDnoiNiY"OLr2k-\nF`.*Xr^b!C7XXob$IbsK8_9;2!15KoPDSlYEgF>`+86/=n)VU%?!p>Vfd=1r!h')Npf$Np:V?9oB]3O-:&AVm:F`TY,]E,1VISjeY(MUicOJ@;k;l,%^8$U)]c"HCeASe*#!sgWR=b1V3FZL4>AaJ":WE[T;4AQ*3Q-4JA*#Il\r<sYA'22C7Gb..VYdF<6b_L`:E0>Z)1Fa1mMbYM7f:Hq%*K(q$s0f3h1+M_8=%W_Y0<;qss3"E)lV&@ba`1U!.Yr*qUD`H>4NbZ&`(VpTK#4lE!hT7UJ)mF@9KLdFZV^*@t0.oQC,P'_9HeF5=LVKp:k^^2DfkS:(!!?T2oiH5*rjTB[&\H)r[bkDM?J%eEq4f$AL7c90^Fm4*XKH4dYDcNEHq7&;QJZC3c]b=L#c[I8Qo[F.)D(2p1Z^7it:\(G[ejJiIt(W(@SjtuIMH5:RP\Xl5dNS`/HB>-HGlC9Y&K?da>24%V6)cQ$<l>f7gc8cr;m9=qFdZM1bc#@Zs\8lVdJF15Jgjkr`8$Gbt>pg@1FZ.dQIA>DYnN>(!F3g;#f^nXOE+rPMdduTbHe_[of@tBLQGhR'hP#7b)LOIgf*s:c*D\HB'tM(l\%76l>/pF`A<pp'jYSRDd(dA?`e4Xl_P<-*:64ZnXdSX&,GIOe.dVTLe/P"uI`#l:SBiaUp2Qm2HpKmgZ&$B11Q@D&H.ph/RYmKoc0\mF$GaEb0f@h#Z8Gh[G;U-!]<5kIT"hWkST[_cmlTd?Ffq-_moAXHh`!KbmO?bhoo'eG2k:.*S4!g5C=(aX)[&fd7,=NMkqne'1BSQ?p[q7YJ`eW0IVQ2mh_Yd;Qj!95AGlBfa.J!NhfLQmJPc8c&*>i-'6B^eo];])0%6ehS9rQB6&GN8"pR<Cog((4dj_.XL4fN3r/kDO,7rBK"]#a<Xl".eB&(g-@s:Wg-(::o.,o>"8qJ(k)GudcFfA[ge`s2'<aD7\Wnl;"H*Z4hDLC#)VSaRSffuG*R5E`$(V!bWX8CXHd?cUjce)pFU70H+Wh4L5Bkj)=<HQT!Q0KoU#s3>n/NY,J'WTAOLd\6AhHJ+U=oQ5M0J<dOJ#@AI"HG9r/:Gn?K-tZ7;cS@H5.`_$&r3GH%CP(#3m1Q:U*f#m&1dOIjnt3N*03u'(4H+a'mEq6k">/_90Z,lk4YZ*M.NnHio4hud*"g*\90.8`MBjjkU^>:LK2=:HM-/r&ZOU$S.5htD>C-LJHrlH<<>rtm5n>ERM&SYOB[:J-?WI6R[6GQ#!"lF;h?eXA\"gU:hK<S2L3O.O@Scc?!)1=keR`)KHE`..i@0Gi_>i+\h48jO$c@H<S.@Fe/l0s(TQDm2!0TA;o8nQ&iI?*+&K*,Dl\K>_#UE*[@-gAJ2->nT498P`)`EAXQkWaW['90)tf>KWBTi+Xj04+%:W"&iV$kslUS'fggti$jZ8EDF];#7.C,EW=F1U:bME=;Lc*L@?.+4]9_CnQ_8mkX#YN*8IAAkun]E!jVN29Tj\s;KXsi`"<S`g>2(pX0VrOmH)`_Z@iPe8qX/NT]ki>bVY7I0hn=i*ne,iY8@bT3.N`o\d'1W?^ds_H@2ncA=U%EUZ;]!m=f?\RG8tR/ANkH"c!=od5JRE%!Y;:a#&NS7P<0tq9N8#gc_fJ2W53d/RenJ/,s2$%XBT>W+X0CLWS"2%;[cb=VRQ[C#eu;0^^:If.Ar/iU*8=C5(#?SP(Y1e<c\r#!=u"IT+hImZ.PhP$pNXlk+X=k^>S7CEio5/No<8h21e#?GNdGNum0E7]-H<6phthXIhqr;$c1`<0h3Wo+RtS;^r^ZSLAm)t?\n3b%cGAhIp0F5.)4#-)(O,O'k6mp]4-mUUDQ<>^Ddr;65B0hj-RV$%kgNkt(L><m`q?3=XC4D^]!sht!&j_CZhkqVQg>mEBB66?aZcO?Z@)M-)LKOo=#2oYc<]=2/7racpJb&]&Ekmi4[Ks3pOCm7\]=~>endstream
|
||||
endobj
|
||||
23 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 3274
|
||||
>>
|
||||
stream
|
||||
Gatm>9lo&K'#"0DFNqoh,uGjTIB?;*-V7mmaa+rQDUmSd&s<fi"@J9Z28=TI"!f1>@`k.D>`fSL!7BW+]3dMYdhmSKrJXXKGBYn?i0,80h4"8g$gI)JhVXlP5P+2&VdjW\dQEnAP8TZo`Z%\iM]a:kpZJl%Npjr3Z4*\aS7+LoIDU[*;X>Hd5IJ*VGjk6l8!fk3%jU=2(hlNO0n$,k&Jmb[jr\^>NUD8W,Q*S:QcB@JJJT:j7YW5*,kQHkB+#VE/mO^Lrq4K>c5*_d2_TH9O)OA>R5U!$je>ll/A:iu(POY`G3Yg@*+s;-HuJP`2;t?Zc@-4]LHh\`CW`n^G46;8L(jcA[rfbS>d_&.&)h;nc$?grKkE>*LWM$5/:tuM23C+0GX(k_pJ3!XPLgm1$%njs5`;rbNu6WZf]S[2`q<7m5F@0ui%%oad&Z>bN49rl2OM)=q?0&cbg19,Ide'T'5E`YW_d_1)k"f7Pe&iOb;%1\as!(Bs6:a[j&BS8ZVhQnIPH(p8h)X-N(Tp&fX=!(=r4Q"P(m1@df2MeQsZqY!Wpa)\nlfe>d(m3g'9V\;8l.>Oeb[;dL>@)mLg=GMKHQOn;\s"PtYX\nQo?uPSN\WMfS,(ZZiTF\<Yr%<H-`ug(8O9kQd5TONs"0@sfq9.5R*O6[+HA/;[3&$Wq_gL([iXH&-.S%c/"N0(JR#$J%'tLdL'*YD5`**Ikfq-@Mk13;1A.4IdRj5JiC"HmML$.n,u,9TE.7hRTC9R#D3r#8,(Cf(T:CW:_J<9WhB=0B5j]J8Sp<X)cPm0t&%-C;7if1=fQ5l:b*W=OW51%W+:tpi@OL-Y[sg*:fQ@^M$eB80LL=]IZT;M9UdoL3OMbGmffcH]Wp_\mq8]7(Rte/gsL`aflkmIpk6MmuOoO&.rj&ioW:!d>js'N:+:Y24]*Ch>h[-]/d@u?:eZ*?g"7XeXuD7;"DN#2s\^1^K/^].1HHu&8)T3>45G!BociH<7."b+oM\rNLWoWaN3@,+l60%Jn_T-JTMB7c"0c?i8??f*!$:!XssPZ-W>o.3Y%rl"oM)99LQ1p'&hW6;:^e<;2Pa=mIWT<!_^FRTj1,@\'c1o',Qi]3(In<VMX?Qf`e95#)?Rr0%)NqO;RI!<Cg":*$l=LZ\HN:$<[_g]ECb0<+SNH1%@P."Jf(XcYg%VDC?Tgq'_GAZX)g)X#9(78>k*);BheM2lXk$*1[^Rb+hf'QemQMiCM1,nM(_h=f>>S0\a`jd0aP:A0mn<;8Q`eJu>i#CR&jbDsr<40b]OP-\TL;_7^4An#@4;`L1ftOAKFi3dPcK7@&/l"elNafGM32\L*N@H_d3cUR_iHY\sq;K8$B^TE9:tWuM_=ZPf[J!UWJ3\^=Cha^M0:")'qRAJN6@if&'i)U%ZMq7d:T+CBi0B['ugmf63&(%OZ4`sZo2naPHgAJ#XsaJLL<W^J%faU!\XLWLt!FNB'VDg!=akiBQ\Jo^M<B*,U%Ak-5`>F_)/]3oWD#WN5*[6[De+]$/(s,jKT.=;HB0q;no/N,F.LF\j0Efgj)Ik[!O,l:OCP/;[HMkb9d=#oQ)_R:K)14b43*$Su_b+u7\,kLR1LTO_!"VmKd8Lp51aeEX++OPmnC"O[#@!W&eL$7CC!!XX[,t-B8].Y1R(--2:o2;MUY@1e&5D>"1:l@_NYge9.gtPF)/qu4mLM_1H(6Lpo@9b[J&+[k8`G[5IH55lq&NaU]f'>Vm^a][+9<R4p[rQt?%aP=D+J_%5F^\YjoLL`TANjk09rP/7R.2J+kJulHYZIieVPmSqKROuOME0fbObNQJ="$+5%`1.J4)ad:6:7K,["W=saAldXk7DA&BKb+J&=49?/8SrI`BiHbZk<Ro*s)E,p+ok"A+gLmj+^/FjtKI_T-33_.a9QumUcd';P!5DJ@&Mo3_j0TbDtFofpWtX$9A1R;"b;0'bT>g\_kf36?NL<.@FF]4ib!kOY@tmd^D2urt/Zoh"-1i^t6aMP6[t\/go:O:$#f1#=mHXT,N0FBN14\="=j8m0N&4`n<tL5iEj1?jAnWcG/l;gDRG?Xk!7"?d=%Hcc5QF8@K\e5R&8?qQ$U_8%N\,?gZ(I^,g^dAc1mI2ufZ'JYglhN#ai5XuYf[gpio[q6cg!j*&FseU#5qgDPLl50&YP);D]A'6`#5T2"'qfO8jW[Un9;45<I17rJOHcs?Cdggt-Oh<7]3a^e8p\^>lopE-l^BB-J._k1@l"*r@^%q&+L(/O8A]MH#bjG3nQPt9ZA6QcO(<R+`X[;3Am8)!SK+4b^AS)0d]s$iL7[KKCmT%j7dM/2IK5LFJ+)un`#p!#3k07Ra64798ZX\=C2'm$$o94QcOqeL"+/],o5b`lR8Li.LY@?5!Z[>r&bks:_p^Q%6>%o+2K>2'778<T6omRJVXar"+#A&`1$BCrKZ5'YI-"FUjLqA0"F2QpAPO8B:]7!jCYTIiOT<&@d2$W2M5X?nL*DBo%KcGY*VV:f8ga,%?dYR(*k/"_=N^.q7u;55_KYt(,eP<rm/>UV9kToU$hUSMa01-9su&NA1#Gdio/)?80JGin:DQHppD(Y<kR%u=\W*n6_CLW2D`m(AdT!LS5#g/"m93r?08MM`K)*l7i_3ioYS..l5u5Y,]1d4FuF/jO5.O$74ghnSg%beS@0%Jto4H4Hjg,BZu[s5=EP8n*"X.C?4'%pDnK;m/j=-eCfCPm>'HkYa2,mV.AMQ]h@nP"H59W\Cl>i*HP*43ACEOrHe(14k]?^[:OSh_l=\@:I"a.&mpJi!no@oB">`4)X*FK+#FB!Q?n+.+j[C',q?02dk,gK1r'K&6!IhD0L9'dSI#C\s33OXXMG*rd&Rnm.l36RU=/RX!&G8\"MdK.taNJ'Yad14o6a_[T?L%mY[7W]t6U&i*&O-?F*YtZL*q0H.t]K:P:3ll?YD>1TPHNNcd[u2uJ-(]B'>k)hq%K\<L,3>\p]X&7hii2&Opb1)d9hOH.l67Yq#T)F.71l?]Xo$TYs/;\@'CR.!<B@&WKdX'DIbm]_boWAc:pcIq3T$5gfGlN4k^Ntj@(G9%GQcWC#;pBgMO*Odhj[am6Xgs"Dp[;NhkS*QBH]'FOl>ds0LgiY^F/if4mJE<I-9gNc(;dj>gI:.u*l"nY["1%seD/'D?GLWqoooM,#47%[RF1f:I[[b"+Vf$"7F2mu:^YWr;Ud&ldDPA;&eM\@R`eN>_)[3hLN`-:573"HEr5"-:@.)9OPPpe+9V\JXF>1WhXO,Z#!MZf3]D~>endstream
|
||||
endobj
|
||||
24 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 3067
|
||||
>>
|
||||
stream
|
||||
GauHOgN)%.&q0LUi%[<);$??6b[`l9CaCs!m<N;0,U>,(/\K`:R@8W_7KXbZ?USX\9t=_!!.f%ITGu^8*(k&BU%g\u#l4.E/:XFJ3oj^:W+UD:iA8#i\GCYCeW[^a"Wdsjf5^t\![F#u8!2gDMpa<K6MQLUGNFouHNhU?6%V`N$<Z+-eAp)aZ1bfV,6)6q,@@I8kCf*LH%5^Jgt+[a(O>dF*e"^\^V\;)'l83$$B>i28iW,pCA:C1.eP3C*YY82c^?#h'G>h=4G_&[2A8,3g&,l%Xb%s?i6dgW6/`JXE;a2PljQX/W"mRH6:1e(V3>0=\l_iE_J<C9a)eB@`>gjJn07tN,A/5>cAMef&I5a+P.Q-)CP7rSGR@K&,HMP]\BqjWZHKhsRrD7b6dCaoJ(lo)eabX&31g.[!e/=;T!ltr[rETCEjA>5A?F@LUi>[$E"nk.#<QZt:E7Y]UcQ4(91`LpBU'aB+lopcAk>nnAbTI)Mqp0=pHqrH4R%)_lC<!dW[WbT_j]:jLL-drb+`gSdIEBb?(@bCK-&Wts0q#_a`>'N3*`b@?^%p.?/JtPY1iJ&DYW?t/,QJDWg^Ee\$dci8^?$BD@^:kZgR5T!AJA1%LdrWYbGa_[[]b7W8LIJgbGPBKCL<hP>5RY="F(EF3rFJ*Pkm\^VM);*_0`G37iSijW4%R*M<ZGPpq0j]Qq^6*d<C.US,N9WqN[^3M:*I_`_NCk_:meq%V1f"b0?H$#<c%[<RbM\Nc0GhR$Ec:'2PlE.aP[qukF[nf;@WSs=iO\k3Lb,irmkA9F4Fdk#a4Yo*-Mdg!Tj(2nK?)[9(%R]sA@W^f$+g>_EJc]WqWH5>b\[<uI'T#7VIiiOPI2CeDVLaD8M;ngArXIFpGmp!X.(L,Z%m",$t#CnaXD^qr[oPIYEnb8WaW3GV0&5JmVK7E+#-,>[<p;d52"`3)WYiq+4qtO"\C;Cd$HPBp`;qm=N',PW_Z>r$`\5CY_qAMgDi.<0qm_5VQ8teiS>uueG#4N^05/Q,VI02YLo`:Rb"3u2ef!)I!-_"G4M2$NKZ\BRT2AgJ#13"Z1OgBSdQ.bY!]Z2:^7YQ:@f+>Bd"ABYd:Z=ineeO.2MQmiT<dbnP@sdTF+r@<F\n\et4OF2E2s_urfcdQKAF66;mbi%KV%[k#.P_k>oN`'HFgI],fr38QL*_2Tc8k^\mdh^B/)+I!FTS-k9R3c6`J?(P!'YA2@%U[DJlF_k;Au6J[U\7."(\8.`LpfU]J)#W9(co<95lI7h\LT#%5KF2NqY,7W3quq15H?n:IiF)dMo_%.^\#B3C#N.Y3P_F]gqSb&1[3N7"9MVVE^G0S\rkL$q;e@Sr,gDr\t!MUV^qG+qa]>emOj(qIF)N4,KsR(^up@GJQV`=JF$E=JA&]?c:E49D2UY;,%.*nM!;"o!GU\4bliPF'@sI`Ks/3FWH*3b2@R_>UP:Z9A2@:=[#j]0ri,-E)B=6&]LA)L&4?p9+W(/9X6hu=m-3T_$C>k)D8\g.r_,lkSd@NCQ]Fd:'%p/K&/Y<@XrE^"MlsbPoVnPR(02gi8Z7:nRdJ"e=VemCkGTD:2j\SSFNN!&B^g8pF?&=q/'!l)2"]EDYhRcQPa(Q4e03!obLrRKA422\8U?XEQeLl._o"9ZcUnoH1E0ng,H/,5Oe-r(!)_1ma.i3JAPr,LQP*p5$l(-pG>j\$S:n,<_i(ff2d=n8WS;lN#[7+drAGs'<3//_[%:Zl.?B$_'[?SfcEVe3j(rV4cK.G3*8%im9p-Uj(\_O_hc"SrQs!+=;R=[f[TkN5D[=Vm`1d+m)S8`DW=C=60_&n.5hJOj"<>5<<KP'8%u]4><'&g`l(0C#(-^QL:7Uh8p?f@o&c&]1_M+r$Y^7m%!2\r@JWY-`4^DQm9`V>"=o9X/eitQr5J+\Ctq#5!ie\RiPL(:[E*FY[Qe(q<Ts=8'#*jQ=/BQB/-SE;Jb<@tF'^.s6riW[$iA_eZ@&H@Q7b#AYtQDLZM[@&gj`+:W_mnVJ%pK#g1@#\mmSh!r;OG!G,/k@[[2m@;t(!3-Qk;ZAH9T%;!*/mc",Ac3B2M"l/0Z)B6gtU6H,lE+)`Jl0e2XPGC<C^7LNZ4_.#^Oa4Ir'g-\7/\U0&sj#%$.,.#1sInjk1]e$.N6:V9gM`'QEF`[7/;TK^)?j84fV(#AY@3V2B'U_Ej6";F87b]f61mW]2lZaRHk$.of!-'!2'e!WI1DiW=Ki-fMI$[]]aF%uj]jNnML2AuYN=$OM(t80'Ok[m2cDOFbKb_0lTTRO$mala8:3n^kR`rUiX?cTEU&lPh;imLks,@'7/Hoq`n>knY^/-mXOr[?ld->/Ia1JZlm`I=f!n:%\3d8]O"eM"dR#ELL"/RD"=EsbBLfU6WRlmt\;XCP4S#.Zk:0,KNXIaI(DGRfh0X`..SJ*E)Nknnk!)-?d()*c#U1@6o%$Z^nHg^Iq=%HbAI"Q]=&qqCsUC0_cZ`jG']Y,GL2QF_)>`?h;P8r2am4);WXWWK94,8CM)(^Om#M)n5Z>Uk!i<qrV:)Sd`'kRGR<1YiRID%6:#YRaBM0hbm8^JGu^ljmY-kqlPd!XEMQBf>o5`@0J!t_6u<4"1n`^15m".r%:R3Q[AnjYE;b9P#!F'Ro.TZ;#;3Drib8Q@?268b2-)q8=tk&4)Qf:M_AE_n>7pk.lNpL79B2WBe+jC=QeSi%Lo<hnn)iQ<;+'tT6Zs-1q/`9bntl\sDH[#je3MV0-Vmi'g[Z9B[;J&o-.=/;&/Y3j7X"BkJ!`H>umd0Rcg*>8aWZUN8+4<adBE?mLno*6XSIP2O\_=gD/\-"JDNkH,.>MYA-OYH8p@KP9dC#BK^!&b)15ZEXVBcrkU+9.?rp1&.d'Bnq3fGqtcE$Q+6j*,Rb=*'-UUQK.+AF"IN*B++H82O^i*7Im)1qmqZ#"\WKGP6YcE"Vk>/>GhdTrc<"s)qk^TfgjHXD(2o#tqkUCV"Op7i2>um!#'4jW7\*+#UTG.^C2k<?b%ln'dTXP[!*<IdOI(IK"^iNY_7q[t;C9Qb6m6:'+=MWqjaC-Wg<M]`^Uo~>endstream
|
||||
endobj
|
||||
25 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1719
|
||||
>>
|
||||
stream
|
||||
Gatm<?$G!^&:N_Cb[\):(u+ff*bi(=AiFR@+Qr*lYQ^uXQ7L]$rdCsq$qZo)k,](@73+rUd[`HA_BTj=chs9n7.9:qpumk'dsO_nhukmOUCZtQO.LPt)7iQW;!\^`@>$5"ESPLincNnp091W$h]m>)p.;B4;!/iN$u);#]2)aH[#_T^X-#a[s3cEEBeBa]-&V;gqLdqfC&Uk3reA`llX:VkrakDLh@b*o7mSu+CKq*R:<RiEB,lmT+,e0cO,%Z;(l$(*_r5%Icl)km/`YK/o:BX^KQ#Z[GY\1f4_Eg!4`E/?ejmaAY&T@!Mo,Yd@#Sq,mAIQkT%+AoHlMD1P"(I/FCkFH>BbC"kX>G@s&=iFWBDO_04htXqB^8hUBCpr?0FgbcOYX_39-&%>i"oOc(\;+qk60'8!+H&qSkM#5/Cm/2S9gEMEWm*qBU-2l*_TNs(?X^odO<pai(3PWb-AU6,m&2d:,n:CfJ:K1qT7(6?/<m3MmMr>WFH8Se=\2+U\SCdd((C-^h&$OesOOk5=p.5gf>bN39=(,@N7)EMQ0haQ?Q"rdnXXk,0)tQVuoF;l(C2H^mAOWl)&:[G:6ba<aouW%+Ha_OX[a<Vn!)duH*4;JrGq&1s?^QGluX?-VTYY\XX9^(FMjeKlJH(Wktp`lF,6>N9',hgq*GJp-5V<3V:]X[kG%q\&H7QRg%totCrMdM3"q'r[NY)(+HcaV>cq\lp[d"*EoH)UY(?-,Qc[pf;FYZM&HC4/k-]9^^1,4c3(b3lCc47[hk%.c^c#4#K13V,X]GOP;N:(rA_^q[)b^Vh.bm((0<2Dt7CmAT5I@@k2M@bE_&S>#_oZ9.ZT\:c=5*<9nTJ`]a`2=#[ZWZD*C@oKM:@(WkC1m:4X!"j]lp2aAUc*t?*<k!d7=1dW?;*H4A(W).+<fT-$I<+=%64[%TXp$(W<ZW2,,BYA#sk'A["\sZ@>5oPcRm`NBleTq'cUZr'&rZ5Kj.@G?orcWQ4ikUkF6!L&f<nM#:2=bH#fE9+9!HLHB2"H8d6I:F/_:NKRUcO9<&>1]Al2hKMJ0.]Fe@[@Ko'ld/SQc'1GIE+t.;]X(!//g]W<-0eL<-P1nLNJICeAut6ro]eQD1=!c-NgFa^)^t0bgWfTdLs33,6)s2TMV?%bRK@N4L5\dNmF9BZ=-L/)AoP>WXf,@p9tn,--k&:4[5M%:Ij3s)D8h%jM!T8lZCD6%<5#Rkf[n;OnenfQ/rPH;WGOG(F!0f;"h^L]!?9l\.a9EiHWoH%9%S[LnpFHD^cH'W!/.M0?[S/U*uj%i1O%dZ,("ntOMpJ`=4RElu@rOUAf)XS6'LZa24LYg5F4_AaKi@_M$OneBI8FOkUn`s)bB?XtqFJ=T9O7/iq'[&(?KnKQeR!aD5Nd=F42Ad*qIWa>kuDu>%PiAC8%*4t&e_BUdqgisO[rEh^0J%I)3jkMs6m5Ul/R;ia8pR/sJ[`Zn1[bD0H#nJh(I%8#Oq]NrC..tTXF-pr/A)>m^_@0ZEBSbfnb0@/URl@d@RpdfX[;W2&$sU8f?X(&Y[W4T%-F.YJ4IuLoZ/P4ZT'&D;`kma2BRDmdjg$gA;^)e12tmZS=T@5erhn9d*pp4\$b*n!35aCS1k8>FKQ!e/^3i^\`P+MPR!Nna0/W8A8t%5$f//\J?l;].K)AHH7)A7@Je7e#'6665(]ogJ)P0o';4VYaWII]g`^f=m"><9*(B~>endstream
|
||||
endobj
|
||||
26 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 3471
|
||||
>>
|
||||
stream
|
||||
Gb!SoD/\Gm')nJ01$GN?c]KMV[d75V(>1P!k--LQP/RLsNL"6^)bJQE-/P<YJ%qDFhOMNie^_&m@3fGUOW;Y/*6p?>/26U(nS^&^G]O,M[h`((go\A4$jZ4W_<'Lpf+Y34EbZLK#@)Ub9P2o\>7>R6TA'hmQK?C3(Vf.qA77j:5/H3ZHA2a]76%jfes&[se(CQ+""m!+LgV7@NV%fjAftP)0#(h3.'nE=h&!TuObEKE5L0HeXknZ^L0KRF)\X!!=peasdO`:&->=%S+HM`*=h)K2YUhMO=j0aPc>3u'W+E&rYkmkmHd?=_5.mOP(4P9\PA/iel53IUagR0(Oe"&P=Ho?l\F?7S9s&`9V@UNF5?aH`&sG1c5D+cE[WQReXGD&'`WR&,\hnt2`>-T+*pE5jV=hOE?@:Z*1<[MgLcC^ZIY1f3#8R.We%Cm]LQtIVpfcU7JVs1t2R1#4WqR]/U^qI-k!^blb[U,C37s7:>hm"=:+^hX/.eEH_Eh*=Q:$1N@<R!tVmg&Gb]j47fQmJNS<b&2=X$"4.)I9'E4o%9fsTaSMAr4L#e2hfMXXYkEiIRepaXR0Ytd_EJtJ<3LN6?#EV_tMbC-586T1RJM#CYkEH/>RPO.*!;'"<q1=hUAEXBPhmUNYa7j(W:&%/!(6RhG1O'n_,M9Rdrk;!fm5(Y&F/"*9_Y93F:S7?KYK1Hp:a;T4f\&o(H#`S4W_0M1lgoQW+2gP+8?nBeo?8\*`5PIGgQ(H#DMH%N#UOu4Sn-!r$25N'm&#b'X.mtXC'4Wp;Qu?LfJe1hVB_E]>@$/[(:nCA.7h,^a'"792UjGIS:r.$?-";k.&PU5S*Q77u.L7&o%n2'_qQ8RC\."!,T3_\W@1OGL(QhAG$m!o0]FHI"[c^@pau9/]BsD"F+#^PNHR#:.p1;qa6p?ij!odc:e$Y.J8$22aU^?=j'8P-Aqu3!Cr4EVeL2&c&_a+GfB:fL,YKXpW6F'JPMM`ZY_FLM-]%gK567['Z90W5A(bUMdNsB:(m'8$lEDY%%S,O+4M9*A))iVNGObdeHAVPeO/8g)f'>tT'+UF!2AmHfifGGV;-1HVG:h4bE@m#ED,1HIp((nMSIuqN&n2kW(RY1!bG<pU0rN!FlhJm3)I^%]oCBVqR._$DV#Xkd?,"=\kMBo.m!r6>XIqE$qd=br+/#@4>Zk**d^_f=]*esn_;P";Vr60/(j7<4]jYqbJL?).,FD$`GQh>?N*TC(\7^]/,&;aKn@=UDKbq['YQe@'if,QLBr"NcnoRVV\Ln^pa4[/;>r.)7e/i=IkNX7s.;FaPlR-6gdSuZ[H!6R*E^ps2mJllnJ:J'[",/=c]'bm'hMjsIKCngD$oNDJB3N->^;(jGpIMQWS^7_J1S[Ei`lNfkSn1e@W"h[FU%[iH.RtCp*%tWMrFeFaVBb`gL2cB'&-XtGGq?@?k1&*;/ChdL6ERFRTrU'C$;g_tiQ"n22d3m*`j<N'".B=%5m`rGl2R*Jq&d^(^A8cX^W^pQc$J-KZODiTkD8WKArSn4mjOc2]:g<1.@BH0:lOrnlMG2!KT>4s(!c-Kk'&glqP!*k&nqIgU7TLNY550F>_T!"q"#q5pZthtd?@g<>>q:.=l.qs8GQ9\%MHW>eIX$I'?n9U20cjm'p]1d5aANq_1%g@_!Wgm3cd0,o>oKdnppqo8]9bqa&,pr:_7=Y;dP\/Gbeih:l-:<Zq=t/SiGC(e)0*qtQUA:E(:1)"GA>?mVf_f/]e,8WaqcV]/URPb/R5G_G]/p#>cq9jQW^b9\S@O:oe]pTl8b!+la^-J1KDos>h&ES]su&LlE#\Rkk41<]5<.GV&&oFl@44B4mYq)8Ilo6Wj\rJ3DpcdZMrG`CopAnM>\U/Q>U/+NVS^Ylff+Ycb@M:CslUF;'"1u@H!)jVRo^4=l4L@Q]AO&IJRKdSXLhtc/$)CA@UMPQ:7kMNo<W]*0h2/%*-d[0#,JubiZX)%6,^_,`u?T<.!"I\bR4N3CgQLGoLPMP>$,@EF&jmLR(-fUj&=VL.nHTJVW;!_Z9K#eTLRXn,>+oOc#Ju?ajQd9apD(#$pTu1+SN@&gsWk=0UZtO\upc834.kg3pMSQhGgr<:U<D.TRRcSlLTo_Jgml%l_q37,Na)Kc#S_K@iZs_/I(o+KbV@pXaAePJXGK%St%$/$Ik>i<'_S-2M(X7%>NN^ri<Di-5S0OiBrd1"pK`*9acYE@]3@8s:%n:N_[fnG2d2,a@"$5BN9&DY@UL?.O7Ih#3KrLMQW0e=BI5La]U8;3XB/W3MV#W=jU?oa0uq2At*eb!]7q*X+rqgoV#`Wjb@-e+nM6k^r6Dd-*JZcj+YSke,VPS=p-bomj%b%`g+HS?bORb16`k3(YhS7441q0Yjkf2t%U$"Oo5-jQpC*K$i8n[%3e)X],4YM!rY2<C&2+JDpd(pkR1[?1MMk>/U/7-u";7(E92+8_^WVXE)(]45j^'Sj\+s(TE*qKZ&2YoUX`^@LdXZ:gOj2]6U1hnUTm/S\!k4n=Q0%5kehuk7KQ:RA945JZ`AqV!*+8hI;fVq2gg+N'b6Y3'4,j$WTr>0"V@1K]%P.%9AMmSB<:%p7_#.A'.>0O?jJ"Z?`s=7&P#n-o:Fr*@W6CWSs_=k_(e!@A4eI7,D-<V>Ne/5(6@](SSo*E5nK,'#jZjftM'anJ]*3Y?`)"".!XD=0Rq!@4@Wh-Q%:[_.Ltld9S4D,hT5sFpRe:Dp)il@])qWM/\aQG+1fo)EG<4[4p@`%:$""B8`3FF0VUnAC6QD;mE?HXI?hDZ)6;o6b2$AEYI8I(,2`L[k%p-ACA`!B%8PuCO7crL;"^'XsYlalT0%C,jRH;m(-oXdTNM/k=BW1/c*4"ehH4G=mo_7Pg0"GIEUk'VYnJin%k68o8Xumnt;11jiS:(4:RU=5d,i2Q[/ZnpG$Vt9io#6B?Hn6j%(L[/E:H1>j<IO4];ejnaVQ.0\oH#UVNW1X+0qiNk^oL&GkKul[X$KD3%PT`Z"6WbFd&UhGFbNXaBZ!:29_n'&e0%2)22SOMtqjbK,9'Gk\/pf+%]/Qf[>*O?T3qrl,_'kAIj;")9ku*oHc_66u@j\22VUPUc<7c@aETn)]4k1d?7k6,*R*BFfhLC8pl,!]7*#[GG)cY):>CSbW/qYa:p4@'2$Y]e:C;l^`ksQ),n?FZ_/a"l4gV>JBq'AFkd1,.rO@T3o:,='mCYpR6:oZK`GBhU*,]YA,-WGGQfK25f(0lV31"H1l>)^9m'DeF#fR@8^uUm/ls=)KkK<`BDk+RW-2J;1cbAGCQ`mG,WMXDp+Y/GCNj]6oA5Y(5.Tq&_3g];,N=j>!s0%GA&nX5Fo@W5:QLH%9(9cY)YHAJ#P.LY/Z`ZA"%K:`ZZ^*@obas9l_Dc:>\2I"bBGU\KNqb19Y`dB5e`l&N'!3]K"[jqe^nY2;f0+17M",+g:.TM`DdXQG%nq3;FZ!&\dB~>endstream
|
||||
endobj
|
||||
27 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 3106
|
||||
>>
|
||||
stream
|
||||
Gb!;fgN)%.&q0LUFQFr5,?"Y^Ou?)W-eXrfUuL%`:"?tR+;0hSP6Ad](%'WA!C0Pi@uGL7PM.Ti+p]I@c-q[?5#!c#iK*7'UQ"EGCRVtK`*Bl['D!6:Y2@Wrl]\M"XM$EW3MB5i2fu\D95qk<"cJ*#PJc$8o]Q!Sbe0cFjlQ^#Z!N%Jbiq1+r\!ng4=q^QqnHB&cC8`7.oJYF5'XBTs%\n)Ng#oLH.n)'F])6tV1Qap&c(>Jq?pU/U,;b'^\c`=Y34$L[EGgc3Hmg\_ZcUjBe_,OrQC&($l>UqH^AIR_^Dn^Jo4;mFFn[qk:E76`b0PmB<KX+hHpBFi6Pf39f:Bb]&+CJVaUkr2$h)g#D4^cFU"87iL1OBhWO"T7&9@J+>Xc5Zle2=2>+Z]B6kkI)*Ypm"k:PPbROIrbi^U,4KZMKA^C[X\q(I:.!3<kadiMr-25=BMhpZZp:fVb:M*Vj]@LO^am2@W,YP.1^0W0!eWQZ/Li1Y:Wi$G)UF,([Y/d7JH(<q7_YlTSA<YUjdU\QCOu004cD175'J,d$*an<A-qD(Y.e%:("MM=0E_T,3(!9^1TI<6K5jF&TnAV:WY8)T#\fnej77j#X4@uLRK%UOc1eB6Y!Tj2<OLFeqQMHnH=d=17BG.i%K]8d18e]?YI_+>O_Ef:r;P)sVdg0g0!BH%uXXG^ZH&See&6u[g/ZN;7Gr')Acb(q@I;MBKbZ]u+Al\Dk%)+HiJ,asPTh:QY8H`:/*f2YEcrOaFmVkLj74AUAepGPac8VJf&ADaJ<@Kk[\#j51arN@Y1!!H&TsbYbc\k-."I&7T;Vm@?Al\Ib\N@dXX/ZAl8&S,4!a+<D[@5J\q%Z1[RH/K;&>ROSce?:8P;WJFMJ-38%9T2ZIF!A_WTRht_UN)0APMB(M5+.i?Rkhna^]mXKE/F=3<FRRZ`M7\Z)NiSq0_dkD?ODdO"6)abEV6tNF-*%WhPXm3p)dY+So/:.P"V-2K^Hn[!`4t5Oe-b#rFCPc@!Wt+f^?D31DRK])9STWiY$MbfOr]Tnb(&l1nID3P.(')r>*X5Ho=iT`?e<HJ$L.o#T&@@5f82]afA@c0Ng!bj/[rn'2LFbe,!?o=>boo6P*4).V;t.;D>cWZN<6&Hc9f6A]s-&%oW(eLnWQg&p!OTsM<_P?"C'`rpO#+\QD6M2nqbME8kBs6.?V;RZkhi_WW>d5O3NB'YooJ>DgHVTq5n3A]:Y"/rn#MF&X$q'O2eAIWZGWO:4^+qr<eW\ESXPA>;k<GJ?CM&=Pn7AR$MZ8(%B(-0]VLK%]7>`p<(&P"r[a$A^kfk_[,_I?hg<ta\YWi^(IjlB+9,JN,-#c_E67cLe@VqTru5a9KW1((SuMBXsF:mRjt67@.BOr+<bGiBB]M0>WtHZ%fg!r%8'Hu+oa3:+26i18_GfDND1&dCc@5K4]"CJT@@\@[W^r/(AZ$i#)>^e&s9S1oUNHg15r(&_X'Al[h.Ni!F]X1]u:l8?_jBudud4J##%GD\%J@pU6/VO?o,,ttd'D).f;_"ks]0iT\r^Z7K4AZ-q(<-a?F)`bF!LmoS_:^)\i<Ubr4k_'94&[K$K'^mV6MO;#+lWNj#&Z[=[`/^O&=0d_9a91a+B"\kG4q3=)JFg^jE7XCq>tcKVkc/4u>-"=>CED"rU!`tqRaVf4eI'eSDGo\nCcR7)?_p4Lcm);'_-?Sh6j]EkY'1SNg!$^&c<b?RDk7#b<N12X5g`i4U:q#T!MKPO3/YnQ4_970U3]=<,b>(^?Qp=NU)\?,a8r09,%@s^Ffroq-bs.jl6k)R?f\4*![2lG^mp-]h!Ll)Z5#(EW\j`i/[S/j/+U\jZHEbk=!MB]Q//1bP&]?!'GHR&iI**.lh''l)LblUm%P1sdU\RghaXiT.otTuOa8p;jI17_?gX#&X87fZV1M2r\A:.**1u5f[WhB8>%V^L^'i"$PYn#8Js041kGWWCWp+@EG1@:s0hi?'VC+I!VV%i=`ru2YV^q$mmuE_\:>9*QGA-p'g1=p3Ri8BbO-0XX;KX"DjN9Rug&_Ae?DD%9V)#VZonB=OOD'3ED3OK0pQq^jDe#g1\LPZJfa)!Znh,M-ljC)%"mhKeC=c>GZ7HtkIh6P@CumqO/l7V.l?n/5Qig0nTi@9Z3dUb8QC-\3D&3MRZ%goiSmaj"\<;PShlefqrebS4Zrcnpd)nCkZ3IYHA'iNeEIUFOG1nHmgg/;:K>'q?rNA-D>SfHb-Cl3#pc[E-o0R'Z,r;"kaE*cM=!0PqhPuG_6cFS[2:>NG?08@=[["K*ApIuuButgsh;^Z5h#lKIDtcAlFlWJZ/)TsApBL--_i(ERR6N7\U8+=-I1%RB^6.O;$,I#!@JsWVY<bhh\e4Z?4PV^??>-ZFEis^FiX"d-\6>4\!pA!]/jGld?p,j@D_1fYi`e"WS>VuZ[eL8c/GhBU0AC)jRf0Yo+c63LhkJP7<T=^o)J&!Nr=MG8As(.D..1*UAOgQK<IQ<[VY%-/;E_jT<csLXHKCf,L`$j8fGZ)sQicO%*f1l(ik-M`,Gq[p?h[Y,Af0_f@L]AQg]_%*Qk#)$r7J8>!Fq1bG5[E$n^LrHmT$U9OrDF-L9=,apW`r!Y71ib<-MXE5!g'@7dOYceZQZ,r.K;&Mr_CSb#oF868]1KoS6\"3qE9uR)l9?(F\g_]M]>72k/f:ndY"fjaS)KFtu:210i]\n\3q.FYkc+-Me#=G"&m]s%E$E7Nf/i36?)M&dacPWA"StpNtCIp3=6DUaA!Y-\^a62bSoHXf"@O[Y=;G[N"^:Lj6n6)a]e?-''Kg?hG]V<Vnm&RAYC(epW8$f1NP%;_R?5\U1u[ckaAS;(OS^&"Dr(oN_!dN^3jOWr"25+5D!oUu,>"g]OrJ&8P"U>5HGIoQ`Xhl*DH)o[u=QB5^&-rFJcsorsq;`66Bj@8ENKJNgE34p1`N[k)h^Hiai#`B9V-*NjN1H5p5JfC[eIcPD1T`No.q[8"gGkD&#=56,o?\T&$"Hd3E%`0T8.Sn'a&RJBS"bBNhg28B,ncp]DJPceP]^hqEWMVa_mUAWRfBh[gTN!!]1C`'['-Od]+Q5?Q2]2Uptk2,V]3r(F#Hm<>~>endstream
|
||||
endobj
|
||||
28 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2796
|
||||
>>
|
||||
stream
|
||||
Gatm=9lo&K'#"0Do[,a3d3L$#dcBJ6`k1)".N)Hi4`3P2,U"?<K`J5gJ,R(c^LS^XiYn+5&HiA)U\f3.CEO`AIt@J]abek-kkZp)+Jcu6Jr)F[`<Z6\EUiE?BRVu7]kB`P3;1?(VI7@6VmBMc+YO,\BG0?Hk)\[KQ0Sf/(N_`tBououOFjH9IE$&0#(]::[BZM$Q-*!NOAAL:J#>B%ZDs/]TZkuK-rRWThkg,R/\]Zm2\tX2r4Ne[1(M"]]N>?7KX7p"dj1C0#j4UbUt'B"hE75q+auQ:3<;=K`d$33)UH*"4XgfdqP?d"4Mpr5SM\;WhE8oP^NE.*KoOtLa8PSj*.S6C[ET_FHtVO0#67IHN'hMe)sMTJO-IEf>-,Zt>g^c:$XGM-?H']2Ji%senJ62V:?AOGK[CW$ru8Rt<*$,T<P!F[L[QR"TkCq<Yi>r75rbFZJ`C]5JR6#t_T;^bkIJY4Prg3Y?K?Jcp?$8]:J\:OPr_u9'l2N7_QJ!K[rP`2b3Jn;U:o(HO@"qSc]AoA6`/f.FHA7UuD>#e]pM9'm-XUE9?+kAE8Kr+`;_FcbZmn"d]1gNrIh$V;$VK-6Z9"Lgh-uC,#&j)W\8S!M5SLd[/h`GK28Vj^ejci&iT_"W?n"d1HY#Qh"L6dTirA`h4/6/S6iHKlY'#@B\g#3:0[F8^YV))#D$Wtfl80`1(Ot0-tZ*L,NLJM&Z3OOiieM:'8j3a3?at$uDD"R^?UM_r!+dI=Y'gQ.d_[51q"tt@($QfrF.?bs(4d=P0$\Q8lru\f,BYC5Wr7`nRXPV*OjS,2pV0V7B+Xl>XTRNY^kJ(h-F;?>.9)C7j1:s5j,Eo6<%+t,9h^Vc#HqPXJ.$N3`dV[mU3M$Cn3m"MT)9.\3lMR)=-;06t\5UB'O>FbHR4MR"A:"po4pSBeE,_ic_nKC>,163=;kcfh&%W5`_0c_m&#JEXPaq>V349tu#*mWsa2_t`"!s0giZW"!Q&?liTfU\0()fqZFXbd\i##M_gLl\7nll8/VmM[J%8rN@\'f;9mpe?XY*5uhceAOiI^ai$c4FV989BQ>ZVfrm(k/$uQ)HHb1,]9AWAbk,'CXn7c544rRR][/'fZW4[nB'(golpg#JD4]8sMAs@BCD>8q_*h:$tNOY\X:U`#LR%R"bhhX&m_rQA]&O6l^i$%0tr`@B0Lb*DsYWh!jj'qc8nk&tf?cUOoBiI#q<2l-=Ll8*H.0Rr2;*VAp1-;/u$6:,6Z3+c=@)JA;2`K)l#YfP(V=E1n5mo*ch?;_K%aYP30O"VHlk*%cl,WUk?[CqVoL:k6Sc3t@phf,uh><H9dg,SMXliB@\f"r7!CMl"o_1)Rj5nc)c8YTmY])FB,6D5$]l9\5H!o8HCt*7=AT-G[",iY2U:j#?P+PKCGd2,udjOlSRfEX2lZT)lkUGU$!IS[.fkQAL2K>a-^L,1Vl6[*cG6HY/Zu-Kh%tTW70,5H(.pg.Ymr<@7:*_q)5Xjc`:r/N@U\j,dYA"mJ"aX[%#ZP]nD$>STEdWKH4-)fl@1g62^Ojo[BW)[ct=Q&E?FriA.fNs*:<q=Vqj,@!XCM^/Qk>Ish:S.U^,?RjF*i])bf<4qAtS=Io/IR&2Io=VA<R7UNN^Z%e.J>Y"u`u-$TD<1fcPq%d,c34-iOkeL.l71KZ?A6OgBqp(8%#;J"[rWBGaTCW(JGePN>8<j?""!1LXf2^92ff`CK1^3k?DBnBE-#K/,)ULHkIG>Z/"r'-%-LW!7Jint3%5#Xh3I#]FWPm-3ch+`*(5P3Bt\cnUN/Q^@esDtgD)+G/7*#Tj_`24&N$)XPtLo*d&b1)$0MoE'NM.E,P:gX7cJ)HU:?;=!'Zt)&Y-9`a_=F:ZQTY,)H$)kY]]2fa]]N+-*omS2mVi:mo=*N?ZR@'Apn[O4bnPRAU_![B3SN(Bi'%C&+=FSYF$C!O?-XSa>G>IDMB!iiD`C&\9C=KIa-Zsaac5;dn\KE/(5&HD'fY+n$L%]d=A=`K;88IW)"oR[,?<[!r+ATj:JS\/\V6g@ciZ=9:)Rn7%[:1P\+Rq4fH<.W:PpHPjZ[Ai<l62j=ou,!n0O0Lec;HX.7^34Fpiu=$p%pl'VBN(@203cPLOh^TuZ<6uI+dN(C15:'"iE%`l^T.YI;(?1:kO%]WV1qj#@JaagJY[T-#-5@OTo-c\`^CFV^d"IA9rQm'?79"AKgC%eT6SgB8H9)M1bej!(M,,s6H[4#HM;/G(UA6i(g5*"TNPhe5MDj3p1.\o>6``uM(ZJ]q.;UWI\Rn(aS21K`B$^O2XMphj*59Q5m+-Wk0o1N@CF"bj'+M9:#SIE(7=?bn9JTt"bFNh9`E&'.!Ls`J/G,$R3SV&?BBfB%H$Z<[3B(@';O:1XfZsc5Yq/37p3>E>e7q+F[+3@XB,/46R1XM!H'R,JilgC'c?)^f5El6G&.%\$`[+WFSZ%-Tfm#_7e^TSH8Z$t/[rEpZMpl,,q2PGnU?@ZAKNun"8pHV`f1O=-fIadUIlg^VGWZM2nlnFkQV_o^%h2SpG_.LWn]9/cP&,P/Z6*Td\gYGEE:ThruCNh#k4g5fDm;?J$GIuJoDT&0VrFiUPZaecF2;<TS&m4n?B%oNfG8n/+J%#@'6+?0=hb7.'Y&UPZE6?e*4B/fdZ+h2-T==6!_QUVn+Y;k7MYR_8IH+n-LW?oRChmC\]l@<S_?=ZN1ONZRn]M0LH=<<g@XkocE%(LujNEQIQ^UD9W*S)+",Hi]=K=bdrEB.*IXD:LUBRP=:)=06P+UE[!h[q,&3^1U?2Pf/XpH-~>endstream
|
||||
endobj
|
||||
29 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 4331
|
||||
>>
|
||||
stream
|
||||
Gb!#_>Beg[&q9SYQq)'rVK`b#SN]&oSb4$OF^o(]%$?XiRNbiH[%mTm3s1QejU5c[kin\/<j4gj"5q)@2]$bti]g6;5McEg\;0=6#G)J#C[aY<K_l^u]Kc5P`+ts7\P<jUg^fnt_H3t^EG(2`++iNuo-g-hkJ@[o5*VNM*6p>r"E;,nd=(LrC@h[hIHpV<r3BX1>>]G9a<,[SMB1"hHslJ3-8tmSqqpB3-PGE?r:aG-J*B&_\0*fG')J:85Su*2$s/0jF'/Hai[ginjQ"-NHH3p!*$^iY0*M_"2aP.<0:ho#?h,#YZ?aQ5(@QUl?d)^DNd*#F:oGUGns0[(48PdVkff'9aUO9d].e2u(XjKNnXg#M_PaFGjM*r-*gIQ"iY3I1Dfmc+Ye'qqS=44q3,5,8b9Dr'K\JWcOeM6>./.Y?mEHEVe/oP,6_.i2S&5KY%rKL8*:lM&4Q[3AWT7X5Sk\=H;/jJQ5m0gknZ4C@kKh!khFE&5ih8A=82p0p#YTq0_bQbAj?uU5bjcl++WS#:Qs>Pif1pd@-0dU$1)9W3W<LHk0'!j04NDqU%M(0[T(?(T"E8!M"Bg^(XPKHBjh-o6BjC33[:IFcC'%Bd$BC`V4Ra=?`#@BL]K0inP>'OB'_kB[0jC!?pl8O41C0IX&YSt^C@gSdL>S0mK&tA'$VWn,=X?ApgrNL#4B[O0;ou:sl0\FPYKK&7Anb;@?GJE4);A6.10tHl"'6([:A"Pd7D!U8/JLV[0e:m+bu/8_bit/01AsCkc"MrKh:Cs9S'CO-,[4h"n1<PQ25"Z59(NK+c),WhVAt0/99;8Kq%s`W!LaR884:"OFM6h5\jQf_ZY_*?K'uCofm[,q6Bk2k?"]RNW2Nd#>&=L1(G'QUXmN1l(Mm0WFH_@*p:MA5Rbc(KplO%=-r]2<=h6*9W%eY]%puaKoo#!n)'Nsf*KYY=BL;J:TTm=J9i0_=e-m#K4'lfL*q)15(Q0;_)5FX9a&jnA7s.u!YK)kliB\\&8sYHEd-@@NP?j(2OAo\D3Y5>(Mt'$ZakeX2.G9#Lc$=d\hJ>rHHB$33IH5>plQd3T=S(=6gXnKOCVA11fWi)$)=SoJ5Oa<!d?6';:-)\SaH-lT"df9?MGcRB:.K!@4+\l]U4:@o&4fOTFpAUd\WG,P(Z;](ET#_G^Z"2]Dc3#7iLaAWa<Ha-&n%"Q&)`2K"On$d7V:6po+qdm,jm/D=b<<'CK+V%pnL<BKDLD<_>f_r/,=BW*U88I(@U5:f,LN0>F%LDm6tkeoA]b>?s47X["a-D=8[E>B,U(Z:0sX<AVi5hgE115Mm&`b<OC+==O"`n.H\Ff?9%dlYE]hE7$QMQ4nt#?[R**L8VoIa]MUp_d?2XgkX7W)*$)@&.NT6^DIn?r<,R](3i`T/Ve^Ygoi.se.^?@@q]oiWKd>\=![k%cJ#nPZNQK$^k]r#28;$7)UXip<G".4LE(0_XW1*NP;Jf(*$H"*&l6,Qci<u4r*nGgJ*`g.A]&7'HZRSa$%+k4lEjd4-Ze49B9_f1_f9@RQafC<KP)Qk5#*u%GPgr_IA9-s?Z2YDt=dAqT4HXYi,K;0sOgLLV!7bq1>>I+$,aDX+6%fEE7>h&kJb.qrMR+rnmS<TOYnG(CcKrAK;+pK,au\Qm_$o5q/TTdqX>&@Bg+qK`qQ&W;@u:L;"[0+tTkYmb?6I'[lM/0H5*[ETSg.XK.ea`ILf.)@nk*UITTR_p7f!p(rnYip+I:gC7'hUnS+HK,e6*t9hJhHTHrCL[6lJ0j']C6G<L'`]csUJfFlNcp.5Lgb+qfAJVbcEGJnF,["]q(24hH^X)RBeH*B^3!]#sfoO<#aG:0VJ6#Ei'hYR\L=0.jK@9109$>=q'Xe&ZQ2UJHFZI3c8_/`mfTqP=F@I&$6tn"]&hGN(*scm1$6d2FW>mHLop&4Mk_9*1GG=[liDho,q&'@QXr*F0J.fPlC#U"JcB[iYgFh3HbRk"J3A&V%_!EA=2LB;kGa?:?fJl:uYeZ$Wgf7Ip1_W3\"3&!tE`SlYaDUk2@n-.8;2;*KqI0Uc/:.'t7=EVH(pD':"ciXW'G16N?C[N%k'SY'rc7f[.Dc[#g"=\;Qgm"p>bBc^nE;UfS#7B&UqW0%hJBW_4ee5SKUes9W&'JW+e84e0[46'DI(!J5WcRZ1`2h+4Y@(&Oqm23@7m_l:cirkSSTd$Dr?<bHDCe.8@aJkr9/bDM']A6ci,!)ITQ]#NW057fRH7A@!#96eZ:?@`?-90\LgS_'./4Fb"I<j<N9b_7L7:'Ai]p"F,#q!&Tpn/L@F,l^"2`Vtd;3f^5h<DUOCUls0Ijk3c)kIiPWje!f.%8(:gB1[S>sXc"5N@U7"_r0@;q4%X`-d=o\+GaBO@.(faapWF-T-"q39DDIP)KjiQB7%?4AVj=Ni)5ib%$TJcrh+G[a$Xc?>E]:C'AC0b;;\*nPDs%4!W2)7D<-=R0aghEm:@(RFiYG5-bk7Ai?b_Oqsdj.@$/<:pG[RgQc.m.:cUPSKe;mTY!$#03"Y\ZeQ@q+1'?GC']r?Oe99&MQ1R8)AIV0;s]tngc,sIVanssNM+\!4T?2\gf#%0/SH&(',;4sO'MS=.P$158Fg;k^3`-1Vro==Q9Jb]_rt2,0Eao5j(Q3)EG4'qd7]ufBr!*H@U_+i6@71ph3WcAZG8[_HTqMDJ!&^G+Y@(bVtnlMUt&U23,eFPO>jC5j"MC"4jrDP-Ge58XB!"30N3(BNF*A/MefWP;W1@2BeM!iR]OohX$t,pA0QJqUh#tadn*,EP*&3lFA^N2,Z6(kL54!.J`XF4&fN:9Z9,+j4CHj<A&kn2p=rs@@@CEX,)VQ><M`#&kLUVK4mGb5B?(&"?PlFUrriQ>HD@hq:pB]qKfsB3R8UuCeA!?h^[](r:N-YH1H]lpA`Vf_jN\PbI/S/=[f<=.34r0iWOeY:Sa(3aDr$%agk2MHs(cP..f%uoW-6pjX,_=`DO[5cM^kjn;/OdWMN4/IeM]SYgruPJD3(h[eigT]HD1DaACI=-VFQmk2a+0XHG$):5e#Jh?#crmFPHF9Nn*d#[cpYD]'3(/e"bE=e"bWoa#.A;h+7.Q9VInes2h$R[\'#E3q/TGbJ:"SIdX7H`/jmHP%=E:FfXC96lHU.fmWndZTHUbYt*2glIaum:`g6'UXDb0Fc%Y]fT7HT,32"@7n\FMdoj>eU)q'<Aq+>[]fL`iMo+"!r'4,hE?:1CID4OCC*5:lU0@TgK<)9o?+g'C"q?Eqs61sb^S?BCVJ=kgY=?-Z:S$b:H-?U>`<X%q1i/kofW%nVrH;+I>eu`49Pu_7#@.<jBi<`GJ0!H4E[''T6Y7h"f'n*Y5uh7EB.hm0P7X4B:N+Z$l-FL46^H8!pg5&M?*Ei>UaaktQXU2i`EPT/&Y77d`+NCeSsZL_G\cDRY2d+-(X1k;$+E3p"15(.O[=oMBpiYocmD/#="Eq_!jd3>hKst'=nU)t!e!J0.4OVYcOeNE5-Jp=<AmcR>A<ff$J8<"ZKJrV.7e8jpu3ld+"688H+BS+"@n==iSlPF3P2HU]o#5AdT0Yab7'^rFL)A!:slc_YI,n0$dJAZk]uM/<u3B?o@n%+IWDXogStdZOVA@iH<`GuLM^/%QYAQY5bTlBf-bq]Wn1sZ8MA5^j(-])2N.^.D>J8qJ2.<EX"3sY,$jh&JP$1>8@Vu&\aR2>d?LsqO5GUs#(mGP;%OoYIOWXOI]Y/ZePba'.N]?9lq-lD:,Z?;6HW32*!O.$PMgSAFd`+m\km*_=c+crq;g"VeNGT76T,,OMlrW-1hDkN+Y5=SVCTATHTS81S7e_==W:Sn)qkmOb85TAVl'i<j$BX;R&APcWpf0m^AV.0,tj\_VH&WT]Krd;6W+t]C3=s.q]HsVD!td2#DYq.=6kS8\-?F#1S#B6/k>mNQQoZp7qQssW'q>Q6tfIo1L=N:@2+!Y@O8Kp\mo:ji)F..8t/PHkh,\h<@TA6o@4u7l]ciH!h-a;Gu?N\M:,L%9o\V=:(q(m'/8501FjPhAt)ce<)MKSY&d2Q_VUbhGs\I+$+`&\(%Q+A69Weh\^A`G"+9(bAKl(.M>l0b0D/mi"t[;kFt_'eFal-Z]"$,,=9Z6]_l<RbQ>1B$;U@IBp9Z[O<krQ$TID7@bBuZ[`t^'j8j@J`L=;_<W>c.h3tLMA/+&Ub7!&s"l#C5T3lZo&Y9?^SbL2TF#u\,+1l@qB``LMkJlcK6-D`[D-'mK"8m$Lh""(r59b2kFRnZfV]hU+hCDZf7/NMZiUFP9="rYK=aMKoTmlDE97DN*8OaRj=M+e+ZKWcWWu1PPQ([W_jO[~>endstream
|
||||
endobj
|
||||
30 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1034
|
||||
>>
|
||||
stream
|
||||
Gatm9>u03/'RfGR\;tGEDOmXr]02Es0Vj:]g'MJ3DK6[Y&j+_UXT*b$%Ln,?<(JE8U=f2]hfJ4@3=P-1iLNsK-S+DQEo7P22S`f<_eKZg<kZ*dJ$^1)=;!t>7;S3M$gfVhNT(8kB+gCQj"]_2!iG/n)Xe0oS.TKNn5GYJ""#W!d4q/*?'GU6D;[5,A#X6#p*7dYjD_+n"kU8udJ=VNF;@8nO2aaa2IKDD+B>9hW=%?+LKle>)l'GSCfoM<(h'NOE#Jji>&@ft(sHE3FTI.oCT6)N=T*p'HT6>5mY8s"?d1m`)7R+e]00'Q)a/\L1.W:4V(-<Z:Jp7Ph-ti9i'Nf8FIqB3E=NAO$R;Y_<"N`R<>jjYCFW:Y32mX4,D@6R<@qaTq?C_PFoXFF##-XoqGj_GJ3$%4oX9OR-KBmkhlm*`0[HSJBl;l[Q?>^<L*]WB7g:H:7"KgY6PhSJQ8][G/pauKU6[&,,o)?hi3+1ulV_?L%pkSTq_P?F6;&Zapk*l<$(JS5l7_[obOp<CJ3g4TPG*[MD._iI]0>^5oJrb*FsK^Onu!joSUu97+r(k]7W=Xrh%KQi,7XJqh%0^TZB/j)8p@N)P7`<;/GKbKlPngL['&A`+_!XL74DL*hWHca).+MoqFT@JD(*SS)RU%4Yc>e_W@/hA!FeE^@D0R]kTpQhTGPr<no3\$PIGB!e(H9?FLmpW/pM`<S!&@k,'5L.!Q4P]eSHrD?+XV&\3>U^6OV,8QcI:goV*Y_3nQW9G-]OgM@srDZVk"rFC:OM%l:"VIEUIW8]nY3I\+MroRteD2X0q!p7`R3Z<aFl#t@C),>#,0W]>.^(=l\.m?D%T*E@4[SN#[cl\b$W#eCkAqG1g-1)c4K(g&iWPD%CD)5Q:0Y0M?0#S0'%EU6+57`;cf;3H7m_I!VmhW'<YH:>j0FioGQ.r.hGK/%c\El_V/g&ae"lWM'OI-[s<pg3h:!01Ke1S7t1h-U4Ll53)B/8NK'B^>b[U*30.rWKd)60^+M9p^;#N&pT["&jJ7,Q~>endstream
|
||||
endobj
|
||||
xref
|
||||
0 31
|
||||
0000000000 65535 f
|
||||
0000000061 00000 n
|
||||
0000000102 00000 n
|
||||
0000000209 00000 n
|
||||
0000000321 00000 n
|
||||
0000000516 00000 n
|
||||
0000000711 00000 n
|
||||
0000000906 00000 n
|
||||
0000001101 00000 n
|
||||
0000001296 00000 n
|
||||
0000001491 00000 n
|
||||
0000001687 00000 n
|
||||
0000001883 00000 n
|
||||
0000002079 00000 n
|
||||
0000002275 00000 n
|
||||
0000002471 00000 n
|
||||
0000002667 00000 n
|
||||
0000002737 00000 n
|
||||
0000003018 00000 n
|
||||
0000003154 00000 n
|
||||
0000004517 00000 n
|
||||
0000005414 00000 n
|
||||
0000009908 00000 n
|
||||
0000014330 00000 n
|
||||
0000017696 00000 n
|
||||
0000020855 00000 n
|
||||
0000022666 00000 n
|
||||
0000026229 00000 n
|
||||
0000029427 00000 n
|
||||
0000032315 00000 n
|
||||
0000036738 00000 n
|
||||
trailer
|
||||
<<
|
||||
/ID
|
||||
[<eb678512918bf07910151fb153fef42b><eb678512918bf07910151fb153fef42b>]
|
||||
% ReportLab generated PDF document -- digest (opensource)
|
||||
|
||||
/Info 17 0 R
|
||||
/Root 16 0 R
|
||||
/Size 31
|
||||
>>
|
||||
startxref
|
||||
37864
|
||||
%%EOF
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Stripe — Create Checkout Session
|
||||
* --------------------------------
|
||||
* Creates a Stripe-hosted Checkout Session and returns its URL so the frontend
|
||||
* can redirect the customer to Stripe's secure payment page. We never see or
|
||||
* store card details — that is the whole point of using hosted Checkout.
|
||||
*
|
||||
* Flow: Pay Now → POST here → Stripe Session → redirect to session.url →
|
||||
* Stripe hosted page → success_url / cancel_url back to the app.
|
||||
*
|
||||
* Env (set in Vercel project settings, and .env locally for `vercel dev`):
|
||||
* STRIPE_SECRET_KEY sk_live_… / sk_test_… (server only — never exposed)
|
||||
*
|
||||
* If STRIPE_SECRET_KEY is missing we return 503 with a clear message instead of
|
||||
* crashing, so the module degrades gracefully until credentials are provided.
|
||||
*/
|
||||
|
||||
// Server-side source of truth for invoice amounts. Prices are resolved HERE,
|
||||
// never trusted from the client, so a tampered request can't change the amount.
|
||||
// In production this would be a DB/CRM lookup; mirrors src/modules/payments/data/invoices.js.
|
||||
const INVOICES = {
|
||||
'INV-2041': { title: 'Roof Repair — Leak Detection & Shingle Replacement', amount: 184500, currency: 'usd' },
|
||||
'INV-2039': { title: 'Full Roof Inspection & Storm Damage Report', amount: 32500, currency: 'usd' },
|
||||
'INV-2012': { title: 'Gutter Cleaning & Maintenance Plan (Q1)', amount: 14900, currency: 'usd' },
|
||||
};
|
||||
|
||||
export default async function handler(req, res) {
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({ error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
const secretKey = (process.env.STRIPE_SECRET_KEY || '').trim();
|
||||
if (!secretKey) {
|
||||
return res.status(503).json({
|
||||
error: 'Stripe is not configured. Set STRIPE_SECRET_KEY to enable payments.',
|
||||
code: 'stripe_not_configured',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const { invoiceId, customerEmail, fallback } = req.body || {};
|
||||
|
||||
// The static map is authoritative for seed invoices (amount can't be
|
||||
// tampered). DEMO ONLY: invoices the owner created live in the browser's
|
||||
// localStorage, so the server doesn't know them — accept the client's
|
||||
// amount/title as a fallback for those. In production every invoice would
|
||||
// be resolved from the billing DB here and this fallback removed.
|
||||
let invoice = INVOICES[invoiceId];
|
||||
if (!invoice && fallback) {
|
||||
const amount = Number(fallback.amount);
|
||||
if (Number.isInteger(amount) && amount > 0) {
|
||||
invoice = {
|
||||
title: String(fallback.title || 'Payment').slice(0, 200),
|
||||
amount,
|
||||
currency: (fallback.currency || 'usd').toLowerCase(),
|
||||
};
|
||||
}
|
||||
}
|
||||
if (!invoice) {
|
||||
return res.status(404).json({ error: `Unknown invoice: ${invoiceId}` });
|
||||
}
|
||||
|
||||
// Lazy import keeps the module out of the cold-start path when unconfigured.
|
||||
const Stripe = (await import('stripe')).default;
|
||||
const stripe = new Stripe(secretKey, { apiVersion: '2024-06-20' });
|
||||
|
||||
// Where Stripe sends the customer back. Derive the app origin from the
|
||||
// request so this works across local/preview/production without config.
|
||||
const origin =
|
||||
req.headers.origin ||
|
||||
(req.headers.host ? `https://${req.headers.host}` : '');
|
||||
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
mode: 'payment',
|
||||
payment_method_types: ['card'],
|
||||
line_items: [
|
||||
{
|
||||
quantity: 1,
|
||||
price_data: {
|
||||
currency: invoice.currency,
|
||||
unit_amount: invoice.amount, // integer, in cents
|
||||
product_data: {
|
||||
name: invoice.title,
|
||||
metadata: { invoiceId },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
...(customerEmail ? { customer_email: customerEmail } : {}),
|
||||
// Echoed back on the session + every webhook event for reconciliation.
|
||||
metadata: { invoiceId },
|
||||
success_url: `${origin}/payments/success?session_id={CHECKOUT_SESSION_ID}`,
|
||||
cancel_url: `${origin}/payments/cancel?invoice=${encodeURIComponent(invoiceId)}`,
|
||||
});
|
||||
|
||||
return res.status(200).json({ id: session.id, url: session.url });
|
||||
} catch (err) {
|
||||
console.error('[create-checkout-session] error:', err.message);
|
||||
return res.status(500).json({ error: err.message || 'Failed to create checkout session' });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Stripe — Retrieve Checkout Session status
|
||||
* -----------------------------------------
|
||||
* The success return page calls this with the `session_id` Stripe appended to
|
||||
* the success_url. We retrieve the session server-side (using the secret key)
|
||||
* and return a trimmed, safe status object the UI can render and record.
|
||||
*
|
||||
* Returning here does NOT replace the webhook — the webhook is the authoritative
|
||||
* record of truth (it fires even if the customer closes the tab). This endpoint
|
||||
* just gives the returning customer immediate, verified confirmation.
|
||||
*
|
||||
* Env: STRIPE_SECRET_KEY
|
||||
*/
|
||||
|
||||
export default async function handler(req, res) {
|
||||
if (req.method !== 'GET') {
|
||||
return res.status(405).json({ error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
const secretKey = (process.env.STRIPE_SECRET_KEY || '').trim();
|
||||
if (!secretKey) {
|
||||
return res.status(503).json({
|
||||
error: 'Stripe is not configured.',
|
||||
code: 'stripe_not_configured',
|
||||
});
|
||||
}
|
||||
|
||||
const sessionId = req.query?.session_id;
|
||||
if (!sessionId) {
|
||||
return res.status(400).json({ error: 'session_id is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const Stripe = (await import('stripe')).default;
|
||||
const stripe = new Stripe(secretKey, { apiVersion: '2024-06-20' });
|
||||
|
||||
const session = await stripe.checkout.sessions.retrieve(sessionId);
|
||||
|
||||
return res.status(200).json({
|
||||
id: session.id,
|
||||
// status: open | complete | expired
|
||||
status: session.status,
|
||||
// payment_status: paid | unpaid | no_payment_required
|
||||
paymentStatus: session.payment_status,
|
||||
amountTotal: session.amount_total,
|
||||
currency: session.currency,
|
||||
customerEmail: session.customer_details?.email || null,
|
||||
invoiceId: session.metadata?.invoiceId || null,
|
||||
created: session.created ? session.created * 1000 : null,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[session-status] error:', err.message);
|
||||
return res.status(500).json({ error: err.message || 'Failed to retrieve session' });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Stripe — Transaction history
|
||||
* ----------------------------
|
||||
* Returns the payment records written by the webhook (the server-side source of
|
||||
* truth). Reads from Vercel KV when connected; returns an empty list otherwise
|
||||
* so the frontend degrades gracefully and falls back to its local record.
|
||||
*
|
||||
* GET /api/payments/transactions -> { transactions: [...] }
|
||||
*/
|
||||
|
||||
export default async function handler(req, res) {
|
||||
if (req.method !== 'GET') {
|
||||
return res.status(405).json({ error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
try {
|
||||
const { kv } = await import('@vercel/kv');
|
||||
const raw = await kv.lrange('payments:transactions', 0, 199);
|
||||
const transactions = (raw || []).map((item) =>
|
||||
typeof item === 'string' ? JSON.parse(item) : item
|
||||
);
|
||||
return res.status(200).json({ transactions });
|
||||
} catch {
|
||||
// KV not configured — the frontend will use its local record instead.
|
||||
return res.status(200).json({ transactions: [] });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Stripe — Webhook receiver
|
||||
* -------------------------
|
||||
* The authoritative record of truth for payments. Stripe POSTs events here;
|
||||
* we verify the signature, then react to payment lifecycle events. This fires
|
||||
* server-to-server even if the customer closes their browser, so the
|
||||
* transaction record must be written here (not only on the success page).
|
||||
*
|
||||
* Configure in the Stripe Dashboard → Developers → Webhooks:
|
||||
* Endpoint URL: https://<your-app>.vercel.app/api/payments/webhook
|
||||
* Events: checkout.session.completed,
|
||||
* checkout.session.async_payment_succeeded,
|
||||
* checkout.session.async_payment_failed,
|
||||
* checkout.session.expired
|
||||
*
|
||||
* Env:
|
||||
* STRIPE_SECRET_KEY sk_… (to construct the Stripe client)
|
||||
* STRIPE_WEBHOOK_SECRET whsec_… (to verify the signature)
|
||||
*
|
||||
* Local testing: `stripe listen --forward-to localhost:5173/api/payments/webhook`
|
||||
*
|
||||
* Persistence uses Vercel KV when connected (same optional pattern as
|
||||
* api/hail-webhook.js); without KV it logs the event and ack's 200.
|
||||
*/
|
||||
|
||||
// Stripe requires the *raw* request body to verify the signature, so the
|
||||
// default JSON body parser must be disabled for this route.
|
||||
export const config = { api: { bodyParser: false } };
|
||||
|
||||
async function readRawBody(req) {
|
||||
const chunks = [];
|
||||
for await (const chunk of req) {
|
||||
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
|
||||
}
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
/** Persist a payment record (best-effort; no-op without Vercel KV). */
|
||||
async function recordPayment(record) {
|
||||
try {
|
||||
const { kv } = await import('@vercel/kv');
|
||||
// Keep a capped, newest-first list of transactions.
|
||||
await kv.lpush('payments:transactions', JSON.stringify(record));
|
||||
await kv.ltrim('payments:transactions', 0, 199);
|
||||
if (record.invoiceId && record.status === 'paid') {
|
||||
await kv.set(`payments:invoice:${record.invoiceId}`, 'paid');
|
||||
}
|
||||
console.log('[payments/webhook] recorded in KV:', record.id);
|
||||
} catch {
|
||||
console.log('[payments/webhook] KV unavailable — event received but not persisted:', record);
|
||||
}
|
||||
}
|
||||
|
||||
export default async function handler(req, res) {
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({ error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
const secretKey = (process.env.STRIPE_SECRET_KEY || '').trim();
|
||||
const webhookSecret = (process.env.STRIPE_WEBHOOK_SECRET || '').trim();
|
||||
if (!secretKey || !webhookSecret) {
|
||||
return res.status(503).json({ error: 'Stripe webhook is not configured.' });
|
||||
}
|
||||
|
||||
let event;
|
||||
try {
|
||||
const Stripe = (await import('stripe')).default;
|
||||
const stripe = new Stripe(secretKey, { apiVersion: '2024-06-20' });
|
||||
|
||||
const rawBody = await readRawBody(req);
|
||||
const signature = req.headers['stripe-signature'];
|
||||
event = stripe.webhooks.constructEvent(rawBody, signature, webhookSecret);
|
||||
} catch (err) {
|
||||
// Signature verification failed — reject (could be a forged request).
|
||||
console.error('[payments/webhook] signature verification failed:', err.message);
|
||||
return res.status(400).send(`Webhook Error: ${err.message}`);
|
||||
}
|
||||
|
||||
try {
|
||||
switch (event.type) {
|
||||
case 'checkout.session.completed':
|
||||
case 'checkout.session.async_payment_succeeded': {
|
||||
const s = event.data.object;
|
||||
await recordPayment({
|
||||
id: s.id,
|
||||
invoiceId: s.metadata?.invoiceId || null,
|
||||
amount: s.amount_total,
|
||||
currency: s.currency,
|
||||
customerEmail: s.customer_details?.email || null,
|
||||
status: s.payment_status === 'paid' ? 'paid' : s.payment_status,
|
||||
eventType: event.type,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'checkout.session.async_payment_failed': {
|
||||
const s = event.data.object;
|
||||
await recordPayment({
|
||||
id: s.id,
|
||||
invoiceId: s.metadata?.invoiceId || null,
|
||||
amount: s.amount_total,
|
||||
currency: s.currency,
|
||||
status: 'failed',
|
||||
eventType: event.type,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'checkout.session.expired': {
|
||||
const s = event.data.object;
|
||||
console.log('[payments/webhook] checkout session expired/cancelled:', s.id);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
// Unhandled event types are acknowledged so Stripe stops retrying.
|
||||
console.log('[payments/webhook] unhandled event:', event.type);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[payments/webhook] handler error:', err.message);
|
||||
// Still 200 below if we got a valid event — returning 500 makes Stripe
|
||||
// retry, which is only desirable for transient persistence failures.
|
||||
}
|
||||
|
||||
return res.status(200).json({ received: true });
|
||||
}
|
||||
@@ -26,4 +26,11 @@ export default defineConfig([
|
||||
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
// Serverless functions and build config run on Node, not the browser.
|
||||
files: ['api/**/*.js', '**/*.config.js'],
|
||||
languageOptions: {
|
||||
globals: { ...globals.node },
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
Generated
+6918
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,7 @@
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@react-three/drei": "^10.7.7",
|
||||
"@react-three/fiber": "^9.5.0",
|
||||
"@stripe/stripe-js": "^9.7.0",
|
||||
"@vercel/analytics": "^1.6.1",
|
||||
"autoprefixer": "^10.4.23",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@@ -36,6 +37,7 @@
|
||||
"react-router-dom": "^7.13.0",
|
||||
"recharts": "^3.7.0",
|
||||
"sonner": "^2.0.7",
|
||||
"stripe": "^22.2.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"three": "^0.182.0"
|
||||
|
||||
Generated
-4567
File diff suppressed because it is too large
Load Diff
+67
-1
@@ -8,7 +8,9 @@ import Dashboard from './pages/Dashboard';
|
||||
import Maps from './pages/Maps';
|
||||
import AdminSchedule from './pages/AdminSchedule';
|
||||
import CustomerProfile from './pages/CustomerProfile';
|
||||
import CustomerDashboard from './pages/CustomerDashboard';
|
||||
import LeaderboardPage from './pages/LeaderboardPage';
|
||||
import { PaymentProvider, PaymentsPage, PaymentSuccess, PaymentCancelled, DemoCheckout } from './modules/payments';
|
||||
import ErrorBoundary from './components/ErrorBoundary';
|
||||
import SmoothScroll from './components/SmoothScroll';
|
||||
import { Toaster } from 'sonner';
|
||||
@@ -31,6 +33,7 @@ import VendorDashboard from './pages/vendor/VendorDashboard';
|
||||
import VendorOrders from './pages/vendor/VendorOrders';
|
||||
import ContractorDashboard from './pages/contractor/ContractorDashboard';
|
||||
import SubContractorDashboard from './pages/subcontractor/SubContractorDashboard';
|
||||
import PaymentManagement from './pages/owner/PaymentManagement';
|
||||
import SubcontractorTaskDetailPage from './pages/subcontractor/SubcontractorTaskDetailPage';
|
||||
import SubcontractorProjectsPage from './pages/subcontractor/SubcontractorProjectsPage';
|
||||
import SubcontractorProjectDetailPage from './pages/subcontractor/SubcontractorProjectDetailPage';
|
||||
@@ -45,6 +48,11 @@ import StormIntelPage from './pages/StormIntelPage';
|
||||
import FieldStormZonePage from './pages/FieldStormZonePage';
|
||||
import UserDetailsPage from './pages/UserDetailsPage';
|
||||
import LeadVerificationPage from './pages/LeadVerification/LeadVerificationPage';
|
||||
import CustomerEstimateWizard from './modules/estimateWizard/CustomerEstimateWizard';
|
||||
import EstimateWizardAdmin from './modules/estimateWizard/EstimateWizardAdmin';
|
||||
import SocialAdsEngine from './modules/socialAdsEngine/SocialAdsEngine';
|
||||
import PublicAdLanding from './modules/socialAdsEngine/PublicAdLanding';
|
||||
import CampaignXComingSoon from './pages/CampaignXComingSoon';
|
||||
|
||||
// ... (existing imports)
|
||||
const ProtectedRoute = ({ children, allowedRoles }) => {
|
||||
@@ -74,6 +82,27 @@ function App() {
|
||||
{/* Public Routes */}
|
||||
<Route path="/" element={<Landing />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
{/* Public DIY Roofing Estimate Wizard — homeowner-facing, no auth, chrome-less */}
|
||||
<Route path="/estimate" element={<CustomerEstimateWizard />} />
|
||||
{/* Public ad lead-capture landing page — homeowner-facing, no auth, chrome-less */}
|
||||
<Route path="/ads/lp" element={<PublicAdLanding />} />
|
||||
<Route path="/estimate-wizard/admin" element={
|
||||
<ProtectedRoute allowedRoles={['OWNER', 'ADMIN']}>
|
||||
<EstimateWizardAdmin />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
{/* Social Media + Advertisement Engine — self-contained admin module (Owner/Admin) */}
|
||||
<Route path="/marketing" element={
|
||||
<ProtectedRoute allowedRoles={['OWNER', 'ADMIN']}>
|
||||
<SocialAdsEngine />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
{/* CampaignX — placeholder teaser screen for the upcoming marketing module (Owner/Admin) */}
|
||||
<Route path="/campaignx" element={
|
||||
<ProtectedRoute allowedRoles={['OWNER', 'ADMIN']}>
|
||||
<CampaignXComingSoon />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route
|
||||
path="/chat-assistant"
|
||||
element={
|
||||
@@ -83,13 +112,34 @@ function App() {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Protected Field Agent Routes */}
|
||||
{/* Customer Portal Routes */}
|
||||
<Route path="/portal/dashboard" element={
|
||||
<ProtectedRoute allowedRoles={['CUSTOMER']}>
|
||||
<CustomerDashboard />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/portal/profile" element={
|
||||
<ProtectedRoute allowedRoles={['CUSTOMER']}>
|
||||
<CustomerProfile />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
{/* Payments Module (independent) — Stripe hosted checkout */}
|
||||
<Route path="/portal/payments" element={
|
||||
<ProtectedRoute allowedRoles={['CUSTOMER']}>
|
||||
<PaymentProvider>
|
||||
<PaymentsPage />
|
||||
</PaymentProvider>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
{/* Payment Management (owner/admin side of the Payments module) */}
|
||||
<Route path="/owner/payments" element={
|
||||
<ProtectedRoute allowedRoles={['OWNER', 'ADMIN']}>
|
||||
<PaymentManagement />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
{/* Self-service profile for internal roles */}
|
||||
<Route path="/profile" element={
|
||||
<ProtectedRoute allowedRoles={['OWNER', 'ADMIN', 'FIELD_AGENT', 'CONTRACTOR', 'SUBCONTRACTOR']}>
|
||||
@@ -432,6 +482,22 @@ function App() {
|
||||
} />
|
||||
</Route>
|
||||
|
||||
{/* Stripe hosted-checkout return pages (public, full-screen).
|
||||
Public on purpose: the round-trip to Stripe drops the in-memory
|
||||
auth session, so these must render without it. */}
|
||||
<Route path="/payments/success" element={
|
||||
<PaymentProvider>
|
||||
<PaymentSuccess />
|
||||
</PaymentProvider>
|
||||
} />
|
||||
<Route path="/payments/cancel" element={
|
||||
<PaymentProvider>
|
||||
<PaymentCancelled />
|
||||
</PaymentProvider>
|
||||
} />
|
||||
{/* Simulated hosted checkout — only used in demo mode (no Stripe keys). */}
|
||||
<Route path="/payments/demo-checkout" element={<DemoCheckout />} />
|
||||
|
||||
{/* Catch all */}
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
LayoutDashboard, Map, Calendar, LogOut, User, Home, MessageSquare,
|
||||
ChevronLeft, ChevronRight, Sun, Moon, Trophy, Users, Briefcase,
|
||||
FileText, Menu, X, Calculator, PlusCircle, ClipboardList, Zap, LayoutGrid, Settings, CloudLightning,
|
||||
HardHat, ShieldCheck
|
||||
HardHat, ShieldCheck, Sparkles, Megaphone, Rocket, CreditCard
|
||||
} from 'lucide-react';
|
||||
|
||||
import PageTransition from './PageTransition';
|
||||
@@ -128,6 +128,7 @@ const Layout = () => {
|
||||
|
||||
const commonItems = [
|
||||
{ to: "/chat-assistant", icon: MessageSquare, label: "AI Assistant" },
|
||||
{ to: "/estimate", icon: Sparkles, label: "Estimate Wizard" },
|
||||
];
|
||||
|
||||
const homeItem = { to: "/", icon: Home, label: "Home" };
|
||||
@@ -156,6 +157,11 @@ const Layout = () => {
|
||||
{ to: "/owner/people", icon: Users, label: "People" },
|
||||
{ to: "/owner/settings", icon: Settings, label: "Org Settings" },
|
||||
...commonItems,
|
||||
{ to: "/estimate-wizard/admin", icon: Settings, label: "Wizard Admin" },
|
||||
{ to: "/marketing", icon: Megaphone, label: "Ad Engine" },
|
||||
{ to: "/owner/payments", icon: CreditCard, label: "Manage Payments" },
|
||||
{ type: "section", label: "Upcoming Features" },
|
||||
{ to: "/campaignx", icon: Rocket, label: <span>Campaign<span className="text-amber-500">X</span></span> },
|
||||
];
|
||||
case 'ADMIN':
|
||||
return [
|
||||
@@ -177,6 +183,11 @@ const Layout = () => {
|
||||
{ to: "/admin/subcontractor-tasks", icon: HardHat, label: "Subcontractor Tasks" },
|
||||
{ to: "/admin/settings", icon: Settings, label: "Org Settings" },
|
||||
...commonItems,
|
||||
{ to: "/estimate-wizard/admin", icon: Settings, label: "Wizard Admin" },
|
||||
{ to: "/marketing", icon: Megaphone, label: "Ad Engine" },
|
||||
{ to: "/owner/payments", icon: CreditCard, label: "Manage Payments" },
|
||||
{ type: "section", label: "Upcoming Features" },
|
||||
{ to: "/campaignx", icon: Rocket, label: <span>Campaign<span className="text-amber-500">X</span></span> },
|
||||
];
|
||||
case 'CONTRACTOR':
|
||||
case 'SUBCONTRACTOR':
|
||||
@@ -209,7 +220,9 @@ const Layout = () => {
|
||||
];
|
||||
default: // Customer or Fallback
|
||||
return [
|
||||
{ to: "/customer/profile", icon: LayoutDashboard, label: "Dashboard" },
|
||||
{ to: "/portal/dashboard", icon: LayoutDashboard, label: "Dashboard" },
|
||||
{ to: "/portal/profile", icon: User, label: "My Profile" },
|
||||
{ to: "/portal/payments", icon: CreditCard, label: "Payments" },
|
||||
...commonItems,
|
||||
];
|
||||
}
|
||||
@@ -282,7 +295,22 @@ const Layout = () => {
|
||||
|
||||
{/* 2. Navigation Items */}
|
||||
<nav className="flex-1 px-3 py-6 space-y-1 overflow-y-auto custom-scrollbar" role="navigation">
|
||||
{navItems.map((item) => (
|
||||
{navItems.map((item, idx) => {
|
||||
// Section header — a divider line followed by a small uppercase label.
|
||||
if (item.type === 'section') {
|
||||
const collapsed = isCollapsed && !isMobileMenuOpen;
|
||||
return (
|
||||
<div key={`section-${idx}`} className="pt-4 pb-1">
|
||||
<div className="h-px bg-zinc-200 dark:bg-white/10 mb-2" />
|
||||
{!collapsed && (
|
||||
<span className="px-3 text-[10px] font-bold uppercase tracking-widest text-zinc-400 dark:text-zinc-500">
|
||||
{item.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<SidebarItem
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
@@ -291,7 +319,8 @@ const Layout = () => {
|
||||
isCollapsed={isCollapsed && !isMobileMenuOpen}
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
/>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* 3. User Profile & Footer */}
|
||||
@@ -355,7 +384,9 @@ const Layout = () => {
|
||||
</PageTransition>
|
||||
</main>
|
||||
|
||||
<Chatbot />
|
||||
{/* Hide the floating chat on full-page flows that have their own bottom CTAs
|
||||
(estimate wizard, ad preview form, Ad Engine) so it doesn't overlap their buttons. */}
|
||||
{!(location.pathname === '/estimate' || location.pathname === '/marketing' || location.pathname.startsWith('/ads/')) && <Chatbot />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,6 +4,9 @@ import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import { useTheme } from '../../context/ThemeContext';
|
||||
import { DISPATCH_REPS, DISPATCH_RECOMMENDATIONS } from '../../data/mockStore';
|
||||
// Shared color taxonomy — same source of truth the Territory Map reads from,
|
||||
// so pin colors stay consistent across the app.
|
||||
import { REP_STATUS_COLORS, REP_STATUS_LABELS, URGENCY_COLORS } from '../../data/statusTaxonomy';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
@@ -11,10 +14,6 @@ import { DISPATCH_REPS, DISPATCH_RECOMMENDATIONS } from '../../data/mockStore';
|
||||
const PLANO_CENTER = [33.055, -96.752];
|
||||
const DEFAULT_ZOOM = 12;
|
||||
|
||||
const REP_STATUS_COLORS = { available: '#10B981', en_route: '#3B82F6', busy: '#F59E0B' };
|
||||
const REP_STATUS_LABELS = { available: 'Available', en_route: 'En Route', busy: 'Busy' };
|
||||
const URGENCY_COLORS = { emergency: '#EF4444', high: '#F59E0B', standard: '#6B7280' };
|
||||
|
||||
// Storm polygon — northeast Plano, sweeping toward central
|
||||
const STORM_POLYGON = [
|
||||
[33.105, -96.745], [33.100, -96.695], [33.078, -96.682],
|
||||
@@ -360,8 +359,9 @@ const DispatchMapPanel = ({ selectedLead, dispatchLeads, reps = DISPATCH_REPS, s
|
||||
)}
|
||||
</MapContainer>
|
||||
|
||||
{/* ── Legend overlay — bottom-left ── */}
|
||||
<div className="absolute bottom-3 left-3 z-[1000] flex flex-col gap-1.5 pointer-events-none">
|
||||
{/* ── Legend overlay — top-left (kept in view; the desktop map is
|
||||
~1190px tall, so a bottom-anchored legend sat far below the fold) ── */}
|
||||
<div className="absolute top-3 left-3 z-[1000] flex flex-col gap-1.5 pointer-events-none">
|
||||
{/* Rep status legend */}
|
||||
<div className="rounded-xl border bg-white/90 dark:bg-zinc-900/85 backdrop-blur-md border-zinc-200/80 dark:border-white/[0.08] px-2.5 py-2 shadow-lg">
|
||||
<p className="text-[9px] font-black uppercase tracking-widest text-zinc-400 dark:text-zinc-500 mb-1.5">Reps</p>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
Bot, CheckCircle, X, Star, Clock,
|
||||
@@ -574,6 +574,87 @@ const RepPickerModal = ({ onClose, onSelect, accent, reps }) => (
|
||||
</div>
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Proactive (ambient-AI) default — shown when no lead is selected so the panel
|
||||
// is useful on load: surfaces the highest-priority unassigned lead and its
|
||||
// best-fit rep with a one-tap dispatch.
|
||||
// ---------------------------------------------------------------------------
|
||||
const URGENCY_RANK = { emergency: 0, high: 1, standard: 2 };
|
||||
|
||||
const ProactiveSuggestion = ({ suggestion, stormMode, accent, onDispatch, onReview, onViewDetails }) => {
|
||||
const { lead, rec, rep } = suggestion;
|
||||
const repColor = REP_STATUS_COLORS[rep.status] || '#6B7280';
|
||||
const scoreCol = scoreColor(rec.score);
|
||||
const topReasons = rec.reasons.slice(0, 3);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key="proactive"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="p-3 space-y-3"
|
||||
>
|
||||
{/* Ambient-AI banner */}
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 rounded-full w-fit" style={{ backgroundColor: `${accent}14`, color: accent }}>
|
||||
<Sparkles size={11} className="shrink-0" />
|
||||
<span className="text-[9px] font-black uppercase tracking-widest">Ambient AI · Suggested Dispatch</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-zinc-500 dark:text-zinc-400 leading-snug -mt-1">
|
||||
Your highest-priority unassigned lead with a ready best-fit rep. Dispatch in one tap, or review the full slate.
|
||||
</p>
|
||||
|
||||
<LeadSummary lead={lead} stormMode={stormMode} onViewDetails={onViewDetails} />
|
||||
|
||||
{/* Best-fit rep */}
|
||||
<div className="rounded-xl border px-3 py-2.5 space-y-2" style={{ backgroundColor: `${accent}0D`, borderColor: `${accent}30` }}>
|
||||
<div className="flex items-center gap-2">
|
||||
<RepAvatar initials={rep.initials} status={rep.status} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[12px] font-bold text-zinc-900 dark:text-white leading-tight truncate">{rep.name}</p>
|
||||
<p className="text-[10px] truncate" style={{ color: repColor }}>
|
||||
{REP_STATUS_LABELS[rep.status]} · {rec.distanceMi} mi · {rep.closeRate}% close
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<p className="text-[9px] font-bold uppercase tracking-widest text-zinc-400 leading-none mb-0.5">Score</p>
|
||||
<p className="text-[1.4rem] font-black font-mono leading-none" style={{ color: scoreCol }}>{rec.score}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* Top reasons */}
|
||||
<div className="flex flex-wrap gap-1 pt-2 border-t" style={{ borderColor: `${accent}25` }}>
|
||||
{topReasons.map((r, i) => (
|
||||
<span key={i} className="inline-flex items-center text-[10px] font-medium px-2 py-0.5 rounded-full bg-white/70 dark:bg-zinc-800/70 text-zinc-600 dark:text-zinc-300">
|
||||
{r}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2">
|
||||
<motion.button
|
||||
whileHover={{ opacity: 0.9 }}
|
||||
whileTap={{ scale: 0.97 }}
|
||||
onClick={() => onDispatch(rep, false, lead)}
|
||||
className="flex-1 flex items-center justify-center gap-1.5 py-2 rounded-xl text-xs font-bold uppercase tracking-wider text-white transition-all duration-200 shadow-sm"
|
||||
style={{ backgroundColor: accent }}
|
||||
>
|
||||
<Zap size={12} />
|
||||
Dispatch {rep.name.split(' ')[0]}
|
||||
</motion.button>
|
||||
<button
|
||||
onClick={() => onReview(lead)}
|
||||
className="px-3 py-2 rounded-xl text-xs font-bold uppercase tracking-wider text-zinc-500 dark:text-zinc-400 border border-zinc-200 dark:border-white/[0.1] hover:border-zinc-300 dark:hover:border-white/[0.18] hover:text-zinc-700 dark:hover:text-zinc-200 transition-all duration-200"
|
||||
>
|
||||
Review
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main Drawer
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -581,9 +662,11 @@ const DispatchRecommendationDrawer = ({
|
||||
selectedLead,
|
||||
isProcessing,
|
||||
reps = DISPATCH_REPS,
|
||||
leads = [],
|
||||
stormMode,
|
||||
accent,
|
||||
onAssign,
|
||||
onSelectLead,
|
||||
onDismissAfterAssign,
|
||||
onViewDetails,
|
||||
isDesktop,
|
||||
@@ -593,27 +676,43 @@ const DispatchRecommendationDrawer = ({
|
||||
|
||||
const recommendations = selectedLead ? (DISPATCH_RECOMMENDATIONS[selectedLead.id] ?? []) : [];
|
||||
|
||||
// Ambient-AI default — when nothing is selected, surface the top unassigned
|
||||
// lead (by urgency, then longest-waiting) and its best-fit rep.
|
||||
const suggestion = useMemo(() => {
|
||||
if (selectedLead) return null;
|
||||
const lead = (leads || [])
|
||||
.filter(l => l.status === 'unassigned' && (DISPATCH_RECOMMENDATIONS[l.id]?.length))
|
||||
.sort((a, b) =>
|
||||
(URGENCY_RANK[a.urgency] ?? 9) - (URGENCY_RANK[b.urgency] ?? 9)
|
||||
|| (b.arrivedMinsAgo || 0) - (a.arrivedMinsAgo || 0)
|
||||
)[0];
|
||||
if (!lead) return null;
|
||||
const rec = DISPATCH_RECOMMENDATIONS[lead.id][0];
|
||||
const rep = reps.find(r => r.id === rec.repId);
|
||||
return rep ? { lead, rec, rep } : null;
|
||||
}, [selectedLead, leads, reps]);
|
||||
|
||||
// Clear dispatched overlay when selectedLead changes
|
||||
useEffect(() => {
|
||||
setDispatchedInfo(null);
|
||||
}, [selectedLead?.id]);
|
||||
|
||||
const handleAssign = useCallback((rep, isManual = false) => {
|
||||
if (!selectedLead) return;
|
||||
const handleAssign = useCallback((rep, isManual = false, lead = selectedLead) => {
|
||||
if (!lead) return;
|
||||
|
||||
const recs = DISPATCH_RECOMMENDATIONS[selectedLead.id] ?? [];
|
||||
const recs = DISPATCH_RECOMMENDATIONS[lead.id] ?? [];
|
||||
const repRec = recs.find(r => r.repId === rep.id);
|
||||
|
||||
onAssign(selectedLead.id, rep.id, isManual);
|
||||
onAssign(lead.id, rep.id, isManual);
|
||||
|
||||
toast.success(`${rep.name.split(' ')[0]} dispatched`, {
|
||||
description: `${selectedLead.property.address} · ${repRec?.slotStart ?? 'TBD'}`,
|
||||
description: `${lead.property.address} · ${repRec?.slotStart ?? 'TBD'}`,
|
||||
duration: 4000,
|
||||
});
|
||||
|
||||
setDispatchedInfo({
|
||||
repFirstName: rep.name.split(' ')[0],
|
||||
address: `${selectedLead.property.address}, ${selectedLead.property.city}`,
|
||||
address: `${lead.property.address}, ${lead.property.city}`,
|
||||
slot: repRec?.slotStart ?? null,
|
||||
});
|
||||
|
||||
@@ -780,6 +879,18 @@ const DispatchRecommendationDrawer = ({
|
||||
</button>
|
||||
</motion.div>
|
||||
|
||||
/* ── PROACTIVE DEFAULT (ambient AI) ── */
|
||||
) : suggestion ? (
|
||||
<ProactiveSuggestion
|
||||
key="proactive"
|
||||
suggestion={suggestion}
|
||||
stormMode={stormMode}
|
||||
accent={accent}
|
||||
onDispatch={handleAssign}
|
||||
onReview={onSelectLead}
|
||||
onViewDetails={onViewDetails}
|
||||
/>
|
||||
|
||||
/* ── EMPTY STATE ── */
|
||||
) : (
|
||||
<motion.div
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { useDraggable } from '@dnd-kit/core';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { MapPin, User, Clock, CloudLightning } from 'lucide-react';
|
||||
import { MapPin, User, Clock, CloudLightning, GripVertical } from 'lucide-react';
|
||||
import { DEMO_TODAY } from '../../data/mockStore';
|
||||
|
||||
function getInitials(name) {
|
||||
@@ -54,9 +54,23 @@ export function KanbanCardDisplay({ lead, columnColor, isOverlay = false, onClic
|
||||
style={{ backgroundColor: columnColor }}
|
||||
/>
|
||||
|
||||
{/* Drag handle affordance — signals the card is draggable to advance stages.
|
||||
Subtle by default, strengthens on hover/drag. Whole card stays draggable. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
title="Drag to move stage"
|
||||
className={`absolute top-2 right-1.5 pointer-events-none transition-colors ${
|
||||
isOverlay
|
||||
? 'text-blue-400 dark:text-blue-500'
|
||||
: 'text-zinc-300 dark:text-zinc-600 group-hover:text-zinc-500 dark:group-hover:text-zinc-400'
|
||||
}`}
|
||||
>
|
||||
<GripVertical size={14} />
|
||||
</div>
|
||||
|
||||
<div className="pl-4 pr-3 py-3">
|
||||
{/* Name row */}
|
||||
<div className="flex items-start gap-2 mb-2">
|
||||
{/* Name row (right padding reserves space for the drag handle) */}
|
||||
<div className="flex items-start gap-2 mb-2 pr-5">
|
||||
<div className={`w-7 h-7 rounded-full flex items-center justify-center text-[11px] font-bold shrink-0 mt-0.5 ${avatarCls}`}>
|
||||
{initials}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { X, Home, DollarSign, User, Edit2, Save, Info, AlertCircle, ShieldCheck, Loader2, ChevronLeft, ChevronRight, Trash2 } from 'lucide-react';
|
||||
import { SpotlightCard } from '../SpotlightCard';
|
||||
|
||||
@@ -309,7 +310,7 @@ const PropertyDetailDrawer = ({ isOpen, onClose, data, newLocation, onSave, load
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<div
|
||||
className={`fixed z-[1000] flex flex-col transition-transform duration-300 ease-out inset-0 md:inset-auto md:top-4 md:right-4 md:bottom-4 md:w-[500px] ${isOpen ? 'translate-x-0' : 'translate-x-full md:translate-x-[calc(100%+2rem)]'}`}
|
||||
role="dialog"
|
||||
@@ -325,7 +326,8 @@ const PropertyDetailDrawer = ({ isOpen, onClose, data, newLocation, onSave, load
|
||||
<button
|
||||
ref={closeButtonRef}
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 p-2 bg-white/50 dark:bg-white/10 hover:bg-white dark:hover:bg-white/20 rounded-full transition-all backdrop-blur-md text-zinc-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-zinc-500 z-50 cursor-pointer shadow-sm border border-black/5 dark:border-white/5"
|
||||
style={{ top: 'max(1rem, env(safe-area-inset-top))' }}
|
||||
className="absolute right-4 p-2 bg-white/50 dark:bg-white/10 hover:bg-white dark:hover:bg-white/20 rounded-full transition-all backdrop-blur-md text-zinc-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-zinc-500 z-50 cursor-pointer shadow-sm border border-black/5 dark:border-white/5"
|
||||
aria-label="Close property details"
|
||||
>
|
||||
<X size={18} />
|
||||
@@ -710,7 +712,8 @@ const PropertyDetailDrawer = ({ isOpen, onClose, data, newLocation, onSave, load
|
||||
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -390,7 +390,9 @@ const CommissionDistributionPanel = ({
|
||||
<p className="text-xs text-zinc-400 mt-1">Add team members with commission rules to see the distribution.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-xl border border-zinc-200 dark:border-white/5">
|
||||
<>
|
||||
{/* ── Desktop table (sm+) ── */}
|
||||
<div className="hidden sm:block overflow-x-auto rounded-xl border border-zinc-200 dark:border-white/5">
|
||||
<table className="w-full text-left min-w-[600px]">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-200 dark:border-white/5 bg-zinc-50 dark:bg-white/[0.02]">
|
||||
@@ -465,6 +467,65 @@ const CommissionDistributionPanel = ({
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* ── Mobile cards (< sm) ── */}
|
||||
<div className="sm:hidden space-y-2.5">
|
||||
{filteredRows.length === 0 ? (
|
||||
<div className="rounded-xl border border-zinc-200 dark:border-white/5 py-10 text-center">
|
||||
<Users size={22} className="mx-auto mb-2 text-zinc-400" />
|
||||
<p className="text-sm text-zinc-500">No commission entries match the current filters.</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearFilters}
|
||||
className="mt-3 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[11px] font-bold uppercase tracking-wider text-emerald-600 dark:text-emerald-400 hover:bg-emerald-50 dark:hover:bg-emerald-500/10 border border-emerald-200 dark:border-emerald-500/20 transition-colors"
|
||||
>
|
||||
<FilterX size={12} /> Clear Filters
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{filteredRows.map((row) => (
|
||||
<div key={row.key} className="rounded-xl border border-zinc-200 dark:border-white/5 bg-white dark:bg-white/[0.02] p-3.5">
|
||||
{/* Name + Amount */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<div className="w-7 h-7 rounded-full bg-emerald-500/15 flex items-center justify-center text-[10px] font-black text-emerald-600 dark:text-emerald-400 flex-shrink-0">
|
||||
{row.name?.[0] || '?'}
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-zinc-900 dark:text-white truncate">{row.name}</span>
|
||||
</div>
|
||||
<span className="font-mono text-sm font-bold text-green-600 dark:text-[#39ff14] shrink-0">{fmt(row.amount)}</span>
|
||||
</div>
|
||||
{/* Role */}
|
||||
<div className="mt-2.5">
|
||||
<span className={`text-[11px] font-bold rounded-md px-1.5 py-0.5 border ${ROLE_COLORS[row.role] || 'text-zinc-400 bg-zinc-500/10 border-zinc-500/20'}`}>
|
||||
{row.role}
|
||||
</span>
|
||||
</div>
|
||||
{/* Project / Type */}
|
||||
<div className="grid grid-cols-2 gap-2 mt-3 pt-3 border-t border-zinc-100 dark:border-white/5">
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<span className="text-[9px] font-mono font-bold text-zinc-400 dark:text-zinc-500 uppercase tracking-widest">Project ID</span>
|
||||
<span className="text-[11px] font-mono text-zinc-500 dark:text-zinc-400 truncate">{row.projectId}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<span className="text-[9px] font-mono font-bold text-zinc-400 dark:text-zinc-500 uppercase tracking-widest">Commission Type</span>
|
||||
<span className="text-[11px] text-zinc-600 dark:text-zinc-400 truncate">{row.commissionType}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{/* Total */}
|
||||
<div className="rounded-xl border border-zinc-300 dark:border-white/10 bg-zinc-50 dark:bg-white/[0.03] px-3.5 py-3 flex items-center justify-between">
|
||||
<span className="text-[10px] font-mono font-bold text-zinc-500 uppercase tracking-widest">
|
||||
{hasActiveFilters ? 'Filtered Total' : 'Total'}
|
||||
</span>
|
||||
<span className="font-mono text-sm font-extrabold text-green-600 dark:text-[#39ff14]">{fmt(total)}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -393,8 +393,9 @@ const TeamManagementPanel = ({
|
||||
transition={{ duration: 0.25 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="overflow-x-auto rounded-xl border border-zinc-200 dark:border-white/5">
|
||||
<table className="w-full text-left">
|
||||
{/* ── Desktop table (sm+) ── */}
|
||||
<div className="hidden sm:block overflow-x-auto rounded-xl border border-zinc-200 dark:border-white/5">
|
||||
<table className="w-full text-left min-w-[560px]">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-200 dark:border-white/5 bg-zinc-50 dark:bg-white/[0.02]">
|
||||
<th className="px-4 py-2.5 text-[10px] font-mono font-bold text-zinc-500 uppercase tracking-widest">Member</th>
|
||||
@@ -419,17 +420,17 @@ const TeamManagementPanel = ({
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`text-[11px] font-bold rounded-md px-1.5 py-0.5 border ${ROLE_COLORS[mc.jobRole] || 'text-zinc-400 bg-zinc-500/10 border-zinc-500/20'}`}>
|
||||
<span className={`text-[11px] font-bold rounded-md px-1.5 py-0.5 border whitespace-nowrap ${ROLE_COLORS[mc.jobRole] || 'text-zinc-400 bg-zinc-500/10 border-zinc-500/20'}`}>
|
||||
{mc.jobRole}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-zinc-600 dark:text-zinc-400">{COMMISSION_TYPES[mc.resolved.type] || mc.resolved.type}</td>
|
||||
<td className="px-4 py-3 text-right font-mono text-xs text-zinc-900 dark:text-white">
|
||||
<td className="px-4 py-3 text-xs text-zinc-600 dark:text-zinc-400 whitespace-nowrap">{COMMISSION_TYPES[mc.resolved.type] || mc.resolved.type}</td>
|
||||
<td className="px-4 py-3 text-right font-mono text-xs text-zinc-900 dark:text-white whitespace-nowrap">
|
||||
{mc.resolved.type === 'flat' || mc.resolved.type === 'custom'
|
||||
? formatCurrency(mc.resolved.rate)
|
||||
: `${mc.resolved.rate}%`}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-mono text-xs font-bold text-green-600 dark:text-[#39ff14]">
|
||||
<td className="px-4 py-3 text-right font-mono text-xs font-bold text-green-600 dark:text-[#39ff14] whitespace-nowrap">
|
||||
{formatCurrency(mc.value)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
@@ -445,7 +446,7 @@ const TeamManagementPanel = ({
|
||||
<tfoot className="border-t-2 border-zinc-300 dark:border-white/10">
|
||||
<tr className="bg-zinc-50 dark:bg-white/[0.03]">
|
||||
<td colSpan={4} className="px-4 py-3 text-[10px] font-mono font-bold text-zinc-500 uppercase tracking-widest">Total Distribution</td>
|
||||
<td className="px-4 py-3 text-right font-mono text-xs font-bold text-green-600 dark:text-[#39ff14]">
|
||||
<td className="px-4 py-3 text-right font-mono text-xs font-bold text-green-600 dark:text-[#39ff14] whitespace-nowrap">
|
||||
{formatCurrency(memberCommissions.filter(m => m.status === 'active').reduce((s, mc) => s + mc.value, 0))}
|
||||
</td>
|
||||
<td />
|
||||
@@ -453,6 +454,62 @@ const TeamManagementPanel = ({
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* ── Mobile cards (< sm) ── */}
|
||||
<div className="sm:hidden space-y-2.5">
|
||||
{memberCommissions.filter(m => m.status === 'active').map((mc) => {
|
||||
const srcCfg = COMMISSION_SOURCE_CFG[mc.resolved.source] || COMMISSION_SOURCE_CFG.org;
|
||||
return (
|
||||
<div key={mc.id} className="rounded-xl border border-zinc-200 dark:border-white/5 bg-white dark:bg-white/[0.02] p-3.5">
|
||||
{/* Member + source */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="w-7 h-7 rounded-full bg-violet-500/15 flex items-center justify-center text-[11px] font-black text-violet-400 flex-shrink-0">
|
||||
{mc.name?.[0]}
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-zinc-900 dark:text-white truncate">{mc.name}</span>
|
||||
</div>
|
||||
<span className={`inline-flex items-center gap-1 text-[10px] font-bold rounded-full px-2 py-0.5 border shrink-0 ${srcCfg.bg}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${srcCfg.dot}`} />
|
||||
<span className={srcCfg.color}>{srcCfg.label}</span>
|
||||
</span>
|
||||
</div>
|
||||
{/* Role */}
|
||||
<div className="mt-2.5">
|
||||
<span className={`text-[11px] font-bold rounded-md px-1.5 py-0.5 border ${ROLE_COLORS[mc.jobRole] || 'text-zinc-400 bg-zinc-500/10 border-zinc-500/20'}`}>
|
||||
{mc.jobRole}
|
||||
</span>
|
||||
</div>
|
||||
{/* Type / Rate / Value */}
|
||||
<div className="grid grid-cols-3 gap-2 mt-3 pt-3 border-t border-zinc-100 dark:border-white/5">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-[9px] font-mono font-bold text-zinc-400 dark:text-zinc-500 uppercase tracking-widest">Type</span>
|
||||
<span className="text-[11px] text-zinc-700 dark:text-zinc-300">{COMMISSION_TYPES[mc.resolved.type] || mc.resolved.type}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-[9px] font-mono font-bold text-zinc-400 dark:text-zinc-500 uppercase tracking-widest">Rate</span>
|
||||
<span className="font-mono text-[11px] text-zinc-900 dark:text-white">
|
||||
{mc.resolved.type === 'flat' || mc.resolved.type === 'custom'
|
||||
? formatCurrency(mc.resolved.rate)
|
||||
: `${mc.resolved.rate}%`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-[9px] font-mono font-bold text-zinc-400 dark:text-zinc-500 uppercase tracking-widest">Value</span>
|
||||
<span className="font-mono text-[11px] font-bold text-green-600 dark:text-[#39ff14]">{formatCurrency(mc.value)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* Total */}
|
||||
<div className="rounded-xl border border-zinc-300 dark:border-white/10 bg-zinc-50 dark:bg-white/[0.03] px-3.5 py-3 flex items-center justify-between">
|
||||
<span className="text-[10px] font-mono font-bold text-zinc-500 uppercase tracking-widest">Total Distribution</span>
|
||||
<span className="font-mono text-sm font-bold text-green-600 dark:text-[#39ff14]">
|
||||
{formatCurrency(memberCommissions.filter(m => m.status === 'active').reduce((s, mc) => s + mc.value, 0))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
@@ -9,12 +9,8 @@ export const ThemeProvider = ({ children }) => {
|
||||
if (savedTheme) {
|
||||
return savedTheme;
|
||||
}
|
||||
// Check system preference
|
||||
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
return 'dark';
|
||||
}
|
||||
}
|
||||
return 'light'; // Default to light if no preference
|
||||
return 'dark'; // Default to dark for first-time visitors
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
+121
-9
@@ -652,9 +652,9 @@ function generateProperties() {
|
||||
|
||||
const MOCK_USERS = [
|
||||
// Customers
|
||||
{ id: 'c1', type: 'customer', username: 'alice', email: 'alice@example.com', password: 'password', name: 'Alice Customer', propertyId: 'P-2600' },
|
||||
{ id: 'c2', type: 'customer', username: 'bob', email: 'bob@example.com', password: 'password', name: 'Bob Buyer' },
|
||||
{ id: 'c3', type: 'customer', username: 'charlie', email: 'charlie@example.com', password: 'password', name: 'Charlie Client' },
|
||||
{ id: 'c1', type: 'customer', username: 'alice', email: 'alice@example.com', password: 'password', name: 'Alice Customer', propertyId: 'P-2600', phone: '214-555-2600' },
|
||||
{ id: 'c2', type: 'customer', username: 'bob', email: 'bob@example.com', password: 'password', name: 'Bob Buyer', phone: '214-555-2601' },
|
||||
{ id: 'c3', type: 'customer', username: 'charlie', email: 'charlie@example.com', password: 'password', name: 'Charlie Client', phone: '214-555-2602' },
|
||||
|
||||
// Field Agents (5 Agents)
|
||||
{ id: 'e1', type: 'employee', empId: 'LUP-1040', email: 'agent1@plano.com', password: 'password', role: 'FIELD_AGENT', name: 'Cody Tatum', xp: 12450, doorsKnocked: 342, leadsGained: 45, appointmentsSet: 18, streakDays: 5, achievements: ['Hot Spot Hunter', 'Storm Chaser'] },
|
||||
@@ -986,7 +986,7 @@ function generateMockMeetings() {
|
||||
status: 'Completed',
|
||||
agentName: 'Hannah Reyes',
|
||||
dealValue: 1500,
|
||||
notes: 'Minor repairs completed. Payment received.',
|
||||
notes: 'Minor repairs completed.',
|
||||
issueDescription: 'Leak in garage.',
|
||||
customerComments: 'Please call before arriving.',
|
||||
outcome: 'Repairs Completed'
|
||||
@@ -2416,6 +2416,20 @@ const MOCK_PROJECTS = [
|
||||
{ name: 'City of Plano', category: 'Permits & Fees', allocated: 1000, committed: 1000, actual: 950 },
|
||||
{ name: 'Reserve Fund', category: 'Contingency', allocated: 5000, committed: 1200, actual: 950 }
|
||||
],
|
||||
// Cost breakdown / expenses — actuals reflect spend-to-date and each line is fully
|
||||
// paid (paymentRequests cover the actual), so outstanding expenses are $0.
|
||||
expenses: [
|
||||
{ id: 'exp_003_1', type: 'material', name: 'Comfort Air Systems', category: 'Equipment & Materials', allocated: 15000, actual: 12500, paid: true, paymentRequests: [ { id: 'req_003_1_1', requestDate: '2026-01-10', requestedAmount: 12500, amountPaid: 12500, paymentDate: '2026-01-14' } ] },
|
||||
{ id: 'exp_003_2', type: 'labor', name: 'Comfort Air Systems', category: 'Labor - HVAC', allocated: 8000, actual: 6000, paid: true, paymentRequests: [ { id: 'req_003_2_1', requestDate: '2026-01-22', requestedAmount: 6000, amountPaid: 6000, paymentDate: '2026-01-26' } ] },
|
||||
{ id: 'exp_003_3', type: 'labor', name: 'Sparky Electric', category: 'Labor - Electrical', allocated: 3000, actual: 2400, paid: true, paymentRequests: [ { id: 'req_003_3_1', requestDate: '2026-02-05', requestedAmount: 2400, amountPaid: 2400, paymentDate: '2026-02-08' } ] },
|
||||
{ id: 'exp_003_4', type: 'other', name: 'City of Plano', category: 'Permits & Fees', allocated: 1000, actual: 950, paid: true, paymentRequests: [ { id: 'req_003_4_1', requestDate: '2026-01-06', requestedAmount: 950, amountPaid: 950, paymentDate: '2026-01-08' } ] },
|
||||
{ id: 'exp_003_5', type: 'other', name: 'Reserve Fund', category: 'Contingency', allocated: 5000, actual: 600, paid: true, paymentRequests: [ { id: 'req_003_5_1', requestDate: '2026-02-10', requestedAmount: 600, amountPaid: 600, paymentDate: '2026-02-12' } ] },
|
||||
{ id: 'exp_003_6', type: 'team', name: 'Jesus Gonzales', category: 'Sales Rep', costType: 'Commission', allocated: 3200, actual: 3200, paid: true, paymentRequests: [ { id: 'req_003_6_1', requestDate: '2026-02-12', requestedAmount: 3200, amountPaid: 3200, paymentDate: '2026-02-15' } ] }
|
||||
],
|
||||
paymentsReceived: [
|
||||
{ id: 'rcv_003_1', from: 'Homeowner', method: 'ACH', amount: 18000, date: '2026-01-08', refNumber: 'ACH-003-1', memo: 'HVAC deposit & equipment payment' },
|
||||
{ id: 'rcv_003_2', from: 'Homeowner', method: 'Check', amount: 12000, date: '2026-02-20', refNumber: 'CHK-003', memo: 'Progress payment — installation' }
|
||||
],
|
||||
invoices: [
|
||||
{ id: 'inv_003_1', amount: 8200, submittedBy: 'con_001', status: 'paid', dueDate: '2026-01-15', datePaid: '2026-01-16' },
|
||||
{ id: 'inv_003_2', amount: 10500.25, submittedBy: 'v6', status: 'pending', dueDate: '2026-02-19' },
|
||||
@@ -2623,6 +2637,20 @@ const MOCK_PROJECTS = [
|
||||
{ name: 'Texas Builders LLC', category: 'Structural Repairs (CO)', allocated: 0, committed: 850, actual: 850 },
|
||||
{ name: 'Reserve Fund', category: 'Contingency', allocated: 5000, committed: 1350, actual: 350 }
|
||||
],
|
||||
// Cost breakdown / expenses — actuals reflect spend-to-date and each line is fully
|
||||
// paid (paymentRequests cover the actual), so outstanding expenses are $0.
|
||||
expenses: [
|
||||
{ id: 'exp_005_1', type: 'material', name: 'ABC Supply Co.', category: 'Materials - Siding', allocated: 12000, actual: 8000, paid: true, paymentRequests: [ { id: 'req_005_1_1', requestDate: '2026-01-10', requestedAmount: 8000, amountPaid: 8000, paymentDate: '2026-01-13' } ] },
|
||||
{ id: 'exp_005_2', type: 'labor', name: 'Siding Solutions TX', category: 'Labor - Prep & Install', allocated: 9000, actual: 3500, paid: true, paymentRequests: [ { id: 'req_005_2_1', requestDate: '2026-01-16', requestedAmount: 3500, amountPaid: 3500, paymentDate: '2026-01-20' } ] },
|
||||
{ id: 'exp_005_3', type: 'other', name: 'Sunbelt Rentals', category: 'Equipment Rental', allocated: 2000, actual: 1500, paid: true, paymentRequests: [ { id: 'req_005_3_1', requestDate: '2026-01-09', requestedAmount: 1500, amountPaid: 1500, paymentDate: '2026-01-12' } ] },
|
||||
{ id: 'exp_005_4', type: 'other', name: 'Texas Builders LLC', category: 'Structural Repairs (CO)', allocated: 850, actual: 850, paid: true, paymentRequests: [ { id: 'req_005_4_1', requestDate: '2026-01-19', requestedAmount: 850, amountPaid: 850, paymentDate: '2026-01-22' } ] },
|
||||
{ id: 'exp_005_5', type: 'other', name: 'Reserve Fund', category: 'Contingency', allocated: 5000, actual: 350, paid: true, paymentRequests: [ { id: 'req_005_5_1', requestDate: '2026-01-25', requestedAmount: 350, amountPaid: 350, paymentDate: '2026-01-27' } ] },
|
||||
{ id: 'exp_005_6', type: 'team', name: 'Sarah Calloway', category: 'Sales Rep', costType: 'Commission', allocated: 2800, actual: 2800, paid: true, paymentRequests: [ { id: 'req_005_6_1', requestDate: '2026-01-28', requestedAmount: 2800, amountPaid: 2800, paymentDate: '2026-01-31' } ] }
|
||||
],
|
||||
paymentsReceived: [
|
||||
{ id: 'rcv_005_1', from: 'Homeowner', method: 'ACH', amount: 13000, date: '2026-01-08', refNumber: 'ACH-005-1', memo: 'Siding project deposit' },
|
||||
{ id: 'rcv_005_2', from: 'Homeowner', method: 'Check', amount: 9000, date: '2026-02-06', refNumber: 'CHK-005', memo: 'Progress payment — tear-off & barrier' }
|
||||
],
|
||||
invoices: [
|
||||
{ id: 'inv_005_1', amount: 8400, submittedBy: 'con_001', status: 'paid', dueDate: '2026-01-12', datePaid: '2026-01-13' },
|
||||
{ id: 'inv_005_2', amount: 5800, submittedBy: 'v3', status: 'paid', dueDate: '2026-01-20', datePaid: '2026-01-21' },
|
||||
@@ -3585,9 +3613,9 @@ const MOCK_PROJECTS = [
|
||||
budget: 19600,
|
||||
approvedBudget: 19600,
|
||||
committedCost: 18100,
|
||||
actualCost: 0,
|
||||
spent: 0,
|
||||
variancePercent: -100.0,
|
||||
actualCost: 18100,
|
||||
spent: 18100,
|
||||
variancePercent: -7.7,
|
||||
margin: 0.08,
|
||||
commissionType: 'percent_gross',
|
||||
commissionRate: 10,
|
||||
@@ -3604,6 +3632,22 @@ const MOCK_PROJECTS = [
|
||||
{ name: 'City of Plano', category: 'Permits', allocated: 600, committed: 600, actual: 0 },
|
||||
{ name: 'Reserve Fund', category: 'Contingency', allocated: 1500, committed: 0, actual: 0 },
|
||||
],
|
||||
expenses: [
|
||||
{ id: 'exp_017_1', type: 'material', name: 'Owens Corning', category: 'Materials', allocated: 10500, actual: 10500, paid: true, paymentRequests: [
|
||||
{ id: 'req_017_1_1', requestDate: '2026-04-02', requestedAmount: 10500, amountPaid: 10500, paymentDate: '2026-04-04' }
|
||||
] },
|
||||
{ id: 'exp_017_2', type: 'labor', name: 'Plano Roofing Crew C', category: 'Labor', allocated: 7000, actual: 7000, paid: true, paymentRequests: [
|
||||
{ id: 'req_017_2_1', requestDate: '2026-04-22', requestedAmount: 7000, amountPaid: 7000, paymentDate: '2026-04-24' }
|
||||
] },
|
||||
{ id: 'exp_017_3', type: 'other', name: 'City of Plano', category: 'Permits', allocated: 600, actual: 600, paid: true, paymentRequests: [
|
||||
{ id: 'req_017_3_1', requestDate: '2026-04-10', requestedAmount: 600, amountPaid: 600, paymentDate: '2026-04-10' }
|
||||
] }
|
||||
],
|
||||
paymentsReceived: [
|
||||
{ id: 'rcv_017_1', from: 'Adriana Voss', method: 'Check', amount: 5880, date: '2026-03-28', refNumber: 'CHK-2017-01', memo: 'Deposit — 30%' },
|
||||
{ id: 'rcv_017_2', from: 'Farmers Insurance', method: 'ACH', amount: 7840, date: '2026-04-25', refNumber: 'ACH-2017-02', memo: 'Progress payment — 40%' },
|
||||
{ id: 'rcv_017_3', from: 'Farmers Insurance', method: 'ACH', amount: 5880, date: '2026-04-28', refNumber: 'ACH-2017-03', memo: 'Final payment — 30%' },
|
||||
],
|
||||
changeOrders: [
|
||||
{ id: 'CO-017-01', title: 'Supplemental — Ice & Water Barrier', amount: 1800, status: 'approved', description: 'Insurance adjuster agreed to add ice/water shield on all valleys and eaves.' },
|
||||
],
|
||||
@@ -3612,8 +3656,8 @@ const MOCK_PROJECTS = [
|
||||
issueLog: [],
|
||||
paymentSchedule: [
|
||||
{ id: 'ps_017_1', milestone: 'Deposit — 30%', amount: 5880, dueDate: '2026-03-28', status: 'paid', paidDate: '2026-03-28' },
|
||||
{ id: 'ps_017_2', milestone: 'Progress — 40%', amount: 7840, dueDate: '2026-04-25', status: 'pending' },
|
||||
{ id: 'ps_017_3', milestone: 'Final — 30%', amount: 5880, dueDate: '2026-04-28', status: 'pending' },
|
||||
{ id: 'ps_017_2', milestone: 'Progress — 40%', amount: 7840, dueDate: '2026-04-25', status: 'paid', paidDate: '2026-04-25' },
|
||||
{ id: 'ps_017_3', milestone: 'Final — 30%', amount: 5880, dueDate: '2026-04-28', status: 'paid', paidDate: '2026-04-28' },
|
||||
],
|
||||
invoices: [],
|
||||
milestones: [
|
||||
@@ -3798,6 +3842,20 @@ const MOCK_PROJECTS = [
|
||||
{ name: 'City of Plano', category: 'Permits', allocated: 800, committed: 800, actual: 800 },
|
||||
{ name: 'Reserve Fund', category: 'Contingency', allocated: 1900, committed: 1000, actual: 0 },
|
||||
],
|
||||
// Cost breakdown / expenses — materials, permits and commission are fully paid, while
|
||||
// the in-progress labor and the structural-rot change order are only partially paid,
|
||||
// so the project still carries some outstanding expenses.
|
||||
expenses: [
|
||||
{ id: 'exp_019_1', type: 'material', name: 'Owens Corning', category: 'Materials', allocated: 15400, actual: 14200, paid: true, paymentRequests: [ { id: 'req_019_1_1', requestDate: '2026-03-24', requestedAmount: 14200, amountPaid: 14200, paymentDate: '2026-03-26' } ] },
|
||||
{ id: 'exp_019_2', type: 'labor', name: 'Plano Siding Crew', category: 'Labor', allocated: 10400, actual: 4800, paid: false, paymentRequests: [ { id: 'req_019_2_1', requestDate: '2026-03-27', requestedAmount: 4800, amountPaid: 2800, paymentDate: '2026-03-28' } ] },
|
||||
{ id: 'exp_019_3', type: 'other', name: 'City of Plano', category: 'Permits', allocated: 800, actual: 800, paid: true, paymentRequests: [ { id: 'req_019_3_1', requestDate: '2026-03-04', requestedAmount: 800, amountPaid: 800, paymentDate: '2026-03-05' } ] },
|
||||
{ id: 'exp_019_4', type: 'other', name: 'Texas Builders LLC', category: 'Structural Sheathing (CO-019-02)', allocated: 3800, actual: 1800, paid: false, paymentRequests: [ { id: 'req_019_4_1', requestDate: '2026-03-29', requestedAmount: 1800, amountPaid: 800, paymentDate: '2026-03-30' } ] },
|
||||
{ id: 'exp_019_5', type: 'team', name: 'Jesus Gonzales', category: 'Sales Rep', costType: 'Commission', allocated: 2850, actual: 2850, paid: true, paymentRequests: [ { id: 'req_019_5_1', requestDate: '2026-03-02', requestedAmount: 2850, amountPaid: 2850, paymentDate: '2026-03-05' } ] }
|
||||
],
|
||||
paymentsReceived: [
|
||||
{ id: 'rcv_019_1', from: 'Insurance Carrier', method: 'ACH', amount: 21375, date: '2026-03-01', refNumber: 'ACH-019-1', memo: 'Insurance claim payout — siding replacement' },
|
||||
{ id: 'rcv_019_2', from: 'Sandra Tran', method: 'Check', amount: 7125, date: '2026-02-25', refNumber: 'CHK-019', memo: 'Homeowner deposit (25%)' }
|
||||
],
|
||||
changeOrders: [
|
||||
{ id: 'CO-019-01', title: 'Rot Repair — 2 Panels', amount: 380, status: 'approved', description: 'Two panels of rotted OSB substrate discovered during demo. Repaired.' },
|
||||
{ id: 'CO-019-02', title: 'Hidden Rot & Structural Sheathing Replacement', amount: 3800, status: 'approved', dateSubmitted: '2026-03-28', description: 'Full west wall sheathing found severely rotted behind old siding — 320 sq ft of OSB substructure replaced plus additional labor days. Exceeds original labor budget by $3,800.' },
|
||||
@@ -9140,6 +9198,25 @@ const DEFAULT_EMPTY_PROFILE = {
|
||||
|
||||
const MockStoreContext = createContext();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Estimate Wizard → CRM lead persistence.
|
||||
// The public DIY Estimate Wizard adds completed estimates as real leads through the
|
||||
// in-memory store (addWizardLead → setLeads). Nothing is persisted to localStorage,
|
||||
// so leads reset on a full page reload, like all CRM demo data.
|
||||
//
|
||||
// When the wizard runs in the SAME tab as the CRM it shares this provider, so the
|
||||
// lead appears immediately. When it runs in a SEPARATE tab (the public /estimate
|
||||
// link is its own JS context), we bridge it with BroadcastChannel — an in-memory,
|
||||
// no-persistence message bus — so the lead still reaches the CRM tab live without
|
||||
// writing anything to storage.
|
||||
// ---------------------------------------------------------------------------
|
||||
const WIZARD_CHANNEL = 'lup-estimate-wizard';
|
||||
const wizardBus = (typeof BroadcastChannel !== 'undefined') ? new BroadcastChannel(WIZARD_CHANNEL) : null;
|
||||
|
||||
function broadcastWizardLead(lead) {
|
||||
try { wizardBus?.postMessage({ type: 'wizard-lead', lead }); } catch { /* unsupported */ }
|
||||
}
|
||||
|
||||
export const MockStoreProvider = ({ children }) => {
|
||||
const [properties, setProperties] = useState([]);
|
||||
const [users, setUsers] = useState(MOCK_USERS);
|
||||
@@ -9358,6 +9435,25 @@ export const MockStoreProvider = ({ children }) => {
|
||||
setProperties(data);
|
||||
}, []);
|
||||
|
||||
// Receive wizard leads created in another tab (public /estimate) and merge them
|
||||
// into the leads list live — no reload, no localStorage. Deduped by wizard
|
||||
// session id (and id) so an echo or repeat message can't double-add.
|
||||
useEffect(() => {
|
||||
if (typeof BroadcastChannel === 'undefined') return;
|
||||
const bus = new BroadcastChannel(WIZARD_CHANNEL);
|
||||
bus.onmessage = (e) => {
|
||||
const lead = e?.data?.type === 'wizard-lead' ? e.data.lead : null;
|
||||
if (!lead) return;
|
||||
setLeads(prev => {
|
||||
const sid = lead.estimateWizard?.sessionId;
|
||||
if (sid && prev.some(l => l?.estimateWizard?.sessionId === sid)) return prev;
|
||||
if (lead.id && prev.some(l => l?.id === lead.id)) return prev;
|
||||
return [lead, ...prev];
|
||||
});
|
||||
};
|
||||
return () => bus.close();
|
||||
}, []);
|
||||
|
||||
const updatePropertyStatus = (id, newStatus) => {
|
||||
setProperties(prev => prev.map(p =>
|
||||
p.id === id ? { ...p, canvassingStatus: newStatus } : p
|
||||
@@ -9979,6 +10075,22 @@ export const MockStoreProvider = ({ children }) => {
|
||||
return newLead;
|
||||
},
|
||||
|
||||
// Lead created by the public Estimate Wizard. In-memory only (no
|
||||
// localStorage) and deduped by wizard session id so a StrictMode double
|
||||
// mount can't create the lead twice.
|
||||
addWizardLead: (lead) => {
|
||||
const created = {
|
||||
...lead,
|
||||
id: lead.id || `lead_wiz_${Date.now()}`,
|
||||
createdAt: lead.createdAt || new Date().toISOString(),
|
||||
status: 'New',
|
||||
};
|
||||
const sid = created.estimateWizard?.sessionId;
|
||||
setLeads(prev => (sid && prev.some(l => l?.estimateWizard?.sessionId === sid)) ? prev : [created, ...prev]);
|
||||
broadcastWizardLead(created); // reach the CRM tab if the wizard is in another tab
|
||||
return created;
|
||||
},
|
||||
|
||||
// Templates
|
||||
templates,
|
||||
addTemplate: (tpl) => {
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Single source of truth for map status taxonomies & color tokens.
|
||||
//
|
||||
// The Territory Map (property canvassing lifecycle) and LynkDispatch (live
|
||||
// dispatch ops) track different entities, so they keep separate taxonomies —
|
||||
// but BOTH read their colors from the shared hue tokens below, so the palette
|
||||
// never drifts or contradicts itself across the app.
|
||||
//
|
||||
// One hue = one meaning:
|
||||
// red → urgent / hot amber → needs attention / busy
|
||||
// emerald→ positive / converted blue → in progress / active
|
||||
// violet → undecided / neutral slate → inactive / declined
|
||||
// gray → low priority / idle
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const STATUS_HUES = {
|
||||
red: '#ef4444',
|
||||
amber: '#f59e0b',
|
||||
emerald: '#10b981',
|
||||
blue: '#3b82f6',
|
||||
violet: '#a78bfa',
|
||||
slate: '#cbd5e1',
|
||||
gray: '#6b7280',
|
||||
};
|
||||
|
||||
// ── Territory Map — property canvassing lifecycle ───────────────────────────
|
||||
// `color` — marker/polygon stroke & fill (hex)
|
||||
// `fillOpacity` — polygon fill alpha
|
||||
// `badge` — Tailwind classes for the status pill
|
||||
// `dot` — Tailwind bg class for the legend swatch
|
||||
export const CANVASSING_STATUS = {
|
||||
'Hot Lead': { color: STATUS_HUES.red, fillOpacity: 0.40, badge: 'bg-red-500 text-white', dot: 'bg-red-500' },
|
||||
'Customer': { color: STATUS_HUES.emerald, fillOpacity: 0.30, badge: 'bg-emerald-500 text-white', dot: 'bg-emerald-500' },
|
||||
'Renovated': { color: STATUS_HUES.blue, fillOpacity: 0.20, badge: 'bg-blue-500 text-white', dot: 'bg-blue-500' },
|
||||
'Neutral': { color: STATUS_HUES.violet, fillOpacity: 0.20, badge: 'bg-violet-400 text-white', dot: 'bg-violet-400' },
|
||||
'Not Interested': { color: STATUS_HUES.slate, fillOpacity: 0.35, badge: 'bg-slate-300 text-slate-900', dot: 'bg-slate-300' },
|
||||
};
|
||||
|
||||
// Render/legend order for the canvassing statuses.
|
||||
export const CANVASSING_STATUS_ORDER = ['Hot Lead', 'Customer', 'Renovated', 'Neutral', 'Not Interested'];
|
||||
|
||||
// Fallback used when a property has no (or an unknown) canvassing status.
|
||||
export const CANVASSING_STATUS_DEFAULT = CANVASSING_STATUS.Neutral;
|
||||
|
||||
// ── LynkDispatch — live dispatch operations ─────────────────────────────────
|
||||
export const REP_STATUS = {
|
||||
available: { color: STATUS_HUES.emerald, label: 'Available' },
|
||||
en_route: { color: STATUS_HUES.blue, label: 'En Route' },
|
||||
busy: { color: STATUS_HUES.amber, label: 'Busy' },
|
||||
};
|
||||
|
||||
export const LEAD_URGENCY = {
|
||||
emergency: { color: STATUS_HUES.red, label: 'Emergency' },
|
||||
high: { color: STATUS_HUES.amber, label: 'High' },
|
||||
standard: { color: STATUS_HUES.gray, label: 'Standard' },
|
||||
};
|
||||
|
||||
// Flat id→value maps for existing call sites that index by status/urgency key.
|
||||
export const REP_STATUS_COLORS = Object.fromEntries(Object.entries(REP_STATUS).map(([k, v]) => [k, v.color]));
|
||||
export const REP_STATUS_LABELS = Object.fromEntries(Object.entries(REP_STATUS).map(([k, v]) => [k, v.label]));
|
||||
export const URGENCY_COLORS = Object.fromEntries(Object.entries(LEAD_URGENCY).map(([k, v]) => [k, v.color]));
|
||||
|
||||
// ── Team Schedule — appointment / meeting status ────────────────────────────
|
||||
// `badge` — Tailwind classes for the status pill
|
||||
// `dot` — Tailwind bg class for the legend swatch
|
||||
// `desc` — one-line meaning shown in the legend (so Converted vs Scheduled is clear)
|
||||
export const MEETING_STATUS = {
|
||||
Scheduled: { label: 'Scheduled', badge: 'bg-blue-500/20 text-blue-600 dark:text-blue-400', dot: 'bg-blue-500', desc: 'Upcoming appointment' },
|
||||
Rescheduled: { label: 'Rescheduled', badge: 'bg-amber-500/20 text-amber-600 dark:text-amber-400', dot: 'bg-amber-500', desc: 'Moved to a new date or time' },
|
||||
Completed: { label: 'Completed', badge: 'bg-emerald-500/20 text-emerald-600 dark:text-emerald-400', dot: 'bg-emerald-500', desc: 'Visit took place' },
|
||||
Converted: { label: 'Converted', badge: 'bg-violet-500/20 text-violet-600 dark:text-violet-400', dot: 'bg-violet-500', desc: 'Won — became a signed job' },
|
||||
Cancelled: { label: 'Cancelled', badge: 'bg-slate-500/20 text-slate-600 dark:text-slate-400', dot: 'bg-slate-400', desc: 'Called off' },
|
||||
};
|
||||
|
||||
export const MEETING_STATUS_ORDER = ['Scheduled', 'Rescheduled', 'Completed', 'Converted', 'Cancelled'];
|
||||
|
||||
export const MEETING_STATUS_DEFAULT = { label: 'Unknown', badge: 'bg-zinc-400/20 text-zinc-500 dark:text-zinc-400', dot: 'bg-zinc-400', desc: '' };
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* CustomerEstimateWizard — public entry point for the DIY Roofing Estimate Wizard.
|
||||
* Rendered chrome-less (no CRM sidebar) at the public /estimate route. The homeowner
|
||||
* is the primary user (spec §1). Wrapped in its own provider so the module stays
|
||||
* fully self-contained.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { CheckCircle2 } from 'lucide-react';
|
||||
import { EstimateWizardProvider, useEstimateWizard } from './EstimateWizardContext';
|
||||
import WizardShell from './components/WizardShell';
|
||||
|
||||
const CompletionScreen = () => {
|
||||
const { inputs, reset } = useEstimateWizard();
|
||||
return (
|
||||
<div className="min-h-[100dvh] w-full bg-zinc-50 dark:bg-[#09090b] flex items-center justify-center px-4 py-10">
|
||||
<div className="max-w-md text-center">
|
||||
<CheckCircle2 size={56} className="text-green-500 mx-auto mb-4" />
|
||||
<h1 className="text-2xl font-bold text-zinc-900 dark:text-white">
|
||||
Thanks{inputs.name ? `, ${inputs.name.split(' ')[0]}` : ''}!
|
||||
</h1>
|
||||
<p className="text-zinc-500 dark:text-zinc-400 mt-2">
|
||||
Your roof options report is ready and your details have been sent to our team —
|
||||
a rep will reach out to confirm your free inspection.
|
||||
</p>
|
||||
<button
|
||||
onClick={reset}
|
||||
className="mt-6 rounded-xl bg-gradient-to-r from-amber-400 to-orange-500 text-white font-semibold px-6 py-2.5 shadow-lg shadow-amber-500/25"
|
||||
>
|
||||
Build another estimate
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const FeatureDisabledScreen = () => (
|
||||
<div className="min-h-[100dvh] w-full bg-zinc-50 dark:bg-[#09090b] flex items-center justify-center px-4 py-10">
|
||||
<div className="max-w-md text-center">
|
||||
<h1 className="text-xl font-bold text-zinc-900 dark:text-white">Estimate wizard unavailable</h1>
|
||||
<p className="text-zinc-500 dark:text-zinc-400 mt-2">
|
||||
This tool is temporarily turned off. Please contact us directly and we’ll be glad to help.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const WizardRouter = () => {
|
||||
const { session, tenantConfig } = useEstimateWizard();
|
||||
if (tenantConfig.featureEnabled === false) return <FeatureDisabledScreen />; // §18 kill-switch
|
||||
return session.status === 'completed' ? <CompletionScreen /> : <WizardShell />;
|
||||
};
|
||||
|
||||
const CustomerEstimateWizard = ({ source = 'public' }) => (
|
||||
<EstimateWizardProvider source={source}>
|
||||
<WizardRouter />
|
||||
</EstimateWizardProvider>
|
||||
);
|
||||
|
||||
export default CustomerEstimateWizard;
|
||||
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* EstimateWizardAdmin — internal admin controls + analytics (spec §14, §16, §18).
|
||||
*
|
||||
* Tenant-controls (no hard-coded pricing/copy): feature kill-switch, AI toggle,
|
||||
* conservative mode, per-package price + base discount + service life, and report
|
||||
* branding. Persists as overrides via saveConfigOverrides(). Also shows a funnel
|
||||
* summary built from completed-session submissions (spec §16).
|
||||
*/
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { Save, RotateCcw, Power, Sparkles, ShieldCheck, BarChart3, Package, History } from 'lucide-react';
|
||||
import {
|
||||
getTenantConfig, loadConfigOverrides, publishConfigOverrides, resetConfigOverrides,
|
||||
} from './data/tenantConfig';
|
||||
import { loadSubmissions } from './api/crmHandoff';
|
||||
|
||||
const PKG_LABEL = { GOOD: 'Good', BETTER: 'Better', BEST: 'Best' };
|
||||
|
||||
const Toggle = ({ label, desc, checked, onChange, icon: Icon }) => (
|
||||
<label className="flex items-start justify-between gap-3 rounded-xl border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 p-4 cursor-pointer">
|
||||
<div className="flex items-start gap-2.5">
|
||||
{Icon && <Icon size={18} className="text-amber-500 mt-0.5" />}
|
||||
<div>
|
||||
<div className="font-semibold text-zinc-900 dark:text-white">{label}</div>
|
||||
<div className="text-xs text-zinc-500 dark:text-zinc-400">{desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
<input type="checkbox" className="mt-1 h-5 w-5 accent-amber-500 shrink-0" checked={checked} onChange={(e) => onChange(e.target.checked)} />
|
||||
</label>
|
||||
);
|
||||
|
||||
const numInput = 'w-full rounded-lg border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 px-2.5 py-1.5 text-sm text-zinc-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-amber-500';
|
||||
|
||||
const textInput = numInput;
|
||||
const areaInput = numInput + ' min-h-[64px] resize-y leading-relaxed';
|
||||
|
||||
const EstimateWizardAdmin = () => {
|
||||
const initial = useMemo(() => getTenantConfig(), []);
|
||||
const [cfg, setCfg] = useState(initial);
|
||||
const [versionInfo, setVersionInfo] = useState(() => {
|
||||
const o = loadConfigOverrides();
|
||||
return { version: initial.version, versionedAt: o.versionedAt || null, history: o.versionHistory || [] };
|
||||
});
|
||||
|
||||
const submissions = useMemo(() => loadSubmissions(), []);
|
||||
const analytics = useMemo(() => {
|
||||
const total = submissions.length;
|
||||
const dist = (key) => submissions.reduce((m, s) => { const k = s[key]; if (k) m[k] = (m[k] || 0) + 1; return m; }, {});
|
||||
const booked = submissions.filter((s) => s.report?.appointmentBooked).length;
|
||||
const addonAttach = total ? (submissions.reduce((n, s) => n + (s.selectedAddons?.length || 0), 0) / total) : 0;
|
||||
return { total, recommended: dist('recommendedPackage'), selected: dist('selectedPackage'), booked, addonAttach };
|
||||
}, [submissions]);
|
||||
|
||||
const setFlag = (k, v) => setCfg((c) => ({ ...c, [k]: v }));
|
||||
const setPkg = (type, field, value) => setCfg((c) => ({
|
||||
...c,
|
||||
packages: c.packages.map((p) => (p.type === type ? { ...p, [field]: value } : p)),
|
||||
}));
|
||||
// Included scope is edited as one item per line.
|
||||
const setScope = (type, text) => setPkg(type, 'scope', text.split('\n').map((s) => s.trim()).filter(Boolean));
|
||||
const setDiscount = (type, value) => setCfg((c) => ({
|
||||
...c,
|
||||
discountScenarios: { ...c.discountScenarios, [type]: { ...c.discountScenarios[type], base: value } },
|
||||
}));
|
||||
const setBranding = (field, value) => setCfg((c) => ({ ...c, branding: { ...c.branding, [field]: value } }));
|
||||
|
||||
const handleSave = () => {
|
||||
const prev = loadConfigOverrides();
|
||||
const overrides = {
|
||||
...prev,
|
||||
featureEnabled: cfg.featureEnabled,
|
||||
aiExplanationsEnabled: cfg.aiExplanationsEnabled,
|
||||
conservativeMode: cfg.conservativeMode,
|
||||
branding: cfg.branding,
|
||||
discountScenarios: cfg.discountScenarios,
|
||||
// Full Good/Better/Best content is tenant-controlled (spec §4/§14).
|
||||
packages: cfg.packages.map((p) => ({
|
||||
type: p.type,
|
||||
name: p.name,
|
||||
category: p.category,
|
||||
warrantyLabel: p.warrantyLabel,
|
||||
hailResistance: p.hailResistance,
|
||||
position: p.position,
|
||||
fit: p.fit,
|
||||
scope: p.scope,
|
||||
pricePerSquare: p.pricePerSquare,
|
||||
serviceLifeYears: p.serviceLifeYears,
|
||||
})),
|
||||
};
|
||||
const saved = publishConfigOverrides(overrides); // bumps version + history
|
||||
setVersionInfo({ version: saved.version, versionedAt: saved.versionedAt, history: saved.versionHistory });
|
||||
setCfg((c) => ({ ...c, version: saved.version }));
|
||||
toast.success(`Published ${saved.version}`);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
resetConfigOverrides();
|
||||
const fresh = getTenantConfig();
|
||||
setCfg(fresh);
|
||||
setVersionInfo({ version: fresh.version, versionedAt: null, history: [] });
|
||||
toast.message('Reset to defaults');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto bg-zinc-50 dark:bg-[#09090b]">
|
||||
<div className="max-w-4xl mx-auto p-4 md:p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-zinc-900 dark:text-white flex items-center gap-2">
|
||||
<span className="w-2 h-6 bg-amber-500 rounded-full" /> Estimate Wizard — Admin
|
||||
</h1>
|
||||
<p className="text-zinc-500 dark:text-zinc-400 text-sm ml-4">
|
||||
Tenant controls for package content, pricing, discounts, AI, branding, and analytics.
|
||||
</p>
|
||||
<div className="ml-4 mt-1.5 flex items-center gap-2 text-[11px]">
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-zinc-100 dark:bg-white/5 px-2 py-0.5 font-mono font-semibold text-zinc-600 dark:text-zinc-300">
|
||||
<History size={11} /> {versionInfo.version}
|
||||
</span>
|
||||
{versionInfo.versionedAt && (
|
||||
<span className="text-zinc-400">
|
||||
published {new Date(versionInfo.versionedAt).toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={handleReset} className="rounded-xl border border-zinc-200 dark:border-white/10 px-3 py-2 text-sm font-medium text-zinc-600 dark:text-zinc-300 hover:border-amber-400 flex items-center gap-1.5">
|
||||
<RotateCcw size={15} /> Reset
|
||||
</button>
|
||||
<button onClick={handleSave} className="rounded-xl bg-gradient-to-r from-amber-400 to-orange-500 text-white px-4 py-2 text-sm font-semibold flex items-center gap-1.5 shadow-lg shadow-amber-500/25">
|
||||
<Save size={15} /> Save & publish
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feature flags */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<Toggle icon={Power} label="Feature enabled" desc="Master kill-switch for the public wizard." checked={cfg.featureEnabled} onChange={(v) => setFlag('featureEnabled', v)} />
|
||||
<Toggle icon={Sparkles} label="AI explanations" desc="Use AI copy (off = deterministic text)." checked={cfg.aiExplanationsEnabled} onChange={(v) => setFlag('aiExplanationsEnabled', v)} />
|
||||
<Toggle icon={ShieldCheck} label="Conservative mode" desc="Hide customer-facing savings claims." checked={cfg.conservativeMode} onChange={(v) => setFlag('conservativeMode', v)} />
|
||||
</div>
|
||||
|
||||
{/* Good / Better / Best content — fully tenant-controlled (spec §4/§14) */}
|
||||
<section className="rounded-2xl border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 p-4">
|
||||
<h2 className="font-semibold text-zinc-900 dark:text-white mb-1 flex items-center gap-2">
|
||||
<Package size={17} className="text-amber-500" /> Good / Better / Best content
|
||||
</h2>
|
||||
<p className="text-[11px] text-zinc-400 mb-4">
|
||||
Every customer-facing field is editable here — nothing is hard-coded. Saving publishes a new version.
|
||||
</p>
|
||||
<div className="space-y-5">
|
||||
{cfg.packages.map((p) => (
|
||||
<div key={p.type} className="rounded-xl border border-zinc-200 dark:border-white/10 p-3.5">
|
||||
<div className="text-xs font-bold uppercase tracking-wide text-amber-600 dark:text-amber-400 mb-3">{PKG_LABEL[p.type]}</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<label className="text-xs text-zinc-500 dark:text-zinc-400">Package name
|
||||
<input className={textInput} value={p.name} onChange={(e) => setPkg(p.type, 'name', e.target.value)} />
|
||||
</label>
|
||||
<label className="text-xs text-zinc-500 dark:text-zinc-400">Category / description
|
||||
<input className={textInput} value={p.category} onChange={(e) => setPkg(p.type, 'category', e.target.value)} />
|
||||
</label>
|
||||
<label className="text-xs text-zinc-500 dark:text-zinc-400">Warranty label
|
||||
<input className={textInput} value={p.warrantyLabel} onChange={(e) => setPkg(p.type, 'warrantyLabel', e.target.value)} />
|
||||
</label>
|
||||
<label className="text-xs text-zinc-500 dark:text-zinc-400">Hail resistance label
|
||||
<input className={textInput} value={p.hailResistance} onChange={(e) => setPkg(p.type, 'hailResistance', e.target.value)} />
|
||||
</label>
|
||||
<label className="text-xs text-zinc-500 dark:text-zinc-400 sm:col-span-2">Positioning / upgrade copy
|
||||
<input className={textInput} value={p.position} onChange={(e) => setPkg(p.type, 'position', e.target.value)} />
|
||||
</label>
|
||||
<label className="text-xs text-zinc-500 dark:text-zinc-400 sm:col-span-2">Best-fit description
|
||||
<textarea className={areaInput} value={p.fit} onChange={(e) => setPkg(p.type, 'fit', e.target.value)} />
|
||||
</label>
|
||||
<label className="text-xs text-zinc-500 dark:text-zinc-400 sm:col-span-2">Included scope (one item per line)
|
||||
<textarea className={areaInput} value={(p.scope || []).join('\n')} onChange={(e) => setScope(p.type, e.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3 mt-3">
|
||||
<label className="text-xs text-zinc-500 dark:text-zinc-400">Price / square ($)
|
||||
<input type="number" className={numInput} value={p.pricePerSquare} onChange={(e) => setPkg(p.type, 'pricePerSquare', Number(e.target.value))} />
|
||||
</label>
|
||||
<label className="text-xs text-zinc-500 dark:text-zinc-400">Service life (yrs)
|
||||
<input type="number" className={numInput} value={p.serviceLifeYears} onChange={(e) => setPkg(p.type, 'serviceLifeYears', Number(e.target.value))} />
|
||||
</label>
|
||||
<label className="text-xs text-zinc-500 dark:text-zinc-400">Base discount (%)
|
||||
<input type="number" className={numInput} value={cfg.discountScenarios[p.type]?.base ?? 0} onChange={(e) => setDiscount(p.type, Number(e.target.value))} />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[11px] text-zinc-400 mt-3">Customer copy always states discounts are illustrative — "ask your carrier to confirm."</p>
|
||||
</section>
|
||||
|
||||
{/* Branding */}
|
||||
<section className="rounded-2xl border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 p-4">
|
||||
<h2 className="font-semibold text-zinc-900 dark:text-white mb-3">Report branding</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<label className="text-xs text-zinc-500 dark:text-zinc-400">Company
|
||||
<input className={numInput} value={cfg.branding.company} onChange={(e) => setBranding('company', e.target.value)} />
|
||||
</label>
|
||||
<label className="text-xs text-zinc-500 dark:text-zinc-400">Phone
|
||||
<input className={numInput} value={cfg.branding.phone} onChange={(e) => setBranding('phone', e.target.value)} />
|
||||
</label>
|
||||
<label className="text-xs text-zinc-500 dark:text-zinc-400">Primary color
|
||||
<input className={numInput} value={cfg.branding.primaryColor} onChange={(e) => setBranding('primaryColor', e.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Analytics */}
|
||||
<section className="rounded-2xl border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 p-4">
|
||||
<h2 className="font-semibold text-zinc-900 dark:text-white mb-3 flex items-center gap-2">
|
||||
<BarChart3 size={17} className="text-amber-500" /> Funnel analytics
|
||||
</h2>
|
||||
{analytics.total === 0 ? (
|
||||
<p className="text-sm text-zinc-500 dark:text-zinc-400">No completed estimates yet. Finish a wizard run to populate metrics.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<Metric label="Completed reports" value={analytics.total} />
|
||||
<Metric label="Appointments booked" value={analytics.booked} />
|
||||
<Metric label="Avg add-ons / report" value={analytics.addonAttach.toFixed(1)} />
|
||||
<Metric label="Best recommended" value={analytics.recommended.BEST || 0} />
|
||||
<DistRow label="Recommended split" dist={analytics.recommended} />
|
||||
<DistRow label="Customer-selected split" dist={analytics.selected} />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Version history */}
|
||||
<section className="rounded-2xl border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 p-4">
|
||||
<h2 className="font-semibold text-zinc-900 dark:text-white mb-3 flex items-center gap-2">
|
||||
<History size={17} className="text-amber-500" /> Version history
|
||||
</h2>
|
||||
{(!versionInfo.history || versionInfo.history.length === 0) ? (
|
||||
<p className="text-sm text-zinc-500 dark:text-zinc-400">
|
||||
No published changes yet — currently on defaults ({versionInfo.version}).
|
||||
Each save publishes a new version that reports are stamped with.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-1.5">
|
||||
{versionInfo.history.map((h) => (
|
||||
<li key={h.version} className="flex items-center justify-between text-sm border-b border-zinc-100 dark:border-white/5 pb-1.5 last:border-0">
|
||||
<span className="font-mono font-semibold text-zinc-700 dark:text-zinc-200">{h.version}</span>
|
||||
<span className="text-xs text-zinc-400">{new Date(h.versionedAt).toLocaleString()}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Metric = ({ label, value }) => (
|
||||
<div className="rounded-xl bg-zinc-50 dark:bg-white/5 p-3">
|
||||
<div className="text-xs text-zinc-400">{label}</div>
|
||||
<div className="text-xl font-bold text-zinc-900 dark:text-white mt-0.5">{value}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const DistRow = ({ label, dist }) => (
|
||||
<div className="rounded-xl bg-zinc-50 dark:bg-white/5 p-3 col-span-2">
|
||||
<div className="text-xs text-zinc-400 mb-1">{label}</div>
|
||||
<div className="flex gap-3 text-sm font-medium text-zinc-700 dark:text-zinc-200">
|
||||
{['GOOD', 'BETTER', 'BEST'].map((t) => (
|
||||
<span key={t}>{PKG_LABEL[t]}: <span className="text-amber-600 dark:text-amber-400">{dist[t] || 0}</span></span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default EstimateWizardAdmin;
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* EstimateWizardContext — self-contained session state for the DIY Estimate Wizard.
|
||||
*
|
||||
* This is the client-side stand-in for the spec's backend entities (§12):
|
||||
* estimate_sessions, estimate_inputs, hail_risk_snapshots, recommendations,
|
||||
* customer_reports, estimate_events.
|
||||
* Session state is held in React memory only — like the rest of the CRM demo store,
|
||||
* nothing is written to localStorage, so a full page reload starts a fresh session.
|
||||
*
|
||||
* Kept separate from the global MockStoreProvider on purpose — this is a new,
|
||||
* isolated module. A Phase 5 adapter can mirror sessions into the CRM mock store.
|
||||
*/
|
||||
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||
import { STEP_FLOW } from './data/questions';
|
||||
import { getTenantConfig, CONFIG_CHANGE_EVENT } from './data/tenantConfig';
|
||||
|
||||
// One-click demo seed (spec demo support). Every value matches a valid choice in
|
||||
// questions.js so the wizard renders selected cards correctly. Address is a real
|
||||
// Plano, TX location so the Nominatim geocoder + storm-history lookup resolve.
|
||||
export const DEMO_INPUTS = {
|
||||
name: 'Jordan Sample',
|
||||
address: '2700 W 15th St, Plano, TX 75075',
|
||||
phone: '(972) 555-0142',
|
||||
email: 'jordan.sample@example.com',
|
||||
contactMethod: 'text',
|
||||
consent: true,
|
||||
roofAgeYears: 14,
|
||||
issueType: 'storm',
|
||||
ownershipHorizon: '16+',
|
||||
premiumAmount: 2400,
|
||||
premiumUnknown: false,
|
||||
budgetPreference: 'balanced',
|
||||
hoaConstraints: 'no',
|
||||
atticConcern: 'energy_bills',
|
||||
pestConcern: 'no',
|
||||
financingInterest: 'maybe',
|
||||
};
|
||||
|
||||
const EstimateWizardContext = createContext(null);
|
||||
|
||||
// A stable-ish id without relying on Date.now/Math.random being available everywhere.
|
||||
function makeSessionId() {
|
||||
const t = new Date().toISOString().replace(/[^0-9]/g, '').slice(0, 14);
|
||||
const r = Math.floor(performance.now() % 100000).toString().padStart(5, '0');
|
||||
return `es_${t}_${r}`;
|
||||
}
|
||||
|
||||
function freshSession(source = 'public') {
|
||||
return {
|
||||
id: makeSessionId(),
|
||||
tenantId: 'lynkeduppro',
|
||||
status: 'started', // started → completed | abandoned
|
||||
source, // website | ad | qr | crm | public
|
||||
startedAt: new Date().toISOString(),
|
||||
completedAt: null,
|
||||
// estimate_inputs (§12)
|
||||
inputs: {
|
||||
name: '', address: '', phone: '', email: '', consent: false, contactMethod: 'email',
|
||||
roofAgeYears: 10,
|
||||
issueType: null,
|
||||
ownershipHorizon: null,
|
||||
premiumAmount: null, premiumUnknown: false,
|
||||
budgetPreference: null,
|
||||
hoaConstraints: null,
|
||||
atticConcern: null,
|
||||
pestConcern: null,
|
||||
financingInterest: null,
|
||||
},
|
||||
property: null, // property_profiles (Phase 2)
|
||||
hailSnapshot: null, // hail_risk_snapshots (Phase 2)
|
||||
selectedPackage: null, // customer's chosen option (Phase 3)
|
||||
selectedAddons: [], // add-on ids (Phase 3)
|
||||
recommendation: null, // recommendations (Phase 4)
|
||||
report: null, // customer_reports (Phase 5)
|
||||
events: [], // estimate_events (audit/analytics)
|
||||
stepIndex: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function EstimateWizardProvider({ children, source = 'public' }) {
|
||||
// Snapshot tenant config at mount, but stay reactive: the admin can publish flags
|
||||
// (kill-switch, AI copy, conservative mode), pricing, and package content while a
|
||||
// wizard is open. Refresh on the same-tab publish event so toggles take effect
|
||||
// without a hard reload (spec §14/§18).
|
||||
const [tenantConfig, setTenantConfig] = useState(getTenantConfig);
|
||||
const [session, setSession] = useState(() => freshSession(source));
|
||||
const [resumed] = useState(false); // no persistence → never a resumed session
|
||||
|
||||
useEffect(() => {
|
||||
const refresh = () => setTenantConfig(getTenantConfig());
|
||||
window.addEventListener(CONFIG_CHANGE_EVENT, refresh); // admin publish
|
||||
return () => window.removeEventListener(CONFIG_CHANGE_EVENT, refresh);
|
||||
}, []);
|
||||
|
||||
const logEvent = useCallback((eventType, payload = {}) => {
|
||||
setSession(s => ({
|
||||
...s,
|
||||
events: [...s.events, { eventType, payload, occurredAt: new Date().toISOString() }],
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const setInput = useCallback((key, value) => {
|
||||
setSession(s => ({ ...s, inputs: { ...s.inputs, [key]: value } }));
|
||||
}, []);
|
||||
|
||||
const patchSession = useCallback((patch) => {
|
||||
setSession(s => ({ ...s, ...patch }));
|
||||
}, []);
|
||||
|
||||
const goToStep = useCallback((index) => {
|
||||
const clamped = Math.max(0, Math.min(STEP_FLOW.length - 1, index));
|
||||
setSession(s => ({ ...s, stepIndex: clamped }));
|
||||
}, []);
|
||||
|
||||
const next = useCallback(() => {
|
||||
setSession(s => {
|
||||
const target = Math.min(STEP_FLOW.length - 1, s.stepIndex + 1);
|
||||
const stepId = STEP_FLOW[s.stepIndex]?.id;
|
||||
return {
|
||||
...s,
|
||||
stepIndex: target,
|
||||
events: [...s.events, { eventType: 'step_complete', payload: { stepId }, occurredAt: new Date().toISOString() }],
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const back = useCallback(() => {
|
||||
setSession(s => ({ ...s, stepIndex: Math.max(0, s.stepIndex - 1) }));
|
||||
}, []);
|
||||
|
||||
const complete = useCallback(() => {
|
||||
setSession(s => ({ ...s, status: 'completed', completedAt: new Date().toISOString() }));
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setSession(freshSession(source));
|
||||
}, [source]);
|
||||
|
||||
// Fill every input with a valid demo answer so the wizard can be exercised fast.
|
||||
const prefillDemo = useCallback(() => {
|
||||
setSession(s => ({
|
||||
...s,
|
||||
inputs: { ...s.inputs, ...DEMO_INPUTS },
|
||||
property: null, // force a fresh geocode on the address step
|
||||
hailSnapshot: null,
|
||||
events: [...s.events, { eventType: 'demo_prefill', payload: {}, occurredAt: new Date().toISOString() }],
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const value = {
|
||||
session,
|
||||
inputs: session.inputs,
|
||||
tenantConfig,
|
||||
resumed,
|
||||
currentStep: STEP_FLOW[session.stepIndex],
|
||||
stepIndex: session.stepIndex,
|
||||
totalSteps: STEP_FLOW.length,
|
||||
setInput,
|
||||
patchSession,
|
||||
logEvent,
|
||||
goToStep,
|
||||
next,
|
||||
back,
|
||||
complete,
|
||||
reset,
|
||||
prefillDemo,
|
||||
};
|
||||
|
||||
return (
|
||||
<EstimateWizardContext.Provider value={value}>
|
||||
{children}
|
||||
</EstimateWizardContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useEstimateWizard() {
|
||||
const ctx = useContext(EstimateWizardContext);
|
||||
if (!ctx) throw new Error('useEstimateWizard must be used within EstimateWizardProvider');
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* bannedClaims.js — guardrail for AI-generated customer copy (spec §15, §18).
|
||||
*
|
||||
* Every AI explanation MUST pass validateClaim() before it is shown or stored.
|
||||
* On any violation the caller discards the AI text and uses the deterministic
|
||||
* fallback. This enforces: no guaranteed discount, no guaranteed hail event,
|
||||
* no insurance/legal advice, no manufacturer names (unless brand mode enabled).
|
||||
*/
|
||||
|
||||
// Manufacturer / brand names that must not appear in customer-facing copy (§3, §15).
|
||||
const MANUFACTURER_NAMES = [
|
||||
'gaf', 'owens corning', 'certainteed', 'malarkey', 'iko', 'tamko',
|
||||
'atlas', 'decra', 'davinci', 'brava', 'f-wave', 'euroshield', 'boral', 'timberline',
|
||||
];
|
||||
|
||||
const BANNED_PATTERNS = [
|
||||
{ id: 'guaranteed_discount', re: /\b(guarantee[ds]?|guaranteed)\b[^.]*\b(discount|savings|premium)\b/i },
|
||||
{ id: 'guaranteed_discount2', re: /\b(discount|savings)\b[^.]*\bguarantee/i },
|
||||
{ id: 'will_get_hail', re: /\b(will|going to|guaranteed to)\b[^.]*\b(hail|storm|be hit|get hit)\b/i },
|
||||
{ id: 'never_replace', re: /\bnever (have to )?replace\b/i },
|
||||
{ id: 'lifetime_no_maintenance', re: /\b(maintenance[- ]free|zero maintenance) (for )?life/i },
|
||||
{ id: 'file_a_claim', re: /\b(file|submit) (a|an|your) (insurance )?claim\b/i },
|
||||
{ id: 'insurance_advice', re: /\b(you should|we recommend|advise you to) (change|switch|drop|cancel) (your )?insurance\b/i },
|
||||
];
|
||||
|
||||
/**
|
||||
* @returns {{ ok: boolean, violations: string[] }}
|
||||
*/
|
||||
export function validateClaim(text = '', { allowBrands = false } = {}) {
|
||||
const violations = [];
|
||||
const lower = text.toLowerCase();
|
||||
|
||||
for (const { id, re } of BANNED_PATTERNS) {
|
||||
if (re.test(text)) violations.push(id);
|
||||
}
|
||||
if (!allowBrands) {
|
||||
for (const name of MANUFACTURER_NAMES) {
|
||||
if (lower.includes(name)) { violations.push(`manufacturer:${name}`); break; }
|
||||
}
|
||||
}
|
||||
return { ok: violations.length === 0, violations };
|
||||
}
|
||||
|
||||
export const BANNED_CLAIM_IDS = BANNED_PATTERNS.map((p) => p.id);
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* explain.js — AI recommendation explanation + rep brief (spec §9).
|
||||
*
|
||||
* Design:
|
||||
* - The deterministic engine already chose the package and the reasons. The AI
|
||||
* only turns them into warm plain-language copy; it cannot change the result.
|
||||
* - Reuses the app's existing Groq integration (config.groqApiKey). With no valid
|
||||
* key (config.isDemoMode) it skips the API and uses a deterministic fallback.
|
||||
* - EVERY AI output passes validateClaim(); on any violation we discard the AI
|
||||
* text and use the safe fallback (spec §15, §18).
|
||||
*/
|
||||
import Groq from 'groq-sdk';
|
||||
import { config } from '../../../config/env';
|
||||
import { SYSTEM_PROMPT } from './knowledgeBase';
|
||||
import { validateClaim } from './bannedClaims';
|
||||
|
||||
const AI_MODEL = 'openai/gpt-oss-120b';
|
||||
|
||||
const PKG_LABEL = { GOOD: 'Good', BETTER: 'Better', BEST: 'Best' };
|
||||
|
||||
function fmtUSD(n) { return `$${Math.round(n).toLocaleString('en-US')}`; }
|
||||
|
||||
// ── Deterministic fallback (also the safety net if AI is unavailable/invalid) ──
|
||||
|
||||
function fallbackExplanation({ inputs, recommendation, valueModel }) {
|
||||
const type = recommendation.selectedPackage;
|
||||
const label = PKG_LABEL[type];
|
||||
const reasons = recommendation.reasons;
|
||||
|
||||
const row = valueModel.rows.find((r) => r.type === type);
|
||||
let savingsLine = '';
|
||||
if (valueModel.premiumKnown && row?.savings) {
|
||||
savingsLine = ` Over 10 years, an illustrative savings scenario ranges from ${fmtUSD(row.savings.low)} to ${fmtUSD(row.savings.high)} — this is only an estimate and must be confirmed with your insurance carrier.`;
|
||||
}
|
||||
|
||||
const customerExplanation =
|
||||
`Based on your answers, the ${label} option appears to be the strongest fit for your home and goals.` +
|
||||
` ${reasons[0] || ''}${savingsLine} We still recommend reviewing all three options with your roofing consultant, since final pricing, roof measurement, ventilation, and insurance eligibility must be verified.`;
|
||||
|
||||
return { customerExplanation, reasons, repBrief: buildRepBrief({ inputs, recommendation }), aiUsed: false, guardrail: { ok: true, violations: [] } };
|
||||
}
|
||||
|
||||
// Rep brief is deterministic regardless of AI (spec §9) — concise sales prep.
|
||||
function buildRepBrief({ inputs, recommendation }) {
|
||||
const parts = [];
|
||||
parts.push(`Recommended: ${PKG_LABEL[recommendation.selectedPackage]} (${recommendation.modelVersion}).`);
|
||||
if (inputs.ownershipHorizon) parts.push(`Ownership ${inputs.ownershipHorizon} yrs.`);
|
||||
if (inputs.budgetPreference) parts.push(`Budget pref: ${inputs.budgetPreference}.`);
|
||||
if (inputs.premiumUnknown || !inputs.premiumAmount) parts.push('Premium skipped — bring savings-scenario explanation.');
|
||||
else parts.push(`Premium ~$${inputs.premiumAmount}/yr.`);
|
||||
if (inputs.atticConcern && inputs.atticConcern !== 'none') parts.push('Mentioned attic/ventilation — bring ventilation guidance.');
|
||||
if (inputs.pestConcern === 'yes') parts.push('Pest concern — discuss critter guard.');
|
||||
if (inputs.issueType === 'leak') parts.push('Active leak — prioritize inspection.');
|
||||
if (inputs.hoaConstraints === 'yes') parts.push('HOA constraints — verify approval before final material.');
|
||||
if (inputs.financingInterest === 'yes') parts.push('Interested in financing — prepare options.');
|
||||
if (recommendation.budgetGuardApplied) parts.push('Budget guard applied (did not upsell to Best over stated low-cost preference).');
|
||||
if (recommendation.uncertaintyFlags.length) parts.push(`Data gaps: ${recommendation.uncertaintyFlags.join(', ')}.`);
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
// ── AI path ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function buildUserPrompt({ inputs, hailSnapshot, recommendation, valueModel }) {
|
||||
const type = recommendation.selectedPackage;
|
||||
const row = valueModel.rows.find((r) => r.type === type);
|
||||
return `Write a 3-4 sentence plain-language explanation for the homeowner of why the ${PKG_LABEL[type]} roof option is highlighted, then a one-line internal rep brief.
|
||||
|
||||
Context (do not contradict; do not add numbers not here):
|
||||
- Recommended option: ${PKG_LABEL[type]}
|
||||
- Deterministic reasons: ${recommendation.reasons.join(' | ')}
|
||||
- Ownership horizon: ${inputs.ownershipHorizon || 'unknown'}
|
||||
- Budget preference: ${inputs.budgetPreference || 'unknown'}
|
||||
- Roof age: ${inputs.roofAgeYears ?? 'unknown'} years
|
||||
- Area hail exposure (historical): ${hailSnapshot?.exposureTier || 'unknown'}
|
||||
- Annual premium: ${inputs.premiumUnknown || !inputs.premiumAmount ? 'not provided' : '$' + inputs.premiumAmount}
|
||||
- 10-yr illustrative savings (base, ${type}): ${row?.savings ? '$' + row.savings.base : 'n/a'}
|
||||
|
||||
Respond ONLY as JSON: {"customerExplanation": "...", "repBrief": "..."}`;
|
||||
}
|
||||
|
||||
export async function generateExplanation(ctx) {
|
||||
// Admin disabled AI (spec §18) or no valid key → deterministic mode.
|
||||
if (ctx.tenantConfig?.aiExplanationsEnabled === false || config.isDemoMode(config.groqApiKey)) {
|
||||
return fallbackExplanation(ctx);
|
||||
}
|
||||
|
||||
try {
|
||||
const groq = new Groq({ apiKey: config.groqApiKey, dangerouslyAllowBrowser: true });
|
||||
const completion = await groq.chat.completions.create({
|
||||
model: AI_MODEL,
|
||||
temperature: 0.3,
|
||||
max_tokens: 400,
|
||||
response_format: { type: 'json_object' },
|
||||
messages: [
|
||||
{ role: 'system', content: SYSTEM_PROMPT },
|
||||
{ role: 'user', content: buildUserPrompt(ctx) },
|
||||
],
|
||||
});
|
||||
|
||||
const raw = completion.choices?.[0]?.message?.content || '';
|
||||
let parsed;
|
||||
try { parsed = JSON.parse(raw); } catch { return fallbackExplanation(ctx); }
|
||||
|
||||
const customerExplanation = String(parsed.customerExplanation || '').trim();
|
||||
const repBriefAI = String(parsed.repBrief || '').trim();
|
||||
|
||||
// Guardrail: validate the customer-facing text. Any violation → safe fallback.
|
||||
const guardrail = validateClaim(customerExplanation);
|
||||
if (!guardrail.ok || customerExplanation.length < 20) {
|
||||
const fb = fallbackExplanation(ctx);
|
||||
return { ...fb, guardrail, blockedAiText: customerExplanation || null };
|
||||
}
|
||||
|
||||
// Rep brief stays deterministic + optional AI sentence (also validated).
|
||||
const repValidated = validateClaim(repBriefAI).ok ? repBriefAI : '';
|
||||
const repBrief = [buildRepBrief(ctx), repValidated].filter(Boolean).join(' ');
|
||||
|
||||
return { customerExplanation, reasons: ctx.recommendation.reasons, repBrief, aiUsed: true, guardrail };
|
||||
} catch {
|
||||
return fallbackExplanation(ctx);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* knowledgeBase.js — approved content blocks for AI grounding (spec §9, §15).
|
||||
* The AI must ground answers in these; unknown questions route to the rep. Keeping
|
||||
* the approved facts in one place makes the guardrails auditable.
|
||||
*/
|
||||
|
||||
export const APPROVED_FACTS = {
|
||||
class4: 'Class 4 means the roofing material has passed a higher impact-resistance test (UL 2218). Whether it affects an insurance premium is decided by the carrier, not the contractor.',
|
||||
hailFraming: 'Historical hail data describes the surrounding AREA over time. It is not a forecast or a guarantee that a specific home will be hit.',
|
||||
insuranceDisclaimer: 'Any insurance premium savings are illustrative and must be confirmed with the homeowner’s insurance carrier or licensed agent. Discounts depend on state, carrier, policy, roof class, and documentation.',
|
||||
serviceLife: 'Premium systems are designed for longer service life, but actual lifespan depends on installation, ventilation, maintenance, climate, and product. Avoid promises like “never replace again.”',
|
||||
preliminary: 'Every wizard estimate is preliminary until a roof measurement and contractor inspection verify it.',
|
||||
neutrality: 'Describe systems by generic category (architectural shingle, Class 4 impact-rated shingle, composite/synthetic or stone-coated steel). Do not name manufacturers.',
|
||||
};
|
||||
|
||||
// Compact system prompt for the AI explainer (kept short for fast demo responses).
|
||||
export const SYSTEM_PROMPT = `You are LynkedUp Pro's roofing estimate explainer. You write short, plain-language, HONEST explanations for a homeowner comparing Good/Better/Best roof options.
|
||||
|
||||
NON-NEGOTIABLE RULES:
|
||||
- Never guarantee an insurance discount or savings. Say savings are illustrative and must be confirmed with their carrier.
|
||||
- Never say a home WILL get hail or be hit. Only describe historical AREA exposure.
|
||||
- Never give insurance or legal advice. Never tell them to file a claim or change insurance.
|
||||
- Never name roofing manufacturers/brands. Use generic categories only.
|
||||
- Never promise "never replace again" — say "designed for longer service life."
|
||||
- Do not invent measurements, prices, or discounts beyond what you are given.
|
||||
- The recommended option is decided by a deterministic engine; you EXPLAIN it, you do not change it.
|
||||
|
||||
Approved facts you may use:
|
||||
- ${APPROVED_FACTS.class4}
|
||||
- ${APPROVED_FACTS.hailFraming}
|
||||
- ${APPROVED_FACTS.insuranceDisclaimer}
|
||||
- ${APPROVED_FACTS.serviceLife}
|
||||
|
||||
Tone: warm, clear, non-pushy. No hype.`;
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* crmHandoff.js — completion handoff to CRM + nurture (spec §11, §13, §17 E2).
|
||||
*
|
||||
* The wizard is a self-contained module. On completion it records a CRM-ready
|
||||
* submission (read by the admin funnel analytics) and the real lead is added to the
|
||||
* global mock store separately. Submissions are held in memory only — like the rest
|
||||
* of the CRM demo store, nothing is written to localStorage, so the list resets on a
|
||||
* full page reload. Also returns the immutable compliance record (spec §18 G1).
|
||||
*/
|
||||
|
||||
// Module-scoped so submissions survive route changes within a session, with no
|
||||
// persistence (matches the in-memory CRM store).
|
||||
let submissionsStore = [];
|
||||
|
||||
const PKG_LABEL = { GOOD: 'Good', BETTER: 'Better', BEST: 'Best' };
|
||||
|
||||
// Parse a free-typed "Street, City, ST 12345" string into CRM lead fields.
|
||||
function parseAddress(raw) {
|
||||
const address = (raw || '').trim();
|
||||
const zip = (address.match(/\b(\d{5})(?:-\d{4})?\b/) || [])[1] || '';
|
||||
const stateM = address.match(/,\s*([A-Z]{2})\b/) || address.match(/\b([A-Z]{2})\b(?=\s+\d{5})/);
|
||||
const state = stateM ? stateM[1] : 'TX';
|
||||
const cityM = address.match(/,\s*([^,]+?)\s*,\s*[A-Z]{2}\b/);
|
||||
const city = cityM ? cityM[1].trim() : 'Plano';
|
||||
return { address, city, state, zip };
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a completed wizard session → the global CRM mock store lead shape, so the
|
||||
* estimate shows up as a real lead. The caller passes this to MockStore.addLead()
|
||||
* (addLead injects id / createdAt / status). Pure — no React, no side effects.
|
||||
*/
|
||||
export function buildLeadFromSession(session, tenantConfig, reportMeta) {
|
||||
const i = session.inputs;
|
||||
const { address, city, state, zip } = parseAddress(i.address);
|
||||
const nameParts = (i.name || '').trim().split(/\s+/);
|
||||
const firstName = nameParts[0] || 'Homeowner';
|
||||
const lastName = nameParts.slice(1).join(' ');
|
||||
|
||||
const tier = session.hailSnapshot?.exposureTier || null;
|
||||
const urgency = tier === 'high' ? 'high' : 'standard';
|
||||
const priority = tier === 'high' ? 'high' : tier === 'moderate' ? 'medium' : 'low';
|
||||
|
||||
const rec = session.recommendation?.selectedPackage;
|
||||
const sel = session.selectedPackage;
|
||||
const noteParts = [
|
||||
'DIY Estimate Wizard self-serve estimate.',
|
||||
rec ? `Recommended: ${PKG_LABEL[rec] || rec}.` : '',
|
||||
sel ? `Customer selected: ${PKG_LABEL[sel] || sel}.` : '',
|
||||
tier ? `Historical hail exposure: ${tier}.` : '',
|
||||
session.selectedAddons?.length ? `Add-ons: ${session.selectedAddons.join(', ')}.` : '',
|
||||
reportMeta?.reportId ? `Report ${reportMeta.reportId}.` : '',
|
||||
].filter(Boolean);
|
||||
|
||||
return {
|
||||
firstName,
|
||||
lastName,
|
||||
address,
|
||||
city,
|
||||
state,
|
||||
zip,
|
||||
lat: session.property?.lat ?? null,
|
||||
lng: session.property?.lon ?? null,
|
||||
urgency,
|
||||
priority,
|
||||
leadSource: 'Estimate Wizard',
|
||||
phones: i.phone ? [{ id: '1', number: i.phone, type: 'Mobile', isPrimary: true }] : [],
|
||||
emails: i.email ? [{ email: i.email, isPrimary: true }] : [],
|
||||
contactMethod: i.contactMethod,
|
||||
propertyType: 'Single Family',
|
||||
propertyPhotos: [],
|
||||
leadType: 'Retail',
|
||||
workType: 'Roof Replacement',
|
||||
tradeType: 'Roofing',
|
||||
referralNote: '',
|
||||
canvasserId: '',
|
||||
canvasserName: '',
|
||||
assignedTo: '',
|
||||
followUpDate: '',
|
||||
isQuickCapture: false,
|
||||
insuranceCompany: '', claimNumber: '', claimStatus: '',
|
||||
adjusterName: '', adjusterPhone: '', policyNumber: '',
|
||||
notes: noteParts.join(' '),
|
||||
createdByName: tenantConfig?.branding?.company || 'Estimate Wizard',
|
||||
// Traceability back to the wizard session / report.
|
||||
estimateWizard: {
|
||||
sessionId: session.id,
|
||||
reportId: reportMeta?.reportId || null,
|
||||
recommendedPackage: rec || null,
|
||||
selectedPackage: sel || null,
|
||||
estimatedTotal: reportMeta?.estimatedTotal ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function loadSubmissions() {
|
||||
return submissionsStore;
|
||||
}
|
||||
|
||||
function persist(list) {
|
||||
submissionsStore = list;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the immutable compliance record stored with the report (spec §18 G1):
|
||||
* assumptions + source data + disclaimer version + package-config version + consent.
|
||||
*/
|
||||
export function buildComplianceRecord(session, tenantConfig) {
|
||||
return {
|
||||
consent: !!session.inputs.consent,
|
||||
tenantConfigVersion: tenantConfig.version,
|
||||
disclaimerVersion: tenantConfig.disclaimerVersion,
|
||||
scoringModelVersion: session.recommendation?.modelVersion || null,
|
||||
hailSource: session.hailSnapshot?.source || null,
|
||||
hailWindowYears: session.hailSnapshot?.windowYears || null,
|
||||
hailConfidence: session.hailSnapshot?.confidence || null,
|
||||
assumedSquares: tenantConfig.pricing.assumedSquares,
|
||||
conservativeMode: tenantConfig.conservativeMode,
|
||||
aiUsed: session.recommendation?.explanation?.aiUsed ?? false,
|
||||
generatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a CRM contact/lead payload + nurture trigger from a completed session.
|
||||
* @returns the submission record (also persisted).
|
||||
*/
|
||||
export function submitToCrm(session, tenantConfig, reportMeta) {
|
||||
const i = session.inputs;
|
||||
const submission = {
|
||||
sessionId: session.id,
|
||||
submittedAt: new Date().toISOString(),
|
||||
contact: { name: i.name, address: i.address, phone: i.phone, email: i.email, contactMethod: i.contactMethod },
|
||||
source: session.source,
|
||||
selectedPackage: session.selectedPackage,
|
||||
selectedAddons: session.selectedAddons,
|
||||
recommendedPackage: session.recommendation?.selectedPackage || null,
|
||||
hail: session.hailSnapshot
|
||||
? { exposureTier: session.hailSnapshot.exposureTier, p10Base: session.hailSnapshot.probability10yr?.base }
|
||||
: null,
|
||||
repBrief: session.recommendation?.explanation?.repBrief || null,
|
||||
report: reportMeta || null,
|
||||
crmLeadId: reportMeta?.crmLeadId || null, // link to the global mock-store lead
|
||||
compliance: buildComplianceRecord(session, tenantConfig),
|
||||
// Nurture handoff (spec §14): which playbook trigger fired.
|
||||
nurtureTrigger: 'wizard_completed',
|
||||
};
|
||||
|
||||
const list = loadSubmissions();
|
||||
list.unshift(submission);
|
||||
persist(list);
|
||||
return submission;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* geocode.js — module-local address geocoder for the Estimate Wizard.
|
||||
*
|
||||
* Uses OpenStreetMap Nominatim (keyless, CORS-open public service), consistent
|
||||
* with the repo's existing keyless-public-API approach (IEM / NOAA in the storm
|
||||
* module). This is NEW module code — it does not modify any existing hail/storm
|
||||
* module; it only produces the lat/lon those modules' endpoints consume.
|
||||
*
|
||||
* Always resolves: on empty input, network failure, or no match it falls back to
|
||||
* the demo service-area center (Plano, TX) so the wizard never dead-ends. The
|
||||
* caller is told via `confidence` / `fallback` so wording stays honest (spec §6).
|
||||
*/
|
||||
|
||||
// Demo service-area center — matches the storm module's WFO=FWD coverage.
|
||||
const FALLBACK = {
|
||||
lat: 33.0198,
|
||||
lon: -96.6989,
|
||||
displayName: 'Plano, TX (approximate area)',
|
||||
county: 'Collin',
|
||||
state: 'TX',
|
||||
confidence: 'low',
|
||||
fallback: true,
|
||||
};
|
||||
|
||||
const NOMINATIM = 'https://nominatim.openstreetmap.org/search';
|
||||
|
||||
export async function geocodeAddress(address) {
|
||||
const q = (address || '').trim();
|
||||
if (q.length < 4) return { ...FALLBACK };
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
q,
|
||||
format: 'json',
|
||||
addressdetails: '1',
|
||||
limit: '1',
|
||||
countrycodes: 'us',
|
||||
});
|
||||
const res = await fetch(`${NOMINATIM}?${params.toString()}`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!res.ok) throw new Error(`geocode ${res.status}`);
|
||||
const data = await res.json();
|
||||
const hit = Array.isArray(data) ? data[0] : null;
|
||||
if (!hit) return { ...FALLBACK };
|
||||
|
||||
const a = hit.address || {};
|
||||
// Nominatim "importance" (0..1) is a rough match-quality signal.
|
||||
const importance = typeof hit.importance === 'number' ? hit.importance : 0.4;
|
||||
const confidence = importance >= 0.5 ? 'high' : importance >= 0.3 ? 'moderate' : 'low';
|
||||
|
||||
return {
|
||||
lat: parseFloat(hit.lat),
|
||||
lon: parseFloat(hit.lon),
|
||||
displayName: hit.display_name || q,
|
||||
county: (a.county || '').replace(/ County$/i, ''),
|
||||
state: a.state || a['ISO3166-2-lvl4'] || '',
|
||||
confidence,
|
||||
fallback: false,
|
||||
};
|
||||
} catch {
|
||||
// Network/CORS/parse failure → safe fallback, flagged as such.
|
||||
return { ...FALLBACK };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* AddonToggles — optional upgrades as simple toggles (spec §8).
|
||||
* Plain-language value statements, never fear-based. "Recommended" badge when the
|
||||
* customer's answers or selected package trigger it. Price/total recompute instantly.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Plus, Check, Sparkles } from 'lucide-react';
|
||||
import { formatUSD, isAddonRecommended } from '../engine/pricing';
|
||||
|
||||
const AddonToggles = ({ addons, selectedIds, onToggle, packageType, triggers }) => {
|
||||
return (
|
||||
<div className="space-y-2.5">
|
||||
{addons.map((addon) => {
|
||||
const on = selectedIds.includes(addon.id);
|
||||
const recommended = isAddonRecommended(addon, packageType, triggers);
|
||||
return (
|
||||
<button
|
||||
key={addon.id}
|
||||
type="button"
|
||||
onClick={() => onToggle(addon.id)}
|
||||
aria-pressed={on}
|
||||
className={`w-full text-left rounded-2xl border p-4 flex items-start gap-3 transition-all
|
||||
focus:outline-none focus:ring-2 focus:ring-amber-500
|
||||
${on
|
||||
? 'border-amber-500 bg-amber-50 dark:bg-amber-500/10'
|
||||
: 'border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 hover:border-amber-400'}`}
|
||||
>
|
||||
<span className={`mt-0.5 shrink-0 w-6 h-6 rounded-lg flex items-center justify-center border
|
||||
${on ? 'bg-amber-500 border-amber-500 text-white' : 'border-zinc-300 dark:border-white/20 text-transparent'}`}>
|
||||
{on ? <Check size={15} /> : <Plus size={15} className="text-zinc-400" />}
|
||||
</span>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-semibold text-zinc-900 dark:text-white">{addon.name}</span>
|
||||
{recommended && (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-bold uppercase tracking-wide text-amber-600 dark:text-amber-400 bg-amber-100 dark:bg-amber-500/15 px-1.5 py-0.5 rounded-full">
|
||||
<Sparkles size={10} /> Recommended
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-0.5">{addon.value}</p>
|
||||
</div>
|
||||
|
||||
<span className="text-sm font-semibold text-zinc-700 dark:text-zinc-200 shrink-0">
|
||||
+{formatUSD(addon.price)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddonToggles;
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* ChoiceCards — single-select card group (spec §5: cards with 2–3 answer choices).
|
||||
* Used by the generic 'cards' step type. Manufacturer-neutral, plain language.
|
||||
*/
|
||||
import React from 'react';
|
||||
import * as Icons from 'lucide-react';
|
||||
|
||||
const ChoiceCards = ({ choices = [], value, onChange }) => {
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
{choices.map((choice) => {
|
||||
const selected = value === choice.value;
|
||||
const Icon = choice.icon ? Icons[choice.icon] : null;
|
||||
return (
|
||||
<button
|
||||
key={choice.value}
|
||||
type="button"
|
||||
onClick={() => onChange(choice.value)}
|
||||
aria-pressed={selected}
|
||||
className={`
|
||||
group relative text-left rounded-2xl border p-4 transition-all duration-200
|
||||
focus:outline-none focus:ring-2 focus:ring-amber-500
|
||||
${selected
|
||||
? 'border-amber-500 bg-amber-50 dark:bg-amber-500/10 shadow-md shadow-amber-500/10'
|
||||
: 'border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 hover:border-amber-400 hover:-translate-y-0.5'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{Icon && (
|
||||
<Icon
|
||||
size={22}
|
||||
className={`mb-2 ${selected ? 'text-amber-500' : 'text-zinc-400 group-hover:text-amber-500'}`}
|
||||
/>
|
||||
)}
|
||||
<div className={`font-semibold ${selected ? 'text-amber-700 dark:text-amber-300' : 'text-zinc-900 dark:text-white'}`}>
|
||||
{choice.label}
|
||||
</div>
|
||||
{choice.desc && (
|
||||
<div className="text-sm text-zinc-500 dark:text-zinc-400 mt-0.5">{choice.desc}</div>
|
||||
)}
|
||||
{selected && (
|
||||
<Icons.CheckCircle2 size={18} className="absolute top-3 right-3 text-amber-500" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChoiceCards;
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* GoodBetterBestCards — the three-option presentation (spec §7).
|
||||
* Rules honored:
|
||||
* - ALWAYS show all three options (never hide alternatives).
|
||||
* - Manufacturer-neutral copy (generic categories only).
|
||||
* - A `highlightedType` may be passed (Phase 4 recommendation) to badge one card,
|
||||
* but every option stays visible and selectable.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Check, Shield, Star } from 'lucide-react';
|
||||
import { pricePackage, formatUSD } from '../engine/pricing';
|
||||
|
||||
const ACCENT = {
|
||||
zinc: { ring: 'ring-zinc-300 dark:ring-zinc-600', text: 'text-zinc-600 dark:text-zinc-300', chip: 'bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-300' },
|
||||
blue: { ring: 'ring-blue-400', text: 'text-blue-600 dark:text-blue-400', chip: 'bg-blue-50 dark:bg-blue-500/10 text-blue-600 dark:text-blue-400' },
|
||||
amber: { ring: 'ring-amber-400', text: 'text-amber-600 dark:text-amber-400', chip: 'bg-amber-50 dark:bg-amber-500/10 text-amber-600 dark:text-amber-400' },
|
||||
};
|
||||
|
||||
const TIER_LABEL = { GOOD: 'Good', BETTER: 'Better', BEST: 'Best' };
|
||||
|
||||
const GoodBetterBestCards = ({ packages, pricing, selectedType, onSelect, highlightedType }) => {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{packages.map((pkg) => {
|
||||
const a = ACCENT[pkg.accent] || ACCENT.zinc;
|
||||
const price = pricePackage(pkg, pricing);
|
||||
const selected = selectedType === pkg.type;
|
||||
const highlighted = highlightedType === pkg.type;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={pkg.type}
|
||||
type="button"
|
||||
onClick={() => onSelect(pkg.type)}
|
||||
aria-pressed={selected}
|
||||
className={`relative text-left rounded-2xl border bg-white dark:bg-zinc-900 p-5 transition-all duration-200
|
||||
focus:outline-none flex flex-col
|
||||
${selected
|
||||
? `border-transparent ring-2 ${a.ring} shadow-lg`
|
||||
: 'border-zinc-200 dark:border-white/10 hover:-translate-y-0.5 hover:shadow-md'}`}
|
||||
>
|
||||
{highlighted && (
|
||||
<span className="absolute -top-2.5 left-1/2 -translate-x-1/2 inline-flex items-center gap-1 rounded-full bg-gradient-to-r from-amber-400 to-orange-500 text-white text-[11px] font-bold px-2.5 py-0.5 shadow">
|
||||
<Star size={11} /> Recommended
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={`text-xs font-bold uppercase tracking-wider ${a.text}`}>{TIER_LABEL[pkg.type]}</span>
|
||||
{selected && <Check size={18} className={a.text} />}
|
||||
</div>
|
||||
|
||||
<h3 className="font-bold text-zinc-900 dark:text-white mt-1">{pkg.name}</h3>
|
||||
<p className="text-xs text-zinc-500 dark:text-zinc-400 mt-0.5">{pkg.category}</p>
|
||||
|
||||
<div className="mt-3">
|
||||
<div className="text-2xl font-bold text-zinc-900 dark:text-white">
|
||||
{formatUSD(price.low)}–{formatUSD(price.high)}
|
||||
</div>
|
||||
<div className="text-[11px] text-zinc-400">preliminary range · verified at inspection</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5 mt-3">
|
||||
<span className={`text-[11px] font-medium px-2 py-0.5 rounded-full ${a.chip}`}>{pkg.warrantyLabel}</span>
|
||||
<span className="text-[11px] font-medium px-2 py-0.5 rounded-full bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-300 inline-flex items-center gap-1">
|
||||
<Shield size={10} /> {pkg.hailResistance} hail resistance
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ul className="mt-3 space-y-1.5 flex-1">
|
||||
{pkg.scope.map((s) => (
|
||||
<li key={s} className="flex items-start gap-1.5 text-xs text-zinc-600 dark:text-zinc-300">
|
||||
<Check size={13} className={`mt-0.5 shrink-0 ${a.text}`} /> {s}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<p className="text-[11px] text-zinc-400 mt-3 italic">{pkg.position}</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GoodBetterBestCards;
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* HailRiskSnapshot — presentational hail-risk card (spec §6).
|
||||
* Strictly historical framing + mandatory confidence/source/disclaimer.
|
||||
* Never says "your home will get hit" — only area-level historical exposure.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { CloudHail, TrendingUp, ShieldQuestion, Clock, Info } from 'lucide-react';
|
||||
|
||||
const tierStyle = {
|
||||
high: { text: 'text-red-600 dark:text-red-400', bg: 'bg-red-50 dark:bg-red-500/10', border: 'border-red-200 dark:border-red-500/20', label: 'Higher historical exposure' },
|
||||
moderate: { text: 'text-orange-600 dark:text-orange-400', bg: 'bg-orange-50 dark:bg-orange-500/10', border: 'border-orange-200 dark:border-orange-500/20', label: 'Moderate historical exposure' },
|
||||
low: { text: 'text-emerald-600 dark:text-emerald-400', bg: 'bg-emerald-50 dark:bg-emerald-500/10', border: 'border-emerald-200 dark:border-emerald-500/20', label: 'Lower historical exposure' },
|
||||
};
|
||||
|
||||
const Metric = ({ icon: Icon, label, value, sub }) => (
|
||||
<div className="rounded-xl border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 p-3.5">
|
||||
<div className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-zinc-400">
|
||||
{Icon && <Icon size={13} />} {label}
|
||||
</div>
|
||||
<div className="text-lg font-bold text-zinc-900 dark:text-white mt-1">{value}</div>
|
||||
{sub && <div className="text-xs text-zinc-500 dark:text-zinc-400 mt-0.5">{sub}</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
const HailRiskSnapshot = ({ snapshot }) => {
|
||||
if (!snapshot) return null;
|
||||
const tier = tierStyle[snapshot.exposureTier] || tierStyle.low;
|
||||
const p = snapshot.probability10yr;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Exposure banner */}
|
||||
<div className={`rounded-2xl border ${tier.border} ${tier.bg} p-4 flex items-start gap-3`}>
|
||||
<CloudHail size={24} className={tier.text} />
|
||||
<div>
|
||||
<div className={`font-bold ${tier.text}`}>{tier.label}</div>
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-300 mt-0.5">{snapshot.wording.probability}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metrics */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
<Metric
|
||||
icon={TrendingUp}
|
||||
label="10-yr probability"
|
||||
value={snapshot.eventCount === 0 ? '—' : `${p.low}–${p.high}%`}
|
||||
sub={`Base ${p.base}%`}
|
||||
/>
|
||||
<Metric
|
||||
icon={CloudHail}
|
||||
label="Annualized rate"
|
||||
value={`${snapshot.annualRate}/yr`}
|
||||
sub={`${snapshot.eventCount} events / ${snapshot.windowYears} yrs`}
|
||||
/>
|
||||
<Metric
|
||||
icon={ShieldQuestion}
|
||||
label="Confidence"
|
||||
value={snapshot.confidence}
|
||||
sub={`~${snapshot.radiusMiles} mi radius`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Source + freshness */}
|
||||
<div className="rounded-xl bg-zinc-50 dark:bg-white/5 p-3.5 space-y-1.5 text-xs text-zinc-500 dark:text-zinc-400">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Info size={13} /> Source: {snapshot.source}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Clock size={13} /> {snapshot.wording.lastUpdated}
|
||||
</div>
|
||||
<div>{snapshot.wording.confidence}</div>
|
||||
</div>
|
||||
|
||||
{/* Trust rule (spec §6): historical, not a forecast */}
|
||||
<p className="text-xs text-zinc-400 leading-relaxed">
|
||||
This is a historical hazard estimate for your <span className="font-medium">area</span> — not a forecast or
|
||||
guarantee for your specific home. Final assessment requires an on-site inspection.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HailRiskSnapshot;
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* PropertyMap — small Leaflet preview of the geocoded property + risk radius.
|
||||
* Uses the same default-marker-icon fix the rest of the app uses (Maps.jsx).
|
||||
*/
|
||||
import React from 'react';
|
||||
import { MapContainer, TileLayer, Marker, Circle } from 'react-leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import L from 'leaflet';
|
||||
import icon from 'leaflet/dist/images/marker-icon.png';
|
||||
import iconShadow from 'leaflet/dist/images/marker-shadow.png';
|
||||
|
||||
const DefaultIcon = L.icon({
|
||||
iconUrl: icon,
|
||||
shadowUrl: iconShadow,
|
||||
iconSize: [25, 41],
|
||||
iconAnchor: [12, 41],
|
||||
});
|
||||
L.Marker.prototype.options.icon = DefaultIcon;
|
||||
|
||||
const PropertyMap = ({ lat, lon, radiusMiles = 20, height = 220 }) => {
|
||||
if (lat == null || lon == null) return null;
|
||||
const center = [lat, lon];
|
||||
return (
|
||||
<div className="rounded-2xl overflow-hidden border border-zinc-200 dark:border-white/10" style={{ height }}>
|
||||
<MapContainer
|
||||
key={`${lat},${lon}`}
|
||||
center={center}
|
||||
zoom={11}
|
||||
scrollWheelZoom={false}
|
||||
dragging={false}
|
||||
doubleClickZoom={false}
|
||||
zoomControl={false}
|
||||
attributionControl={false}
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
>
|
||||
<TileLayer url="https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png" />
|
||||
<Circle
|
||||
center={center}
|
||||
radius={radiusMiles * 1609.34}
|
||||
pathOptions={{ color: '#fda913', fillColor: '#fda913', fillOpacity: 0.08, weight: 1.5 }}
|
||||
/>
|
||||
<Marker position={center} />
|
||||
</MapContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PropertyMap;
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* TenYearValueChart — 10-year value comparison panel (spec §10).
|
||||
*
|
||||
* Honors the full §10 brief:
|
||||
* - shows ASSUMPTIONS per tier (discount %, service life, hail positioning, value view)
|
||||
* - lets the customer TOGGLE discount scenarios (low / base / high)
|
||||
* - separates upfront cost from lifecycle value (breakeven + lifecycle delta)
|
||||
* - never claims a guaranteed discount; all savings labeled illustrative
|
||||
*
|
||||
* Savings/breakeven/lifecycle only render when a premium was provided and
|
||||
* conservative mode is off; otherwise the panel falls back to assumptions only.
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Legend, Cell } from 'recharts';
|
||||
|
||||
const PKG_LABEL = { GOOD: 'Good', BETTER: 'Better', BEST: 'Best' };
|
||||
const BAR_COLOR = { GOOD: '#a1a1aa', BETTER: '#3b82f6', BEST: '#fda913' };
|
||||
const SCENARIOS = [
|
||||
{ key: 'low', label: 'Low' },
|
||||
{ key: 'base', label: 'Base' },
|
||||
{ key: 'high', label: 'High' },
|
||||
];
|
||||
|
||||
const usd = (n) => `$${Number(n).toLocaleString('en-US')}`;
|
||||
|
||||
const TenYearValueChart = ({ valueModel }) => {
|
||||
const [scenario, setScenario] = useState('base');
|
||||
const showSavings = valueModel.premiumKnown && !valueModel.conservativeMode;
|
||||
|
||||
const data = valueModel.rows.map((r) => ({
|
||||
name: PKG_LABEL[r.type],
|
||||
type: r.type,
|
||||
Upfront: r.price.base,
|
||||
'Est. 10-yr savings': r.savings ? r.savings[scenario] : 0,
|
||||
}));
|
||||
|
||||
const fmtK = (n) => `$${(n / 1000).toFixed(0)}k`;
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 p-4">
|
||||
<div className="flex items-center justify-between gap-2 mb-3 flex-wrap">
|
||||
<h4 className="font-semibold text-zinc-900 dark:text-white text-sm">10-year value comparison</h4>
|
||||
{showSavings && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[11px] text-zinc-400 mr-1">Discount scenario</span>
|
||||
{SCENARIOS.map((s) => (
|
||||
<button
|
||||
key={s.key}
|
||||
type="button"
|
||||
onClick={() => setScenario(s.key)}
|
||||
className={`px-2.5 py-1 rounded-lg text-[11px] font-semibold transition-all
|
||||
${scenario === s.key
|
||||
? 'bg-amber-500 text-white shadow'
|
||||
: 'bg-zinc-100 dark:bg-white/5 text-zinc-500 dark:text-zinc-400 hover:text-amber-600'}`}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Upfront vs illustrative savings */}
|
||||
<div style={{ width: '100%', height: 200 }}>
|
||||
<ResponsiveContainer>
|
||||
<BarChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
|
||||
<XAxis dataKey="name" tick={{ fontSize: 12 }} stroke="#a1a1aa" />
|
||||
<YAxis tickFormatter={fmtK} tick={{ fontSize: 11 }} stroke="#a1a1aa" width={44} />
|
||||
<Tooltip
|
||||
formatter={(v) => usd(v)}
|
||||
contentStyle={{ borderRadius: 12, border: '1px solid #e4e4e7', fontSize: 12 }}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
<Bar dataKey="Upfront" radius={[4, 4, 0, 0]}>
|
||||
{data.map((d) => <Cell key={d.type} fill={BAR_COLOR[d.type]} fillOpacity={0.85} />)}
|
||||
</Bar>
|
||||
{showSavings && (
|
||||
<Bar dataKey="Est. 10-yr savings" radius={[4, 4, 0, 0]} fill="#22c55e" fillOpacity={0.8} />
|
||||
)}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* Assumptions + lifecycle metrics per tier */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2.5 mt-4">
|
||||
{valueModel.rows.map((r) => (
|
||||
<div key={r.type} className="rounded-xl border border-zinc-200 dark:border-white/10 p-3">
|
||||
<div className="text-xs font-bold uppercase tracking-wide text-zinc-500 dark:text-zinc-400 mb-2">{PKG_LABEL[r.type]}</div>
|
||||
<Metric label="Upfront (base)" value={usd(r.price.base)} />
|
||||
<Metric label="Discount assumption" value={`${r.discountScenario[scenario] ?? 0}%`} />
|
||||
<Metric label="Service life" value={`${r.serviceLifeYears}+ yrs`} />
|
||||
<Metric label="Hail resistance" value={r.hailResistance} />
|
||||
<Metric label="10-yr value view" value={r.valueView} muted />
|
||||
{showSavings && (
|
||||
<>
|
||||
<div className="border-t border-zinc-100 dark:border-white/5 my-2" />
|
||||
<Metric label="Est. 10-yr savings" value={r.savings ? usd(r.savings[scenario]) : '—'} accent />
|
||||
{/* Breakeven only when pricing AND discount known (spec §10 display rule). */}
|
||||
<Metric label="Upgrade breakeven" value={r.breakeven[scenario] != null ? `${r.breakeven[scenario]} yrs` : '—'} />
|
||||
<Metric label="Lifecycle value delta" value={usd(r.lifecycleDelta[scenario])} hint="model estimate" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-zinc-400 mt-3 leading-relaxed">
|
||||
{showSavings
|
||||
? 'Savings scenarios are illustrative model estimates — not guaranteed. They depend on carrier approval, policy terms, state rules, and documentation. Lifecycle value delta is a model estimate, not a promise.'
|
||||
: valueModel.conservativeMode
|
||||
? 'Savings scenarios are hidden. Figures show upfront cost and durability assumptions only.'
|
||||
: 'Add your insurance premium earlier in the wizard to see illustrative 10-year savings, breakeven, and lifecycle value scenarios.'}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Metric = ({ label, value, accent, muted, hint }) => (
|
||||
<div className="flex items-baseline justify-between gap-2 py-0.5">
|
||||
<span className="text-[11px] text-zinc-400">{label}</span>
|
||||
<span className={`text-xs font-semibold text-right ${accent ? 'text-emerald-600 dark:text-emerald-400' : muted ? 'text-zinc-500 dark:text-zinc-400' : 'text-zinc-800 dark:text-zinc-100'}`}>
|
||||
{value}
|
||||
{hint && <span className="block text-[9px] font-normal text-zinc-400 italic">{hint}</span>}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default TenYearValueChart;
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* WizardProgress — slim progress bar + step counter (spec §4: "keep the customer moving").
|
||||
*/
|
||||
import React from 'react';
|
||||
|
||||
const WizardProgress = ({ stepIndex, totalSteps }) => {
|
||||
const pct = Math.round(((stepIndex + 1) / totalSteps) * 100);
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-semibold tracking-wider uppercase text-zinc-400">
|
||||
Step {stepIndex + 1} of {totalSteps}
|
||||
</span>
|
||||
<span className="text-xs font-semibold text-amber-500">{pct}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 w-full rounded-full bg-zinc-200 dark:bg-zinc-800 overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-gradient-to-r from-amber-400 to-orange-500 transition-all duration-500 ease-out"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WizardProgress;
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* WizardShell — the one-question-per-screen engine (spec §4, §5, §17 EPIC A2).
|
||||
*
|
||||
* Responsibilities:
|
||||
* - render the current step (custom component | cards | slider)
|
||||
* - gate Continue on required answers; allow explicit Skip where permitted
|
||||
* - Back/Continue navigation, completion on the final step
|
||||
* - mobile-first single-CTA layout (spec §4: "one primary CTA per screen")
|
||||
*/
|
||||
import React from 'react';
|
||||
import { ArrowLeft, ArrowRight, RotateCcw } from 'lucide-react';
|
||||
import { useEstimateWizard } from '../EstimateWizardContext';
|
||||
import WizardProgress from './WizardProgress';
|
||||
import ChoiceCards from './ChoiceCards';
|
||||
import StartStep from './steps/StartStep';
|
||||
import AddressStep from './steps/AddressStep';
|
||||
import PremiumStep from './steps/PremiumStep';
|
||||
import HailRiskStep from './steps/HailRiskStep';
|
||||
import OptionsStep from './steps/OptionsStep';
|
||||
import AddonsStep from './steps/AddonsStep';
|
||||
import RecommendationStep from './steps/RecommendationStep';
|
||||
import ReportStep from './steps/ReportStep';
|
||||
|
||||
// Step registry — every step now has a real component.
|
||||
const STEP_COMPONENTS = {
|
||||
start: StartStep,
|
||||
address: AddressStep,
|
||||
premium: PremiumStep,
|
||||
hailRisk: HailRiskStep,
|
||||
options: OptionsStep,
|
||||
addons: AddonsStep,
|
||||
recommendation: RecommendationStep,
|
||||
report: ReportStep,
|
||||
};
|
||||
|
||||
// Validation per step: returns true when the user may advance.
|
||||
function canAdvance(step, inputs, session) {
|
||||
if (step.id === 'start') return inputs.consent && inputs.name?.trim() && inputs.address?.trim();
|
||||
if (step.id === 'address') return !!inputs.address?.trim();
|
||||
if (step.id === 'options') return !!session.selectedPackage;
|
||||
if (step.type === 'cards' && step.required) return inputs[step.inputKey] != null;
|
||||
if (step.type === 'slider' && step.required) return inputs[step.inputKey] != null;
|
||||
return true;
|
||||
}
|
||||
|
||||
const SliderStep = ({ step }) => {
|
||||
const { inputs, setInput } = useEstimateWizard();
|
||||
const val = inputs[step.inputKey] ?? step.min;
|
||||
return (
|
||||
<div className="space-y-6 pt-2">
|
||||
<div className="text-center">
|
||||
<span className="text-5xl font-bold text-amber-500">{val}</span>
|
||||
<span className="text-lg text-zinc-400 ml-1">
|
||||
{val >= step.max ? `+ ${step.unit}` : step.unit}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={step.min}
|
||||
max={step.max}
|
||||
step={step.step}
|
||||
value={val}
|
||||
onChange={(e) => setInput(step.inputKey, Number(e.target.value))}
|
||||
className="w-full accent-amber-500"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-zinc-400">
|
||||
<span>{step.min} {step.unit}</span>
|
||||
<span>{Math.round((step.min + step.max) / 2)} {step.unit}</span>
|
||||
<span>{step.maxLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const WizardShell = () => {
|
||||
const {
|
||||
currentStep: step, stepIndex, totalSteps, inputs,
|
||||
setInput, next, back, complete, reset, resumed, session,
|
||||
} = useEstimateWizard();
|
||||
|
||||
const isLast = stepIndex === totalSteps - 1;
|
||||
const advance = canAdvance(step, inputs, session);
|
||||
|
||||
const renderBody = () => {
|
||||
if (step.type === 'cards') {
|
||||
return (
|
||||
<ChoiceCards
|
||||
choices={step.choices}
|
||||
value={inputs[step.inputKey]}
|
||||
onChange={(v) => setInput(step.inputKey, v)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (step.type === 'slider') return <SliderStep step={step} />;
|
||||
const Comp = STEP_COMPONENTS[step.id];
|
||||
return Comp ? <Comp /> : null;
|
||||
};
|
||||
|
||||
const handleContinue = () => {
|
||||
if (isLast) { complete(); return; }
|
||||
next();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-[100dvh] w-full bg-zinc-50 dark:bg-[#09090b] flex flex-col">
|
||||
{/* Top bar */}
|
||||
<header className="w-full border-b border-zinc-200 dark:border-white/5 bg-white/70 dark:bg-zinc-900/60 backdrop-blur-md">
|
||||
<div className="max-w-2xl mx-auto px-4 py-3 sm:py-4">
|
||||
<div className="flex items-center justify-between gap-3 mb-3">
|
||||
<span className="font-bold tracking-tight text-zinc-900 dark:text-white truncate">
|
||||
LynkedUp<span className="text-amber-500">Pro</span>
|
||||
<span className="ml-2 text-xs font-medium text-zinc-400 hidden xs:inline">Roof Options</span>
|
||||
</span>
|
||||
<button
|
||||
onClick={reset}
|
||||
title="Start over"
|
||||
className="text-xs text-zinc-400 hover:text-amber-500 flex items-center gap-1"
|
||||
>
|
||||
<RotateCcw size={13} /> Start over
|
||||
</button>
|
||||
</div>
|
||||
<WizardProgress stepIndex={stepIndex} totalSteps={totalSteps} />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Resume hint */}
|
||||
{resumed && session.status !== 'completed' && stepIndex === 0 && (
|
||||
<div className="max-w-2xl mx-auto w-full px-4 pt-3">
|
||||
<div className="rounded-xl bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/20 px-3 py-2 text-xs text-amber-700 dark:text-amber-300">
|
||||
Welcome back — we saved your progress. You can continue or start over.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Body */}
|
||||
<main className="flex-1 w-full">
|
||||
<div className="max-w-2xl mx-auto px-4 py-6 sm:py-8">
|
||||
{/* key forces remount per step → re-triggers the enter animation */}
|
||||
<div key={step.id} className="animate-in fade-in slide-in-from-right-4 duration-300">
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-zinc-900 dark:text-white tracking-tight">
|
||||
{step.title}
|
||||
</h1>
|
||||
{step.subtitle && (
|
||||
<p className="text-zinc-500 dark:text-zinc-400 mt-1.5 mb-6">{step.subtitle}</p>
|
||||
)}
|
||||
<div className="mt-6">{renderBody()}</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Footer nav — one primary CTA. Respects the mobile safe-area inset so the
|
||||
CTA clears the iPhone home indicator. */}
|
||||
<footer
|
||||
className="sticky bottom-0 w-full border-t border-zinc-200 dark:border-white/5 bg-white/80 dark:bg-zinc-900/70 backdrop-blur-md"
|
||||
style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}
|
||||
>
|
||||
<div className="max-w-2xl mx-auto px-4 py-3 flex items-center gap-2 sm:gap-3">
|
||||
{stepIndex > 0 && (
|
||||
<button
|
||||
onClick={back}
|
||||
className="shrink-0 rounded-xl border border-zinc-200 dark:border-white/10 px-3 sm:px-4 py-2.5 text-sm font-medium text-zinc-600 dark:text-zinc-300 hover:border-amber-400 flex items-center gap-1.5"
|
||||
>
|
||||
<ArrowLeft size={16} /> <span className="hidden xs:inline">Back</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{step.skippable && !isLast && (
|
||||
<button
|
||||
onClick={next}
|
||||
className="shrink-0 text-sm font-medium text-zinc-400 hover:text-amber-500 px-1.5 sm:px-2 py-2.5"
|
||||
>
|
||||
Skip
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleContinue}
|
||||
disabled={!advance}
|
||||
className={`ml-auto min-w-0 rounded-xl px-5 sm:px-6 py-2.5 text-sm font-semibold flex items-center justify-center gap-1.5 whitespace-nowrap transition-all
|
||||
${advance
|
||||
? 'bg-gradient-to-r from-amber-400 to-orange-500 text-white shadow-lg shadow-amber-500/25 hover:shadow-amber-500/40'
|
||||
: 'bg-zinc-200 dark:bg-zinc-800 text-zinc-400 cursor-not-allowed'}`}
|
||||
>
|
||||
{isLast ? 'Finish' : stepIndex === 0 ? 'Start estimate' : 'Continue'}
|
||||
{!isLast && <ArrowRight size={16} className="shrink-0" />}
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WizardShell;
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* AddonsStep — Screen 7b "Add-ons" (spec §8).
|
||||
* Add-ons pre-select from the customer's answers + chosen package (once), then the
|
||||
* customer can toggle freely. Running total recomputes instantly.
|
||||
*/
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import { useEstimateWizard } from '../../EstimateWizardContext';
|
||||
import AddonToggles from '../AddonToggles';
|
||||
import {
|
||||
pricePackage, addonsTotal, deriveAddonTriggers, defaultSelectedAddons, formatUSD,
|
||||
} from '../../engine/pricing';
|
||||
|
||||
const AddonsStep = () => {
|
||||
const { inputs, session, patchSession, tenantConfig, logEvent } = useEstimateWizard();
|
||||
const { addons, packages, pricing } = tenantConfig;
|
||||
|
||||
const packageType = session.selectedPackage || 'BETTER';
|
||||
const triggers = useMemo(() => deriveAddonTriggers(inputs, session.hailSnapshot), [inputs, session.hailSnapshot]);
|
||||
|
||||
// Pre-select recommended add-ons once (spec §8 default-selected rules).
|
||||
useEffect(() => {
|
||||
if (session.addonsInitialized) return;
|
||||
patchSession({
|
||||
selectedAddons: defaultSelectedAddons(addons, packageType, triggers),
|
||||
addonsInitialized: true,
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [session.addonsInitialized]);
|
||||
|
||||
const selectedIds = session.selectedAddons || [];
|
||||
|
||||
const handleToggle = (id) => {
|
||||
const next = selectedIds.includes(id)
|
||||
? selectedIds.filter((x) => x !== id)
|
||||
: [...selectedIds, id];
|
||||
patchSession({ selectedAddons: next });
|
||||
logEvent('addon_toggled', { id, on: !selectedIds.includes(id) });
|
||||
};
|
||||
|
||||
const pkg = packages.find((p) => p.type === packageType);
|
||||
const base = pkg ? pricePackage(pkg, pricing).base : 0;
|
||||
const addonsSum = addonsTotal(addons, selectedIds);
|
||||
const total = base + addonsSum;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<AddonToggles
|
||||
addons={addons}
|
||||
selectedIds={selectedIds}
|
||||
onToggle={handleToggle}
|
||||
packageType={packageType}
|
||||
triggers={triggers}
|
||||
/>
|
||||
|
||||
{/* Running total for the selected package. Not sticky — the wizard's
|
||||
footer CTA is already pinned, and two stacked sticky bars overlap on
|
||||
mobile. */}
|
||||
<div className="rounded-2xl border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 p-4 shadow-sm">
|
||||
<div className="flex items-center justify-between text-sm text-zinc-500 dark:text-zinc-400">
|
||||
<span>{pkg?.name} (base estimate)</span>
|
||||
<span>{formatUSD(base)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm text-zinc-500 dark:text-zinc-400 mt-1">
|
||||
<span>Selected add-ons ({selectedIds.length})</span>
|
||||
<span>+{formatUSD(addonsSum)}</span>
|
||||
</div>
|
||||
<div className="h-px bg-zinc-200 dark:bg-white/10 my-2.5" />
|
||||
<div className="flex items-center justify-between font-bold text-zinc-900 dark:text-white">
|
||||
<span>Estimated total</span>
|
||||
<span>~{formatUSD(total)}</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-zinc-400 mt-1.5">
|
||||
Preliminary — verified at inspection. Add-ons can be revised with your rep.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddonsStep;
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* AddressStep — Screen 2 "Property validation" (spec §4).
|
||||
* Geocodes the address (module-local Nominatim geocoder) and shows a Leaflet map
|
||||
* preview, populating property_profiles (spec §12). Hail history is pulled in the
|
||||
* next step from the existing /api/storm-history endpoint.
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import { MapPin, Loader2, CheckCircle2, Search } from 'lucide-react';
|
||||
import { useEstimateWizard } from '../../EstimateWizardContext';
|
||||
import { geocodeAddress } from '../../api/geocode';
|
||||
import PropertyMap from '../PropertyMap';
|
||||
|
||||
const AddressStep = () => {
|
||||
const { inputs, setInput, session, patchSession, logEvent } = useEstimateWizard();
|
||||
const [locating, setLocating] = useState(false);
|
||||
const property = session.property;
|
||||
|
||||
const handleLocate = async () => {
|
||||
setLocating(true);
|
||||
try {
|
||||
const prop = await geocodeAddress(inputs.address);
|
||||
patchSession({ property: prop, hailSnapshot: null }); // re-locating invalidates a stale snapshot
|
||||
logEvent('address_geocoded', { confidence: prop.confidence, fallback: prop.fallback });
|
||||
} finally {
|
||||
setLocating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<label className="block">
|
||||
<span className="text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-1.5 flex items-center gap-1.5">
|
||||
<MapPin size={15} className="text-amber-500" /> Confirm your property address
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
className="flex-1 rounded-xl border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 px-3.5 py-2.5 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-amber-500"
|
||||
placeholder="Street, city, state, ZIP"
|
||||
value={inputs.address}
|
||||
onChange={(e) => { setInput('address', e.target.value); if (property) patchSession({ property: null, hailSnapshot: null }); }}
|
||||
autoComplete="street-address"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLocate}
|
||||
disabled={locating || !inputs.address?.trim()}
|
||||
className="rounded-xl bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 px-4 py-2.5 text-sm font-semibold flex items-center gap-1.5 disabled:opacity-40"
|
||||
>
|
||||
{locating ? <Loader2 size={16} className="animate-spin" /> : <Search size={16} />}
|
||||
Locate
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{property ? (
|
||||
<>
|
||||
<PropertyMap lat={property.lat} lon={property.lon} radiusMiles={20} />
|
||||
<div className="flex items-start gap-2 text-sm">
|
||||
<CheckCircle2 size={16} className="text-emerald-500 mt-0.5 shrink-0" />
|
||||
<span className="text-zinc-600 dark:text-zinc-300">
|
||||
{property.fallback
|
||||
? 'We placed an approximate pin for your area. Your rep will confirm the exact location.'
|
||||
: <>Located: <span className="font-medium">{property.displayName}</span></>}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-dashed border-zinc-300 dark:border-white/10 bg-zinc-50 dark:bg-white/5 p-6 text-center">
|
||||
<MapPin size={26} className="text-zinc-400 mx-auto mb-2" />
|
||||
<p className="text-sm text-zinc-500 dark:text-zinc-400">
|
||||
Tap <span className="font-medium">Locate</span> to confirm your home on the map and pull local storm history.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-zinc-400">
|
||||
Final roof measurements are validated during your inspection — nothing here is binding.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddressStep;
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* HailRiskStep — Screen 6 "Hail risk" (spec §6).
|
||||
* Runs geocode → /api/storm-history → Poisson model, freezes the snapshot into
|
||||
* the session (spec §12), and renders the historical-only risk card + map.
|
||||
*/
|
||||
import React, { useEffect } from 'react';
|
||||
import { Loader2, AlertCircle } from 'lucide-react';
|
||||
import { useEstimateWizard } from '../../EstimateWizardContext';
|
||||
import { useWizardHailRisk } from '../../hooks/useWizardHailRisk';
|
||||
import PropertyMap from '../PropertyMap';
|
||||
import HailRiskSnapshot from '../HailRiskSnapshot';
|
||||
|
||||
const HailRiskStep = () => {
|
||||
const { inputs, session, patchSession, tenantConfig, logEvent } = useEstimateWizard();
|
||||
|
||||
// Reuse a previously frozen snapshot if the user navigates back/forward.
|
||||
const alreadyHave = !!session.hailSnapshot;
|
||||
|
||||
const { loading, error, property, snapshot } = useWizardHailRisk({
|
||||
address: inputs.address,
|
||||
property: session.property,
|
||||
enabled: !alreadyHave,
|
||||
stormSettings: tenantConfig.stormSettings,
|
||||
});
|
||||
|
||||
// Freeze property + snapshot into the session once resolved.
|
||||
useEffect(() => {
|
||||
if (alreadyHave) return;
|
||||
if (snapshot && property) {
|
||||
patchSession({ property, hailSnapshot: snapshot });
|
||||
logEvent('hail_snapshot_generated', {
|
||||
exposureTier: snapshot.exposureTier,
|
||||
p10Base: snapshot.probability10yr.base,
|
||||
confidence: snapshot.confidence,
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [snapshot, property, alreadyHave]);
|
||||
|
||||
const shownProperty = session.property || property;
|
||||
const shownSnapshot = session.hailSnapshot || snapshot;
|
||||
|
||||
if (loading && !shownSnapshot) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Loader2 size={28} className="text-amber-500 animate-spin mb-3" />
|
||||
<p className="text-sm text-zinc-500 dark:text-zinc-400">
|
||||
Checking historical storm reports for your area…
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && !shownSnapshot) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-red-200 dark:border-red-500/20 bg-red-50 dark:bg-red-500/10 p-4 flex items-start gap-3">
|
||||
<AlertCircle size={20} className="text-red-500 shrink-0" />
|
||||
<div>
|
||||
<p className="font-semibold text-red-700 dark:text-red-300">Storm history unavailable</p>
|
||||
<p className="text-sm text-red-600 dark:text-red-400 mt-0.5">
|
||||
We couldn’t load storm data right now — your rep will review local hail history during your visit.
|
||||
You can still continue.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{shownProperty && (
|
||||
<PropertyMap
|
||||
lat={shownProperty.lat}
|
||||
lon={shownProperty.lon}
|
||||
radiusMiles={shownSnapshot?.radiusMiles ?? 20}
|
||||
/>
|
||||
)}
|
||||
<HailRiskSnapshot snapshot={shownSnapshot} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HailRiskStep;
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* OptionsStep — Screen 7 "Build options" (spec §7).
|
||||
* Shows all three Good/Better/Best options; customer selects one. The AI highlight
|
||||
* (Phase 4) will badge a recommended option without hiding the others.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Info } from 'lucide-react';
|
||||
import { useEstimateWizard } from '../../EstimateWizardContext';
|
||||
import GoodBetterBestCards from '../GoodBetterBestCards';
|
||||
|
||||
const OptionsStep = () => {
|
||||
const { session, patchSession, tenantConfig, logEvent } = useEstimateWizard();
|
||||
|
||||
const handleSelect = (type) => {
|
||||
patchSession({ selectedPackage: type });
|
||||
logEvent('package_selected', { package: type });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<GoodBetterBestCards
|
||||
packages={tenantConfig.packages}
|
||||
pricing={tenantConfig.pricing}
|
||||
selectedType={session.selectedPackage}
|
||||
highlightedType={session.recommendation?.selectedPackage}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
<div className="rounded-xl bg-zinc-50 dark:bg-white/5 p-3.5 flex items-start gap-2 text-xs text-zinc-500 dark:text-zinc-400">
|
||||
<Info size={14} className="mt-0.5 shrink-0" />
|
||||
These are preliminary ranges based on an assumed roof size. Final pricing requires a roof
|
||||
measurement and inspection — your rep will confirm everything. This is not a binding quote.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OptionsStep;
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* PlaceholderStep — temporary stand-in for steps delivered in later phases.
|
||||
* Keeps the full §4 journey navigable end-to-end during Phase 1 so the flow,
|
||||
* autosave, and progress can be demoed. Each phase replaces its entry in the
|
||||
* WizardShell step registry with the real component.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Hammer } from 'lucide-react';
|
||||
|
||||
const PHASE_BY_STEP = {
|
||||
hailRisk: 'Phase 2 — Storm/Hail Risk Intelligence (§6)',
|
||||
options: 'Phase 3 — Good / Better / Best options (§7)',
|
||||
addons: 'Phase 3 — Add-ons & upgrades (§8)',
|
||||
recommendation: 'Phase 4 — AI recommendation + 10-year value (§9, §10)',
|
||||
report: 'Phase 5 — Customer report, CRM handoff (§11)',
|
||||
};
|
||||
|
||||
const PlaceholderStep = ({ stepId }) => (
|
||||
<div className="rounded-2xl border border-dashed border-zinc-300 dark:border-white/10 bg-zinc-50 dark:bg-white/5 p-8 flex flex-col items-center justify-center text-center">
|
||||
<Hammer size={30} className="text-amber-500 mb-3" />
|
||||
<p className="font-semibold text-zinc-700 dark:text-zinc-200">Coming next</p>
|
||||
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
|
||||
{PHASE_BY_STEP[stepId] || 'This screen is delivered in a later phase.'}
|
||||
</p>
|
||||
<p className="text-xs text-zinc-400 mt-3">
|
||||
The journey, autosave, and progress are fully wired — Continue to walk the full flow.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default PlaceholderStep;
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* PremiumStep — annual insurance premium (spec §5).
|
||||
* Optional: amount / "I don't know" / skip. Enables the 10-year savings scenario
|
||||
* (Phase 4). Never required — skipping must not block the wizard (spec §18).
|
||||
*/
|
||||
import React from 'react';
|
||||
import { DollarSign } from 'lucide-react';
|
||||
import { useEstimateWizard } from '../../EstimateWizardContext';
|
||||
|
||||
const PremiumStep = () => {
|
||||
const { inputs, setInput } = useEstimateWizard();
|
||||
const unknown = inputs.premiumUnknown;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className={`transition-opacity ${unknown ? 'opacity-40 pointer-events-none' : ''}`}>
|
||||
<span className="text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-1.5 flex items-center gap-1.5">
|
||||
<DollarSign size={15} className="text-amber-500" /> Approximate annual premium
|
||||
</span>
|
||||
<div className="relative">
|
||||
<span className="absolute left-3.5 top-1/2 -translate-y-1/2 text-zinc-400">$</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
inputMode="numeric"
|
||||
className="w-full rounded-xl border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 pl-7 pr-3.5 py-2.5 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-amber-500"
|
||||
placeholder="e.g. 4,000"
|
||||
value={inputs.premiumAmount ?? ''}
|
||||
onChange={(e) => setInput('premiumAmount', e.target.value === '' ? null : Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const nextUnknown = !unknown;
|
||||
setInput('premiumUnknown', nextUnknown);
|
||||
if (nextUnknown) setInput('premiumAmount', null);
|
||||
}}
|
||||
className={`w-full rounded-xl border px-3 py-2.5 text-sm font-medium transition-all
|
||||
${unknown
|
||||
? 'border-amber-500 bg-amber-50 dark:bg-amber-500/10 text-amber-700 dark:text-amber-300'
|
||||
: 'border-zinc-200 dark:border-white/10 text-zinc-600 dark:text-zinc-300 hover:border-amber-400'}`}
|
||||
>
|
||||
I don’t know my premium
|
||||
</button>
|
||||
|
||||
<p className="text-xs text-zinc-400">
|
||||
This is only used to illustrate a potential savings scenario. Any savings must be confirmed with your
|
||||
insurance carrier — we never guarantee a discount.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PremiumStep;
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* RecommendationStep — Screen 8 "AI recommendation" (spec §9, §10).
|
||||
*
|
||||
* Flow:
|
||||
* 1. Deterministic scoring engine picks the highlight + reasons (logged).
|
||||
* 2. 10-year value model computes the savings/lifecycle comparison.
|
||||
* 3. AI turns the reasons into plain-language copy (guardrailed); falls back to
|
||||
* deterministic text if AI is unavailable or trips a banned-claim check.
|
||||
* The recommendation record is frozen into the session (spec §12 recommendations).
|
||||
*/
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Sparkles, Loader2, Check, ShieldCheck, Info } from 'lucide-react';
|
||||
import { useEstimateWizard } from '../../EstimateWizardContext';
|
||||
import { score } from '../../engine/scoringEngine';
|
||||
import { computeTenYearValue } from '../../engine/valueModel';
|
||||
import { generateExplanation } from '../../ai/explain';
|
||||
import { formatUSD } from '../../engine/pricing';
|
||||
import GoodBetterBestCards from '../GoodBetterBestCards';
|
||||
import TenYearValueChart from '../TenYearValueChart';
|
||||
|
||||
const PKG_LABEL = { GOOD: 'Good', BETTER: 'Better', BEST: 'Best' };
|
||||
|
||||
/**
|
||||
* Deterministic, manufacturer-neutral description for a package the customer
|
||||
* clicked that is NOT the recommended highlight. Built purely from the tenant
|
||||
* config package fields (+ the value model for the savings line) — no AI call,
|
||||
* so it's instant and always on-message. Mirrors the guardrailed tone of the
|
||||
* recommendation copy ("illustrative", "confirm with your carrier").
|
||||
*/
|
||||
function describePackage(pkg, valueModel) {
|
||||
const label = PKG_LABEL[pkg.type];
|
||||
const row = valueModel?.rows?.find((r) => r.type === pkg.type);
|
||||
|
||||
const paragraph =
|
||||
`The ${label} option — ${pkg.name} — is our "${(pkg.position || '').toLowerCase()}" choice` +
|
||||
`${pkg.fit ? `. It tends to fit best when: ${pkg.fit}` : '.'}`;
|
||||
|
||||
const bullets = [];
|
||||
if (pkg.warrantyLabel) bullets.push(pkg.warrantyLabel);
|
||||
if (pkg.serviceLifeYears) bullets.push(`~${pkg.serviceLifeYears}-year expected service life`);
|
||||
if (pkg.hailResistance) bullets.push(`${pkg.hailResistance} hail resistance`);
|
||||
if (valueModel?.premiumKnown && row?.savings) {
|
||||
bullets.push(
|
||||
`Illustrative 10-yr insurance savings ${formatUSD(row.savings.low)}–${formatUSD(row.savings.high)} — confirm with your carrier`
|
||||
);
|
||||
} else if (pkg.insuranceDiscountPct > 0) {
|
||||
bullets.push('May qualify for an insurance discount — confirm with your carrier');
|
||||
}
|
||||
|
||||
return { paragraph, bullets };
|
||||
}
|
||||
|
||||
const RecommendationStep = () => {
|
||||
const { inputs, session, patchSession, tenantConfig, logEvent } = useEstimateWizard();
|
||||
|
||||
// Deterministic — recomputed from current answers, stable across renders.
|
||||
const recommendation = useMemo(
|
||||
() => score({ inputs, hailSnapshot: session.hailSnapshot, tenantConfig }),
|
||||
[inputs, session.hailSnapshot, tenantConfig]
|
||||
);
|
||||
const value = useMemo(
|
||||
() => computeTenYearValue({ inputs, tenantConfig }),
|
||||
[inputs, tenantConfig]
|
||||
);
|
||||
|
||||
const [explanation, setExplanation] = useState(session.recommendation?.explanation || null);
|
||||
const [loadingAI, setLoadingAI] = useState(true);
|
||||
|
||||
// Freeze deterministic record + log it (reproducible audit, spec §18).
|
||||
useEffect(() => {
|
||||
patchSession({ recommendation: { ...recommendation, value } });
|
||||
logEvent('recommendation_scored', {
|
||||
modelVersion: recommendation.modelVersion,
|
||||
selectedPackage: recommendation.selectedPackage,
|
||||
scores: Object.fromEntries(Object.entries(recommendation.scores).map(([k, v]) => [k, v.total])),
|
||||
uncertaintyFlags: recommendation.uncertaintyFlags,
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [recommendation, value]);
|
||||
|
||||
// AI explanation (async, guardrailed).
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoadingAI(true);
|
||||
generateExplanation({ inputs, hailSnapshot: session.hailSnapshot, recommendation, valueModel: value, tenantConfig })
|
||||
.then((res) => {
|
||||
if (cancelled) return;
|
||||
setExplanation(res);
|
||||
patchSession({ recommendation: { ...recommendation, value, explanation: res } });
|
||||
logEvent('ai_explanation_generated', { aiUsed: res.aiUsed, guardrailOk: res.guardrail?.ok });
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [recommendation, value]);
|
||||
|
||||
// Lock the highlighted option in as the customer's selection if they hadn't chosen.
|
||||
useEffect(() => {
|
||||
if (!session.selectedPackage) patchSession({ selectedPackage: recommendation.selectedPackage });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const highlight = recommendation.selectedPackage;
|
||||
const selectedType = session.selectedPackage || highlight;
|
||||
const isRecommendedSelected = selectedType === highlight;
|
||||
const selectedPkg = tenantConfig.packages.find((p) => p.type === selectedType);
|
||||
const selectedDesc = !isRecommendedSelected && selectedPkg ? describePackage(selectedPkg, value) : null;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<GoodBetterBestCards
|
||||
packages={tenantConfig.packages}
|
||||
pricing={tenantConfig.pricing}
|
||||
selectedType={selectedType}
|
||||
highlightedType={highlight}
|
||||
onSelect={(t) => { patchSession({ selectedPackage: t }); logEvent('package_selected', { package: t, after: 'recommendation' }); }}
|
||||
/>
|
||||
|
||||
{/* Explanation — describes the RECOMMENDED package (AI-assisted) while it's
|
||||
selected, and switches to a deterministic per-package description when the
|
||||
customer clicks one of the other options. */}
|
||||
{isRecommendedSelected ? (
|
||||
<div className="rounded-2xl border border-amber-200 dark:border-amber-500/20 bg-amber-50/60 dark:bg-amber-500/5 p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Sparkles size={16} className="text-amber-500" />
|
||||
<span className="font-semibold text-zinc-900 dark:text-white">
|
||||
Why we highlighted {PKG_LABEL[highlight]}
|
||||
</span>
|
||||
{explanation && (
|
||||
<span className="ml-auto text-[10px] font-semibold uppercase tracking-wide text-emerald-600 dark:text-emerald-400 inline-flex items-center gap-1">
|
||||
<ShieldCheck size={11} /> {explanation.aiUsed ? 'AI-assisted' : 'Reviewed'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loadingAI && !explanation ? (
|
||||
<div className="flex items-center gap-2 text-sm text-zinc-500 dark:text-zinc-400 py-2">
|
||||
<Loader2 size={15} className="animate-spin" /> Preparing your explanation…
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-zinc-700 dark:text-zinc-200 leading-relaxed">
|
||||
{explanation?.customerExplanation}
|
||||
</p>
|
||||
<ul className="mt-3 space-y-1.5">
|
||||
{recommendation.reasons.map((r) => (
|
||||
<li key={r} className="flex items-start gap-2 text-sm text-zinc-600 dark:text-zinc-300">
|
||||
<Check size={14} className="text-amber-500 mt-0.5 shrink-0" /> {r}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Info size={16} className="text-blue-500" />
|
||||
<span className="font-semibold text-zinc-900 dark:text-white">
|
||||
About the {PKG_LABEL[selectedType]} option
|
||||
</span>
|
||||
<span className="ml-auto text-[10px] font-semibold uppercase tracking-wide text-amber-600 dark:text-amber-400">
|
||||
{PKG_LABEL[highlight]} recommended
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-zinc-700 dark:text-zinc-200 leading-relaxed">
|
||||
{selectedDesc.paragraph}
|
||||
</p>
|
||||
<ul className="mt-3 space-y-1.5">
|
||||
{selectedDesc.bullets.map((b) => (
|
||||
<li key={b} className="flex items-start gap-2 text-sm text-zinc-600 dark:text-zinc-300">
|
||||
<Check size={14} className="text-blue-500 mt-0.5 shrink-0" /> {b}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mt-3 text-xs text-zinc-400">
|
||||
We still suggest {PKG_LABEL[highlight]} based on your answers — but every option stays available.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TenYearValueChart valueModel={value} />
|
||||
|
||||
<p className="text-xs text-zinc-400 leading-relaxed">
|
||||
This recommendation considers your stated budget and goals — all three options stay available.
|
||||
Insurance savings are illustrative and must be confirmed with your carrier.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecommendationStep;
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* ReportStep — Screen 9 "Report + next step" (spec §11, §13).
|
||||
* Shows the report summary, lets the customer email/print/download/share, books
|
||||
* the inspection, and fires the CRM + nurture handoff (once) with the immutable
|
||||
* compliance record (spec §18 G1).
|
||||
*/
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Download, Printer, Mail, Share2, FileText, Loader2 } from 'lucide-react';
|
||||
import { useEstimateWizard } from '../../EstimateWizardContext';
|
||||
import { useMockStore } from '../../../../data/mockStore';
|
||||
import { buildReportModel, downloadReportPdf, getReportBlobUrl } from '../../utils/wizardReportExport';
|
||||
import { submitToCrm, buildLeadFromSession } from '../../api/crmHandoff';
|
||||
import { formatUSD } from '../../engine/pricing';
|
||||
|
||||
const PKG_LABEL = { GOOD: 'Good', BETTER: 'Better', BEST: 'Best' };
|
||||
|
||||
const ActionButton = ({ icon: Icon, label, onClick, busy, primary }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={busy}
|
||||
className={`flex items-center justify-center gap-2 rounded-xl px-4 py-2.5 text-sm font-semibold transition-all disabled:opacity-50
|
||||
${primary
|
||||
? 'bg-gradient-to-r from-amber-400 to-orange-500 text-white shadow-lg shadow-amber-500/25'
|
||||
: 'border border-zinc-200 dark:border-white/10 text-zinc-700 dark:text-zinc-200 hover:border-amber-400'}`}
|
||||
>
|
||||
{busy ? <Loader2 size={16} className="animate-spin" /> : Icon && <Icon size={16} />} {label}
|
||||
</button>
|
||||
);
|
||||
|
||||
const ReportStep = () => {
|
||||
const { session, tenantConfig, patchSession, logEvent } = useEstimateWizard();
|
||||
const { addWizardLead } = useMockStore();
|
||||
const model = buildReportModel(session, tenantConfig);
|
||||
const [busy, setBusy] = useState(null);
|
||||
const firedRef = useRef(false);
|
||||
|
||||
// Fire CRM + nurture handoff once (spec §13 / §17 E2). The ref guard prevents a
|
||||
// duplicate lead under React StrictMode's double-invoked mount effect.
|
||||
useEffect(() => {
|
||||
if (firedRef.current || session.report) return;
|
||||
firedRef.current = true;
|
||||
|
||||
// Create a real lead in the global CRM mock store so the estimate shows up
|
||||
// in the pipeline (addLead injects id / createdAt / status: 'New').
|
||||
const lead = addWizardLead(buildLeadFromSession(session, tenantConfig, {
|
||||
reportId: model.reportId,
|
||||
estimatedTotal: model.totals.total,
|
||||
}));
|
||||
|
||||
const reportMeta = {
|
||||
reportId: model.reportId, version: 1, generatedAt: model.generatedAt,
|
||||
deliveryStatus: 'created', crmLeadId: lead?.id || null, estimatedTotal: model.totals.total,
|
||||
};
|
||||
patchSession({ report: reportMeta });
|
||||
submitToCrm(session, tenantConfig, reportMeta);
|
||||
logEvent('report_generated', { reportId: model.reportId });
|
||||
logEvent('crm_handoff', { trigger: 'wizard_completed', crmLeadId: lead?.id || null });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleDownload = async () => {
|
||||
setBusy('download');
|
||||
try { await downloadReportPdf(session, tenantConfig); logEvent('report_downloaded', { reportId: model.reportId }); }
|
||||
finally { setBusy(null); }
|
||||
};
|
||||
|
||||
const handlePrint = async () => {
|
||||
setBusy('print');
|
||||
try {
|
||||
const { url } = await getReportBlobUrl(session, tenantConfig);
|
||||
const w = window.open(url, '_blank');
|
||||
if (w) w.addEventListener('load', () => w.print());
|
||||
patchSession({ report: { ...session.report, printedAt: new Date().toISOString() } });
|
||||
logEvent('report_printed', { reportId: model.reportId });
|
||||
} finally { setBusy(null); }
|
||||
};
|
||||
|
||||
const handleEmail = () => {
|
||||
const subject = encodeURIComponent(`Your Roof Options Report (${model.reportId})`);
|
||||
const body = encodeURIComponent(
|
||||
`Hi ${model.contact.name || ''},\n\nHere is a summary of your roof options report.\n` +
|
||||
`Recommended: ${PKG_LABEL[model.recommended]}\nEstimated total: ~${formatUSD(model.totals.total)}\n\n` +
|
||||
`${tenantConfig.branding.company} · ${tenantConfig.branding.phone}\n\n` +
|
||||
`Note: ${tenantConfig.disclaimer}`
|
||||
);
|
||||
window.location.href = `mailto:${model.contact.email || ''}?subject=${subject}&body=${body}`;
|
||||
patchSession({ report: { ...session.report, emailedAt: new Date().toISOString(), deliveryStatus: 'emailed' } });
|
||||
logEvent('report_emailed', { reportId: model.reportId });
|
||||
};
|
||||
|
||||
const handleShare = async () => {
|
||||
const summary = `My roof options report (${PKG_LABEL[model.recommended]} recommended) from ${tenantConfig.branding.company}.`;
|
||||
try {
|
||||
if (navigator.share) await navigator.share({ title: 'Roof Options Report', text: summary });
|
||||
else { await navigator.clipboard?.writeText(summary); }
|
||||
logEvent('report_shared', { reportId: model.reportId });
|
||||
} catch { /* user cancelled */ }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Report summary card */}
|
||||
<div className="rounded-2xl border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 p-5">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<FileText size={18} className="text-amber-500" />
|
||||
<span className="font-bold text-zinc-900 dark:text-white">Your Roof Options Report</span>
|
||||
<span className="ml-auto text-[11px] text-zinc-400">{model.reportId}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div><div className="text-zinc-400 text-xs">Prepared for</div><div className="font-medium text-zinc-800 dark:text-zinc-100">{model.contact.name || '—'}</div></div>
|
||||
<div><div className="text-zinc-400 text-xs">Recommended</div><div className="font-medium text-amber-600 dark:text-amber-400">{PKG_LABEL[model.recommended]}</div></div>
|
||||
<div><div className="text-zinc-400 text-xs">Hail exposure</div><div className="font-medium text-zinc-800 dark:text-zinc-100 capitalize">{model.hail?.exposureTier || '—'}</div></div>
|
||||
<div><div className="text-zinc-400 text-xs">Estimated total</div><div className="font-medium text-zinc-800 dark:text-zinc-100">~{formatUSD(model.totals.total)}</div></div>
|
||||
</div>
|
||||
<p className="text-[11px] text-zinc-400 mt-3 leading-relaxed border-t border-zinc-100 dark:border-white/5 pt-2.5">
|
||||
{tenantConfig.disclaimer}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Delivery actions */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2.5">
|
||||
<ActionButton icon={Download} label="Download" onClick={handleDownload} busy={busy === 'download'} />
|
||||
<ActionButton icon={Printer} label="Print" onClick={handlePrint} busy={busy === 'print'} />
|
||||
<ActionButton icon={Mail} label="Email" onClick={handleEmail} />
|
||||
<ActionButton icon={Share2} label="Share" onClick={handleShare} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReportStep;
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* StartStep — Screen 1 "Simple Start" (spec §4 / §5).
|
||||
* Captures name, property address, mobile, email, contact method + consent.
|
||||
* Consent is required before proceeding (spec §15 Privacy / §3 compliance).
|
||||
*/
|
||||
import React from 'react';
|
||||
import { User, MapPin, Phone, Mail, Wand2 } from 'lucide-react';
|
||||
import { useEstimateWizard } from '../../EstimateWizardContext';
|
||||
|
||||
const Field = ({ icon: Icon, label, children }) => (
|
||||
<label className="block">
|
||||
<span className="text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-1.5 flex items-center gap-1.5">
|
||||
{Icon && <Icon size={15} className="text-amber-500" />} {label}
|
||||
</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
|
||||
const inputCls =
|
||||
'w-full rounded-xl border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 px-3.5 py-2.5 ' +
|
||||
'text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-amber-500';
|
||||
|
||||
const StartStep = () => {
|
||||
const { inputs, setInput, prefillDemo } = useEstimateWizard();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={prefillDemo}
|
||||
className="w-full flex items-center justify-center gap-2 rounded-xl border border-dashed border-amber-300 dark:border-amber-500/30 bg-amber-50 dark:bg-amber-500/10 px-3 py-2.5 text-sm font-medium text-amber-700 dark:text-amber-300 hover:border-amber-400"
|
||||
>
|
||||
<Wand2 size={15} /> Try a demo address (auto-fills every answer)
|
||||
</button>
|
||||
|
||||
<Field icon={User} label="Name">
|
||||
<input
|
||||
className={inputCls}
|
||||
placeholder="First and last name"
|
||||
value={inputs.name}
|
||||
onChange={(e) => setInput('name', e.target.value)}
|
||||
autoComplete="name"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field icon={MapPin} label="Property address">
|
||||
<input
|
||||
className={inputCls}
|
||||
placeholder="Street, city, state, ZIP"
|
||||
value={inputs.address}
|
||||
onChange={(e) => setInput('address', e.target.value)}
|
||||
autoComplete="street-address"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Field icon={Phone} label="Mobile number">
|
||||
<input
|
||||
className={inputCls}
|
||||
placeholder="(555) 123-4567"
|
||||
value={inputs.phone}
|
||||
onChange={(e) => setInput('phone', e.target.value)}
|
||||
autoComplete="tel"
|
||||
inputMode="tel"
|
||||
/>
|
||||
</Field>
|
||||
<Field icon={Mail} label="Email">
|
||||
<input
|
||||
className={inputCls}
|
||||
placeholder="you@email.com"
|
||||
value={inputs.email}
|
||||
onChange={(e) => setInput('email', e.target.value)}
|
||||
autoComplete="email"
|
||||
inputMode="email"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-1.5 block">Best contact method?</span>
|
||||
<div className="flex gap-2">
|
||||
{['text', 'email', 'phone'].map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
onClick={() => setInput('contactMethod', m)}
|
||||
className={`flex-1 rounded-xl border px-3 py-2 text-sm font-medium capitalize transition-all
|
||||
${inputs.contactMethod === m
|
||||
? 'border-amber-500 bg-amber-50 dark:bg-amber-500/10 text-amber-700 dark:text-amber-300'
|
||||
: 'border-zinc-200 dark:border-white/10 text-zinc-600 dark:text-zinc-300 hover:border-amber-400'}`}
|
||||
>
|
||||
{m}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-start gap-2.5 rounded-xl bg-zinc-50 dark:bg-white/5 p-3.5 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5 h-4 w-4 accent-amber-500"
|
||||
checked={inputs.consent}
|
||||
onChange={(e) => setInput('consent', e.target.checked)}
|
||||
/>
|
||||
<span className="text-xs text-zinc-600 dark:text-zinc-400 leading-relaxed">
|
||||
I agree to be contacted about my roof estimate and understand my address and any insurance
|
||||
details I provide are used to generate this educational report. This is not a binding quote.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StartStep;
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* questions.js — Wizard question architecture (spec §5).
|
||||
*
|
||||
* Every question must improve pricing accuracy, recommendation quality, or sales
|
||||
* follow-up (spec §5: "Do not ask curiosity questions"). Each entry maps to a key
|
||||
* stored on estimateInputs and is consumed by the scoring engine (Phase 4).
|
||||
*
|
||||
* The ordered STEP
|
||||
* _FLOW (below) defines the one-question-per-screen journey
|
||||
* (spec §4). Some "screens" are composite (Start collects contact fields) — these
|
||||
* use a dedicated component instead of the generic question renderer.
|
||||
*/
|
||||
|
||||
// Reusable answer choice sets
|
||||
export const ISSUE_CHOICES = [
|
||||
{ value: 'aging', label: 'Aging roof', desc: 'General wear, nearing end of life', icon: 'Clock', triggers: ['old_roof'] },
|
||||
{ value: 'leak', label: 'Leak or active issue', desc: 'Water intrusion or visible damage', icon: 'Droplets', triggers: ['leak'] },
|
||||
{ value: 'storm', label: 'Storm concern', desc: 'Possible hail or wind damage', icon: 'CloudLightning', triggers: [] },
|
||||
];
|
||||
|
||||
export const OWNERSHIP_CHOICES = [
|
||||
{ value: '1-5', label: '1–5 years', desc: 'May sell soon', score: 1 },
|
||||
{ value: '6-15', label: '6–15 years', desc: 'Medium-term', score: 2 },
|
||||
{ value: '16+', label: '16+ years / forever', desc: 'Long-term home', score: 3 },
|
||||
];
|
||||
|
||||
export const BUDGET_CHOICES = [
|
||||
{ value: 'lowest', label: 'Lowest upfront', desc: 'Minimize initial cost' },
|
||||
{ value: 'balanced', label: 'Balanced', desc: 'Value for the money' },
|
||||
{ value: 'longterm', label: 'Long-term protection', desc: 'Best lifecycle value' },
|
||||
];
|
||||
|
||||
export const HOA_CHOICES = [
|
||||
{ value: 'no', label: 'No' },
|
||||
{ value: 'yes', label: 'Yes' },
|
||||
{ value: 'unsure', label: 'Not sure' },
|
||||
];
|
||||
|
||||
export const ATTIC_CHOICES = [
|
||||
{ value: 'attic_hot', label: 'Hot attic', triggers: ['attic_hot'] },
|
||||
{ value: 'energy_bills', label: 'High energy bills', triggers: ['energy_bills'] },
|
||||
{ value: 'none', label: 'Not sure', triggers: [] },
|
||||
];
|
||||
|
||||
export const PESTS_CHOICES = [
|
||||
{ value: 'yes', label: 'Yes', triggers: ['pests'] },
|
||||
{ value: 'no', label: 'No', triggers: [] },
|
||||
{ value: 'unsure', label: 'Not sure', triggers: [] },
|
||||
];
|
||||
|
||||
export const FINANCING_CHOICES = [
|
||||
{ value: 'yes', label: 'Yes' },
|
||||
{ value: 'maybe', label: 'Maybe' },
|
||||
{ value: 'no', label: 'No' },
|
||||
];
|
||||
|
||||
/**
|
||||
* STEP_FLOW — ordered screens. `type` selects the renderer in WizardShell.
|
||||
* - 'component' → custom step component (key matches steps/ registry)
|
||||
* - 'cards' → single-select card group bound to `inputKey`
|
||||
* - 'slider' → numeric slider bound to `inputKey`
|
||||
* `required` gates the Continue button; `skippable` allows an explicit Skip.
|
||||
*/
|
||||
export const STEP_FLOW = [
|
||||
{ id: 'start', type: 'component', title: 'Build your roofing estimate in 3 minutes',
|
||||
subtitle: 'No pressure. Your report will show Good, Better, and Best options based on your home and goals.' },
|
||||
|
||||
{ id: 'address', type: 'component', title: 'What property is this for?',
|
||||
subtitle: "We'll use this to pull local storm history. We validate measurements later during inspection." },
|
||||
|
||||
{ id: 'roofAge', type: 'slider', inputKey: 'roofAgeYears', title: 'How old is your roof?',
|
||||
subtitle: 'Slide to estimate roof age. We will validate later during inspection.',
|
||||
min: 1, max: 20, step: 1, unit: 'yr', maxLabel: '20+ yrs', required: true },
|
||||
|
||||
{ id: 'issue', type: 'cards', inputKey: 'issueType', title: 'What is happening right now?',
|
||||
subtitle: 'This helps us tailor your options.', choices: ISSUE_CHOICES, required: true },
|
||||
|
||||
{ id: 'ownership', type: 'cards', inputKey: 'ownershipHorizon', title: 'How long do you plan to live here?',
|
||||
subtitle: 'This decides whether lowest upfront or long-term value matters more.', choices: OWNERSHIP_CHOICES, required: true },
|
||||
|
||||
{ id: 'premium', type: 'component', title: 'Annual insurance premium?',
|
||||
subtitle: 'Optional — it lets us estimate potential 10-year savings scenarios.' },
|
||||
|
||||
{ id: 'hailRisk', type: 'component', title: 'Your area’s historical hail exposure',
|
||||
subtitle: 'A historical risk estimate for your area — not a forecast.' },
|
||||
|
||||
{ id: 'budget', type: 'cards', inputKey: 'budgetPreference', title: 'What matters most for budget?',
|
||||
subtitle: 'So we recommend the right option for you — not the most expensive one.', choices: BUDGET_CHOICES, required: true },
|
||||
|
||||
{ id: 'hoa', type: 'cards', inputKey: 'hoaConstraints', title: 'HOA or design constraints?',
|
||||
subtitle: 'Some neighborhoods require approval for certain materials.', choices: HOA_CHOICES, skippable: true },
|
||||
|
||||
{ id: 'attic', type: 'cards', inputKey: 'atticConcern', title: 'Attic comfort or ventilation issues?',
|
||||
subtitle: 'Helps us suggest ventilation upgrades if useful.', choices: ATTIC_CHOICES, skippable: true },
|
||||
|
||||
{ id: 'pests', type: 'cards', inputKey: 'pestConcern', title: 'Concerned about rodents or pests?',
|
||||
subtitle: 'Helps us suggest critter-guard protection if useful.', choices: PESTS_CHOICES, skippable: true },
|
||||
|
||||
{ id: 'financing', type: 'cards', inputKey: 'financingInterest', title: 'Interested in financing?',
|
||||
subtitle: 'We can prepare financing options for your appointment.', choices: FINANCING_CHOICES, skippable: true },
|
||||
|
||||
{ id: 'options', type: 'component', title: 'Your Good / Better / Best options',
|
||||
subtitle: 'All three options, side by side. We highlight a best fit — you stay in control.' },
|
||||
|
||||
{ id: 'addons', type: 'component', title: 'Optional upgrades',
|
||||
subtitle: 'Add or remove anything. Your rep can revise after inspection.' },
|
||||
|
||||
{ id: 'recommendation', type: 'component', title: 'Our recommendation',
|
||||
subtitle: 'Here’s the best fit for your home and goals, and exactly why.' },
|
||||
|
||||
{ id: 'report', type: 'component', title: 'Your roof options report',
|
||||
subtitle: 'Email, print, or download — then book your free inspection.' },
|
||||
];
|
||||
|
||||
export const TOTAL_STEPS = STEP_FLOW.length;
|
||||
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* tenantConfig.js — Estimate Wizard tenant-configurable defaults.
|
||||
*
|
||||
* Spec §14 (Admin Controls) requires that pricing, discounts, warranty text,
|
||||
* package scope, service-life, and scoring weights are NOT hard-coded — they are
|
||||
* tenant-controlled. For the demo these defaults live here and can be overridden
|
||||
* by the admin page (Phase 5). Nothing customer-facing should bypass this object.
|
||||
*
|
||||
* Manufacturer-neutral by design (spec §3, §15): packages are generic categories,
|
||||
* internal SKU/vendor names are intentionally absent from customer-facing fields.
|
||||
*/
|
||||
|
||||
// ── Good / Better / Best packages (spec §7) ──────────────────────────────────
|
||||
export const DEFAULT_PACKAGES = [
|
||||
{
|
||||
type: 'GOOD',
|
||||
name: 'Standard Architectural',
|
||||
category: 'Architectural / laminate shingle system',
|
||||
materialClass: 'standard',
|
||||
warrantyLabel: '30-year material warranty category',
|
||||
position: 'Lowest upfront investment',
|
||||
fit: 'Shorter ownership horizon, limited budget, lower hail exposure, or preparing to sell soon.',
|
||||
// price rule: per-square installed price (demo). Real pricing engine lives in admin.
|
||||
pricePerSquare: 420,
|
||||
serviceLifeYears: 28, // tenant-configurable (e.g. 25–30)
|
||||
insuranceDiscountPct: 0, // Good is not impact-rated → no assumed discount
|
||||
hailResistance: 'Standard',
|
||||
scope: ['Architectural laminate shingles', 'Code-required underlayment', 'Standard hip/ridge', 'Standard accessories'],
|
||||
accent: 'zinc',
|
||||
},
|
||||
{
|
||||
type: 'BETTER',
|
||||
name: 'Class 4 Impact-Rated',
|
||||
category: 'Class 4 impact-rated architectural shingle system',
|
||||
materialClass: 'impact-rated',
|
||||
warrantyLabel: 'Enhanced manufacturer warranty category',
|
||||
position: 'Balanced protection and budget',
|
||||
fit: 'Medium-to-long ownership, meaningful hail exposure, wants stronger protection without premium-system cost.',
|
||||
pricePerSquare: 565,
|
||||
serviceLifeYears: 35, // tenant-configurable (e.g. 30–40)
|
||||
insuranceDiscountPct: 15, // illustrative; "ask your carrier to confirm"
|
||||
hailResistance: 'Higher',
|
||||
scope: ['Class 4 impact-rated shingles', 'Upgraded underlayment', 'Standard hip/ridge', 'Improved hail resistance'],
|
||||
accent: 'blue',
|
||||
},
|
||||
{
|
||||
type: 'BEST',
|
||||
name: 'Premium Long-Life System',
|
||||
category: 'Composite / synthetic or stone-coated steel category',
|
||||
materialClass: 'premium',
|
||||
warrantyLabel: 'Premium long-life warranty category',
|
||||
position: 'Highest long-term protection',
|
||||
fit: 'Long ownership, high hail exposure, high premium, wants fewer future roof cycles and strongest curb appeal.',
|
||||
pricePerSquare: 950,
|
||||
serviceLifeYears: 50, // tenant-configurable (e.g. 50+)
|
||||
insuranceDiscountPct: 20, // illustrative; "ask your carrier to confirm"
|
||||
hailResistance: 'Highest',
|
||||
scope: ['Composite/synthetic or stone-coated steel', 'Premium underlayment', 'Upgraded accessories', 'Highest durability positioning'],
|
||||
accent: 'amber',
|
||||
},
|
||||
];
|
||||
|
||||
// ── Add-ons (spec §8) — plain-language, never fear-based ──────────────────────
|
||||
export const DEFAULT_ADDONS = [
|
||||
{
|
||||
id: 'hip-ridge',
|
||||
name: 'High-profile hip & ridge',
|
||||
value: 'Improves the finished look and strengthens the ridge-line appearance.',
|
||||
price: 480,
|
||||
compatibility: ['GOOD', 'BETTER', 'BEST'],
|
||||
recommendedOn: ['BEST'],
|
||||
triggerKeys: ['aesthetics'],
|
||||
},
|
||||
{
|
||||
id: 'solar-vents',
|
||||
name: 'Solar attic vents',
|
||||
value: 'Helps move hot attic air and may improve comfort and roof-system health.',
|
||||
price: 650,
|
||||
compatibility: ['GOOD', 'BETTER', 'BEST'],
|
||||
recommendedOn: [],
|
||||
triggerKeys: ['attic_hot', 'energy_bills'],
|
||||
},
|
||||
{
|
||||
id: 'gutter-guards',
|
||||
name: 'Gutter guards',
|
||||
value: 'Reduces gutter debris buildup and maintenance frequency.',
|
||||
price: 900,
|
||||
compatibility: ['GOOD', 'BETTER', 'BEST'],
|
||||
recommendedOn: [],
|
||||
triggerKeys: ['canopy'],
|
||||
},
|
||||
{
|
||||
id: 'critter-guards',
|
||||
name: 'Critter guards',
|
||||
value: 'Helps block common rodent and small-animal roofline entry points.',
|
||||
price: 350,
|
||||
compatibility: ['GOOD', 'BETTER', 'BEST'],
|
||||
recommendedOn: [],
|
||||
triggerKeys: ['pests'],
|
||||
},
|
||||
{
|
||||
id: 'upgraded-flashing',
|
||||
name: 'Upgraded flashing / penetrations',
|
||||
value: 'Improves protection around common leak points.',
|
||||
price: 520,
|
||||
compatibility: ['GOOD', 'BETTER', 'BEST'],
|
||||
recommendedOn: ['BEST'],
|
||||
triggerKeys: ['leak', 'old_roof', 'complex'],
|
||||
},
|
||||
{
|
||||
id: 'enhanced-underlayment',
|
||||
name: 'Enhanced underlayment / ice-water zones',
|
||||
value: 'Adds protection in vulnerable valleys, eaves, and penetrations.',
|
||||
price: 700,
|
||||
compatibility: ['GOOD', 'BETTER', 'BEST'],
|
||||
recommendedOn: [],
|
||||
triggerKeys: ['complex', 'leak'],
|
||||
},
|
||||
];
|
||||
|
||||
// ── Scoring weights (spec §9) — deterministic, tenant-adjustable ──────────────
|
||||
export const DEFAULT_SCORING_WEIGHTS = {
|
||||
ownershipHorizon: 0.25,
|
||||
hailRisk: 0.25,
|
||||
insuranceSavings: 0.20,
|
||||
budgetPreference: 0.15,
|
||||
roofAge: 0.10,
|
||||
aesthetics: 0.05,
|
||||
};
|
||||
|
||||
// ── Storm-risk settings (spec §6, §14) ────────────────────────────────────────
|
||||
export const DEFAULT_STORM_SETTINGS = {
|
||||
windowYears: 20, // 10 fallback / 30 for smoother trends
|
||||
radiusMiles: 10,
|
||||
minHailSizeInches: 0.75,
|
||||
dedupeWindowHours: 6,
|
||||
};
|
||||
|
||||
// ── Pricing engine knobs (spec §14) ───────────────────────────────────────────
|
||||
export const DEFAULT_PRICING = {
|
||||
wasteFactor: 1.05,
|
||||
minJobPrice: 6500,
|
||||
pitchFactor: 1.0, // multiplied per pitch/stories/complexity in admin
|
||||
taxPlaceholderPct: 0,
|
||||
permitPlaceholder: 0,
|
||||
// No measurement source in the wizard (spec §3) → assume an average roof size
|
||||
// so the customer sees a PRELIMINARY range. Verified during inspection.
|
||||
assumedSquares: 25, // ~2,500 sq ft of roof surface
|
||||
rangeSpread: 0.08, // ±8% band shown to the customer (low/base/high)
|
||||
};
|
||||
|
||||
// ── Insurance discount scenarios (spec §10/§14) — low/base/high ───────────────
|
||||
export const DEFAULT_DISCOUNT_SCENARIOS = {
|
||||
GOOD: { low: 0, base: 0, high: 0 },
|
||||
BETTER: { low: 10, base: 15, high: 18 },
|
||||
BEST: { low: 15, base: 18, high: 22 },
|
||||
};
|
||||
|
||||
// ── Report branding (spec §14) ────────────────────────────────────────────────
|
||||
export const DEFAULT_BRANDING = {
|
||||
company: 'LynkedUp Pro',
|
||||
phone: '866-259-6533',
|
||||
email: 'estimates@lynkeduppro.com',
|
||||
serviceArea: 'Texas & surrounding regions',
|
||||
primaryColor: '#fda913',
|
||||
legalFooter: 'Preliminary estimate. Not a binding quote.',
|
||||
};
|
||||
|
||||
// ── Legal / disclaimer (spec §15) — verbatim required template ────────────────
|
||||
export const DISCLAIMER_TEMPLATE =
|
||||
'This report is an educational, preliminary estimate based on the information provided and available ' +
|
||||
'property/storm data. Final pricing requires property measurement and contractor inspection. Insurance ' +
|
||||
'premium savings are illustrative only and must be confirmed with your insurance carrier or licensed ' +
|
||||
'insurance professional. Historical storm information is not a forecast or guarantee.';
|
||||
|
||||
// Schema version of the config shape. The *published* tenant version is this plus
|
||||
// a revision suffix (e.g. tenant-config-v1.r4) that bumps on every admin save, so
|
||||
// each report records exactly which package content/pricing produced it (§14, §18).
|
||||
export const TENANT_CONFIG_VERSION = 'tenant-config-v1';
|
||||
export const DISCLAIMER_VERSION = 'disclaimer-v1';
|
||||
|
||||
// Admin-editable overrides (spec §14). Held in memory only — like the rest of the
|
||||
// CRM demo store, nothing is written to localStorage, so this resets on a full page
|
||||
// reload. The wizard merges these over the defaults so pricing/branding/feature
|
||||
// flags are tenant-controlled, not code. Module-scoped so the value survives route
|
||||
// changes within a session (admin → wizard → leads) without persistence.
|
||||
let configOverrides = {};
|
||||
|
||||
// Same-tab change signal so an open wizard refreshes when the admin publishes,
|
||||
// without a hard reload (the in-memory store has no `storage` event to lean on).
|
||||
export const CONFIG_CHANGE_EVENT = 'lup-estimate-wizard-config-changed';
|
||||
|
||||
function notifyConfigChanged() {
|
||||
try { window.dispatchEvent(new Event(CONFIG_CHANGE_EVENT)); } catch { /* SSR/no window */ }
|
||||
}
|
||||
|
||||
function defaults() {
|
||||
return {
|
||||
version: TENANT_CONFIG_VERSION,
|
||||
disclaimerVersion: DISCLAIMER_VERSION,
|
||||
// Feature flags (spec §18): kill-switch, AI toggle, conservative copy mode.
|
||||
featureEnabled: true,
|
||||
aiExplanationsEnabled: true,
|
||||
conservativeMode: false,
|
||||
packages: DEFAULT_PACKAGES,
|
||||
addons: DEFAULT_ADDONS,
|
||||
scoringWeights: DEFAULT_SCORING_WEIGHTS,
|
||||
stormSettings: DEFAULT_STORM_SETTINGS,
|
||||
pricing: DEFAULT_PRICING,
|
||||
discountScenarios: DEFAULT_DISCOUNT_SCENARIOS,
|
||||
branding: DEFAULT_BRANDING,
|
||||
disclaimer: DISCLAIMER_TEMPLATE,
|
||||
};
|
||||
}
|
||||
|
||||
export function loadConfigOverrides() {
|
||||
return configOverrides;
|
||||
}
|
||||
|
||||
export function saveConfigOverrides(overrides) {
|
||||
configOverrides = overrides || {};
|
||||
notifyConfigChanged();
|
||||
}
|
||||
|
||||
export function resetConfigOverrides() {
|
||||
configOverrides = {};
|
||||
notifyConfigChanged();
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a new tenant config version (spec §14: "versioned"). Bumps the revision,
|
||||
* stamps versionedAt, and appends to a capped version history so admins can see and
|
||||
* audit every change. Returns the saved overrides (with the new version metadata).
|
||||
*/
|
||||
export function publishConfigOverrides(overrides) {
|
||||
const prev = loadConfigOverrides();
|
||||
const revision = (Number(prev.revision) || 0) + 1;
|
||||
const version = `${TENANT_CONFIG_VERSION}.r${revision}`;
|
||||
const versionedAt = new Date().toISOString();
|
||||
const history = Array.isArray(prev.versionHistory) ? prev.versionHistory : [];
|
||||
|
||||
const saved = {
|
||||
...overrides,
|
||||
revision,
|
||||
version,
|
||||
versionedAt,
|
||||
versionHistory: [{ revision, version, versionedAt }, ...history].slice(0, 25),
|
||||
};
|
||||
saveConfigOverrides(saved);
|
||||
return saved;
|
||||
}
|
||||
|
||||
export function getTenantConfig() {
|
||||
const base = defaults();
|
||||
const o = loadConfigOverrides();
|
||||
|
||||
// Shallow-merge top-level objects so partial admin edits don't drop defaults.
|
||||
// `...o` lets a published version/versionedAt/revision override the base schema
|
||||
// version, so getTenantConfig().version reflects the tenant's edits.
|
||||
return {
|
||||
...base,
|
||||
...o,
|
||||
pricing: { ...base.pricing, ...(o.pricing || {}) },
|
||||
branding: { ...base.branding, ...(o.branding || {}) },
|
||||
scoringWeights: { ...base.scoringWeights, ...(o.scoringWeights || {}) },
|
||||
stormSettings: { ...base.stormSettings, ...(o.stormSettings || {}) },
|
||||
discountScenarios: { ...base.discountScenarios, ...(o.discountScenarios || {}) },
|
||||
// Packages: merge admin price/label edits by type, keep default scope/copy.
|
||||
packages: base.packages.map((p) => {
|
||||
const ov = (o.packages || []).find((x) => x.type === p.type);
|
||||
return ov ? { ...p, ...ov } : p;
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* hailProbability.js — conservative historical hail-risk model (spec §6).
|
||||
*
|
||||
* IMPORTANT framing (spec §6): the output is a HISTORICAL hazard estimate for the
|
||||
* surrounding area, NOT a property-level forecast or guarantee. The model computes
|
||||
* a RANGE (confidence band), not one overconfident number, and stores every
|
||||
* assumption so the report can be defended.
|
||||
*
|
||||
* lambda (λ) = deduped hail events / years
|
||||
* P(at least one hail event over N years) = 1 - e^(-λN)
|
||||
*
|
||||
* Pure function — no I/O. Fed by /api/storm-history (existing storm module, used
|
||||
* read-only). Returns the hail_risk_snapshot shape (spec §12).
|
||||
*/
|
||||
|
||||
function clamp(n, lo, hi) { return Math.max(lo, Math.min(hi, n)); }
|
||||
function pct(n) { return Math.round(n * 100); }
|
||||
|
||||
/**
|
||||
* @param {object} input
|
||||
* eventCount {number} deduped hail events observed in the window
|
||||
* windowYears {number} historical window analyzed
|
||||
* radiusMiles {number} search radius around the property
|
||||
* geocodeConfidence {'high'|'moderate'|'low'}
|
||||
* maxHailInches {number|null}
|
||||
* lastEventDate {string|null} ISO date of most recent hail event
|
||||
* source {string} human-readable data source label
|
||||
* generatedAt {string} ISO timestamp the snapshot was frozen
|
||||
*/
|
||||
export function computeHailRisk(input) {
|
||||
const {
|
||||
eventCount = 0,
|
||||
windowYears = 5,
|
||||
radiusMiles = 20,
|
||||
geocodeConfidence = 'moderate',
|
||||
maxHailInches = null,
|
||||
lastEventDate = null,
|
||||
source = 'NOAA / IEM Local Storm Reports',
|
||||
generatedAt = new Date().toISOString(),
|
||||
} = input || {};
|
||||
|
||||
const years = Math.max(1, windowYears);
|
||||
const count = Math.max(0, eventCount);
|
||||
|
||||
// Annualized rate (events/year).
|
||||
const lambda = count / years;
|
||||
|
||||
// Poisson confidence band using event-count standard error (√count).
|
||||
const se = Math.sqrt(count);
|
||||
const lambdaLow = Math.max(0, (count - se) / years);
|
||||
const lambdaHigh = (count + se) / years;
|
||||
|
||||
const prob = (lam, n) => 1 - Math.exp(-lam * n);
|
||||
const p10Base = prob(lambda, 10);
|
||||
const p10Low = prob(lambdaLow, 10);
|
||||
const p10High = prob(lambdaHigh, 10);
|
||||
|
||||
// Never present a 100% historical probability — that reads as a certainty/guarantee,
|
||||
// which this model explicitly is not (spec §6). High-activity areas push p10High to
|
||||
// ~1.0, so cap the DISPLAYED band just under certainty. Underlying p10* values still
|
||||
// drive the exposure tier and scoring unchanged.
|
||||
const MAX_DISPLAY_PROB = 0.95;
|
||||
const dispLow = Math.min(p10Low, MAX_DISPLAY_PROB);
|
||||
const dispBase = Math.min(p10Base, MAX_DISPLAY_PROB);
|
||||
const dispHigh = Math.min(p10High, MAX_DISPLAY_PROB);
|
||||
|
||||
// Confidence score (0..1): sample size + window length + geocode precision.
|
||||
const sampleScore = clamp(count / 8, 0, 1); // ~8+ events → strong
|
||||
const windowScore = clamp(years / 10, 0, 1); // 10y window → full
|
||||
const geoScore = geocodeConfidence === 'high' ? 1 : geocodeConfidence === 'moderate' ? 0.6 : 0.3;
|
||||
const confidenceValue = +(0.4 * sampleScore + 0.3 * windowScore + 0.3 * geoScore).toFixed(2);
|
||||
const confidenceLabel = confidenceValue >= 0.66 ? 'High' : confidenceValue >= 0.4 ? 'Moderate' : 'Low';
|
||||
|
||||
// Coarse exposure tier used downstream by the scoring engine (Phase 4).
|
||||
const exposureTier = p10Base >= 0.66 ? 'high' : p10Base >= 0.33 ? 'moderate' : 'low';
|
||||
|
||||
return {
|
||||
// hail_risk_snapshot fields (spec §12)
|
||||
source,
|
||||
windowYears: years,
|
||||
radiusMiles,
|
||||
eventCount: count,
|
||||
annualRate: +lambda.toFixed(3),
|
||||
maxHailInches,
|
||||
lastEventDate,
|
||||
probability10yr: { low: pct(dispLow), base: pct(dispBase), high: pct(dispHigh) },
|
||||
confidence: confidenceLabel,
|
||||
confidenceValue,
|
||||
exposureTier,
|
||||
generatedAt,
|
||||
|
||||
// Customer-facing wording (spec §6) — historical, never a forecast.
|
||||
wording: {
|
||||
annualRate: count === 0
|
||||
? 'No qualifying hail events were found in the available reports for this area.'
|
||||
: `This area has averaged about ${lambda.toFixed(1)} observed hail event(s) per year in the available dataset.`,
|
||||
probability: count === 0
|
||||
? 'There is not enough historical hail activity nearby to estimate a meaningful probability.'
|
||||
: `Based on historical reports, the chance of at least one hail event in the next 10 years is estimated at ${pct(dispLow)}–${pct(dispHigh)}%, assuming future patterns resemble the past.`,
|
||||
confidence: `Confidence: ${confidenceLabel}. This is based on county-level and nearby reports within ~${radiusMiles} miles, not a roof-specific inspection.`,
|
||||
lastUpdated: `Storm data reflects reports through ${new Date(generatedAt).toLocaleDateString()}.`,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* pricing.js — preliminary package + add-on pricing (spec §7, §8, §10, §14).
|
||||
*
|
||||
* Pure functions. All inputs come from tenantConfig (nothing hard-coded per §14).
|
||||
* Because the wizard has no verified roof measurement (spec §3), every price is a
|
||||
* PRELIMINARY RANGE (low/base/high) and must be labeled as such — final pricing
|
||||
* requires inspection.
|
||||
*/
|
||||
|
||||
const round = (n, to = 50) => Math.round(n / to) * to;
|
||||
|
||||
/**
|
||||
* Price a single package for an assumed roof size.
|
||||
* @returns {{ low:number, base:number, high:number }}
|
||||
*/
|
||||
export function pricePackage(pkg, pricing) {
|
||||
const squares = pricing.assumedSquares ?? 25;
|
||||
const waste = pricing.wasteFactor ?? 1.05;
|
||||
const pitch = pricing.pitchFactor ?? 1.0;
|
||||
|
||||
const raw = pkg.pricePerSquare * squares * waste * pitch;
|
||||
const base = Math.max(pricing.minJobPrice ?? 0, raw);
|
||||
const spread = pricing.rangeSpread ?? 0.08;
|
||||
|
||||
return {
|
||||
low: round(base * (1 - spread)),
|
||||
base: round(base),
|
||||
high: round(base * (1 + spread)),
|
||||
};
|
||||
}
|
||||
|
||||
/** Price every package (keyed by type). */
|
||||
export function priceAllPackages(packages, pricing) {
|
||||
const out = {};
|
||||
packages.forEach((p) => { out[p.type] = pricePackage(p, pricing); });
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Total price of the selected add-ons (flat, demo). */
|
||||
export function addonsTotal(addons, selectedIds) {
|
||||
return addons
|
||||
.filter((a) => selectedIds.includes(a.id))
|
||||
.reduce((sum, a) => sum + (a.price || 0), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive add-on trigger keys from the customer's wizard answers + hail snapshot.
|
||||
* Drives default-selected add-ons and AI "recommend when…" hints (spec §8).
|
||||
*/
|
||||
export function deriveAddonTriggers(inputs, hailSnapshot) {
|
||||
const t = new Set();
|
||||
if (inputs.issueType === 'leak') t.add('leak');
|
||||
if (inputs.issueType === 'aging' || (inputs.roofAgeYears ?? 0) >= 15) t.add('old_roof');
|
||||
if (inputs.atticConcern === 'attic_hot') t.add('attic_hot');
|
||||
if (inputs.atticConcern === 'energy_bills') t.add('energy_bills');
|
||||
if (inputs.pestConcern === 'yes') t.add('pests');
|
||||
if (inputs.budgetPreference === 'longterm') t.add('aesthetics');
|
||||
if (hailSnapshot?.exposureTier === 'high') t.add('complex');
|
||||
return t;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which add-ons should be pre-selected for a given package + answers (spec §8).
|
||||
* An add-on defaults ON if its triggers fire, or it's recommended on this package.
|
||||
*/
|
||||
export function defaultSelectedAddons(addons, packageType, triggers) {
|
||||
return addons
|
||||
.filter((a) =>
|
||||
(a.recommendedOn || []).includes(packageType) ||
|
||||
(a.triggerKeys || []).some((k) => triggers.has(k))
|
||||
)
|
||||
.map((a) => a.id);
|
||||
}
|
||||
|
||||
/** Is an add-on "recommended" for display badge purposes? */
|
||||
export function isAddonRecommended(addon, packageType, triggers) {
|
||||
return (addon.recommendedOn || []).includes(packageType) ||
|
||||
(addon.triggerKeys || []).some((k) => triggers.has(k));
|
||||
}
|
||||
|
||||
export function formatUSD(n) {
|
||||
return `$${Math.round(n).toLocaleString('en-US')}`;
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* scoringEngine.js — deterministic Good/Better/Best recommendation (spec §9).
|
||||
*
|
||||
* Contract (spec §9, §18):
|
||||
* - Deterministic & reproducible: same inputs + same weights ⇒ same scores.
|
||||
* - Logged: returns the full per-factor breakdown so it can be audited.
|
||||
* - AI may EXPLAIN the result but must never silently change it.
|
||||
* - Never always recommends Best — budget preference + uncertainty penalties guard
|
||||
* against over-recommendation (spec §15 fairness).
|
||||
*
|
||||
* Pure function. No I/O, no randomness, no Date.
|
||||
*/
|
||||
|
||||
const TYPES = ['GOOD', 'BETTER', 'BEST'];
|
||||
export const SCORING_MODEL_VERSION = 'scoring-v1';
|
||||
|
||||
// ── Per-factor sub-scores (0..1) by package type ─────────────────────────────
|
||||
|
||||
function ownershipFactor(horizon) {
|
||||
switch (horizon) {
|
||||
case '1-5': return { GOOD: 1.0, BETTER: 0.6, BEST: 0.3 };
|
||||
case '6-15': return { GOOD: 0.5, BETTER: 1.0, BEST: 0.7 };
|
||||
case '16+': return { GOOD: 0.3, BETTER: 0.7, BEST: 1.0 };
|
||||
default: return { GOOD: 0.6, BETTER: 0.7, BEST: 0.6 };
|
||||
}
|
||||
}
|
||||
|
||||
function hailFactor(exposureTier) {
|
||||
switch (exposureTier) {
|
||||
case 'low': return { GOOD: 1.0, BETTER: 0.7, BEST: 0.5 };
|
||||
case 'moderate': return { GOOD: 0.5, BETTER: 1.0, BEST: 0.8 };
|
||||
case 'high': return { GOOD: 0.2, BETTER: 0.8, BEST: 1.0 };
|
||||
default: return { GOOD: 0.6, BETTER: 0.7, BEST: 0.6 }; // unknown → neutral
|
||||
}
|
||||
}
|
||||
|
||||
function budgetFactor(pref) {
|
||||
switch (pref) {
|
||||
case 'lowest': return { GOOD: 1.0, BETTER: 0.5, BEST: 0.2 };
|
||||
case 'balanced': return { GOOD: 0.6, BETTER: 1.0, BEST: 0.6 };
|
||||
case 'longterm': return { GOOD: 0.3, BETTER: 0.7, BEST: 1.0 };
|
||||
default: return { GOOD: 0.7, BETTER: 0.8, BEST: 0.6 };
|
||||
}
|
||||
}
|
||||
|
||||
function roofAgeFactor(years, issueType) {
|
||||
const urgent = issueType === 'leak';
|
||||
if (years >= 15 || urgent) return { GOOD: 0.5, BETTER: 0.8, BEST: 1.0 };
|
||||
if (years >= 8) return { GOOD: 0.7, BETTER: 1.0, BEST: 0.8 };
|
||||
return { GOOD: 1.0, BETTER: 0.8, BEST: 0.6 };
|
||||
}
|
||||
|
||||
// Insurance-savings factor scales with premium × each package's discount.
|
||||
function insuranceFactor(premium, packages) {
|
||||
if (!premium || premium <= 0) {
|
||||
return { factors: { GOOD: 0.5, BETTER: 0.5, BEST: 0.5 }, known: false };
|
||||
}
|
||||
const savings = {};
|
||||
packages.forEach((p) => { savings[p.type] = premium * ((p.insuranceDiscountPct || 0) / 100); });
|
||||
const max = Math.max(...Object.values(savings), 1);
|
||||
const factors = {};
|
||||
TYPES.forEach((t) => { factors[t] = +(savings[t] / max).toFixed(3); });
|
||||
return { factors, known: true, savings };
|
||||
}
|
||||
|
||||
function aestheticsFactor(inputs) {
|
||||
const premiumLook = inputs.budgetPreference === 'longterm' || inputs.hoaConstraints === 'yes';
|
||||
return premiumLook
|
||||
? { GOOD: 0.5, BETTER: 0.8, BEST: 1.0 }
|
||||
: { GOOD: 0.7, BETTER: 0.9, BEST: 1.0 };
|
||||
}
|
||||
|
||||
// ── Reason synthesis (deterministic, feeds AI + fallback copy) ────────────────
|
||||
|
||||
function buildReasons(type, ctx) {
|
||||
const { inputs, hailSnapshot } = ctx;
|
||||
const r = [];
|
||||
if (type === 'BEST') {
|
||||
if (inputs.ownershipHorizon === '16+') r.push('You plan to stay in the home long-term (16+ years), so a longer-life system spreads its cost over more years.');
|
||||
if (hailSnapshot?.exposureTier === 'high') r.push('Your area shows higher historical hail exposure, where the most durable system adds the most protection.');
|
||||
if (inputs.premiumAmount > 0) r.push('Your insurance premium creates a potential savings scenario if your carrier recognizes qualifying impact-resistant materials.');
|
||||
r.push('It carries the strongest lifecycle value case and fewest expected future roof cycles.');
|
||||
} else if (type === 'BETTER') {
|
||||
r.push('It improves hail resistance with a Class 4 impact-rated system at a balanced cost.');
|
||||
if (hailSnapshot?.exposureTier !== 'low') r.push('Given your area’s historical hail activity, the added impact resistance is meaningful.');
|
||||
if (inputs.premiumAmount > 0) r.push('It may qualify for insurance premium consideration where available — confirm eligibility with your carrier.');
|
||||
r.push('It balances stronger protection against premium-system cost.');
|
||||
} else {
|
||||
if (inputs.budgetPreference === 'lowest') r.push('You prioritized the lowest upfront investment.');
|
||||
if (inputs.ownershipHorizon === '1-5') r.push('A shorter ownership horizon makes lowest upfront cost a rational choice.');
|
||||
if (hailSnapshot?.exposureTier === 'low') r.push('Your area shows lower historical hail exposure.');
|
||||
r.push('It uses a quality architectural shingle system that meets code with standard accessories.');
|
||||
}
|
||||
return r.slice(0, 4);
|
||||
}
|
||||
|
||||
/**
|
||||
* score() — main entry.
|
||||
* @param {object} args { inputs, hailSnapshot, tenantConfig }
|
||||
* @returns recommendation record (spec §12 recommendations)
|
||||
*/
|
||||
export function score({ inputs, hailSnapshot, tenantConfig }) {
|
||||
const weights = tenantConfig.scoringWeights;
|
||||
const packages = tenantConfig.packages;
|
||||
|
||||
const fOwn = ownershipFactor(inputs.ownershipHorizon);
|
||||
const fHail = hailFactor(hailSnapshot?.exposureTier);
|
||||
const fBudget = budgetFactor(inputs.budgetPreference);
|
||||
const fAge = roofAgeFactor(inputs.roofAgeYears ?? 10, inputs.issueType);
|
||||
const fIns = insuranceFactor(inputs.premiumAmount, packages);
|
||||
const fAes = aestheticsFactor(inputs);
|
||||
|
||||
const uncertaintyFlags = [];
|
||||
if (!inputs.premiumAmount || inputs.premiumUnknown) uncertaintyFlags.push('premium_unknown');
|
||||
if (!hailSnapshot || hailSnapshot.exposureTier == null) uncertaintyFlags.push('hail_unknown');
|
||||
if (inputs.hoaConstraints === 'unsure') uncertaintyFlags.push('hoa_unsure');
|
||||
|
||||
const scores = {};
|
||||
TYPES.forEach((t) => {
|
||||
const factors = {
|
||||
ownershipHorizon: fOwn[t],
|
||||
hailRisk: fHail[t],
|
||||
insuranceSavings: fIns.factors[t],
|
||||
budgetPreference: fBudget[t],
|
||||
roofAge: fAge[t],
|
||||
aesthetics: fAes[t],
|
||||
};
|
||||
let total =
|
||||
factors.ownershipHorizon * weights.ownershipHorizon +
|
||||
factors.hailRisk * weights.hailRisk +
|
||||
factors.insuranceSavings * weights.insuranceSavings +
|
||||
factors.budgetPreference * weights.budgetPreference +
|
||||
factors.roofAge * weights.roofAge +
|
||||
factors.aesthetics * weights.aesthetics;
|
||||
|
||||
// Uncertainty penalty — applied to BEST so missing data never auto-upsells.
|
||||
if (t === 'BEST') total -= 0.04 * uncertaintyFlags.length;
|
||||
|
||||
scores[t] = { total: +total.toFixed(4), factors };
|
||||
});
|
||||
|
||||
// Pick the top-scoring package.
|
||||
let selectedPackage = TYPES.reduce((best, t) => (scores[t].total > scores[best].total ? t : best), 'GOOD');
|
||||
|
||||
// Budget guard (spec §7/§15): a strong "lowest upfront" preference must not be
|
||||
// overridden to BEST unless exposure is high AND the homeowner is long-term.
|
||||
let budgetGuardApplied = false;
|
||||
if (inputs.budgetPreference === 'lowest' && selectedPackage === 'BEST') {
|
||||
const justified = hailSnapshot?.exposureTier === 'high' && inputs.ownershipHorizon === '16+';
|
||||
if (!justified) { selectedPackage = 'BETTER'; budgetGuardApplied = true; }
|
||||
}
|
||||
|
||||
return {
|
||||
modelVersion: SCORING_MODEL_VERSION,
|
||||
weights,
|
||||
scores,
|
||||
selectedPackage,
|
||||
reasons: buildReasons(selectedPackage, { inputs, hailSnapshot }),
|
||||
uncertaintyFlags,
|
||||
budgetGuardApplied,
|
||||
insuranceKnown: fIns.known,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* valueModel.js — 10-year value comparison (spec §10).
|
||||
*
|
||||
* Makes premium options understandable WITHOUT pretending savings are guaranteed:
|
||||
* - 10-yr premium savings scenario = annual premium × assumed discount × 10
|
||||
* - computes low/base/high scenarios so the UI can TOGGLE between them (carrier
|
||||
* discount is never certain)
|
||||
* - upgrade breakeven only when pricing AND discount are known
|
||||
* - lifecycle value delta is labeled a model estimate, not a promise
|
||||
* - per-tier "10-year value view" positioning (Upfront only / Savings + protection /
|
||||
* Savings + longer lifecycle + protection)
|
||||
*
|
||||
* Pure function. Honors conservativeMode (spec §18) by suppressing savings claims.
|
||||
*/
|
||||
import { pricePackage } from './pricing';
|
||||
|
||||
const YEARS = 10;
|
||||
const SCEN_KEYS = ['low', 'base', 'high'];
|
||||
|
||||
// 10-year value view positioning per tier (spec §10 comparison table).
|
||||
const VALUE_VIEW = {
|
||||
GOOD: 'Upfront only',
|
||||
BETTER: 'Savings + protection',
|
||||
BEST: 'Savings + longer lifecycle + protection',
|
||||
};
|
||||
|
||||
export function computeTenYearValue({ inputs, tenantConfig }) {
|
||||
const { packages, pricing, discountScenarios, conservativeMode } = tenantConfig;
|
||||
const premium = (!inputs.premiumUnknown && inputs.premiumAmount > 0) ? inputs.premiumAmount : null;
|
||||
const showSavings = !!premium && !conservativeMode;
|
||||
|
||||
const goodPkg = packages.find((p) => p.type === 'GOOD');
|
||||
const goodPrice = pricePackage(goodPkg, pricing).base;
|
||||
const goodLife = goodPkg?.serviceLifeYears || 28;
|
||||
|
||||
const rows = packages.map((pkg) => {
|
||||
const price = pricePackage(pkg, pricing);
|
||||
const scen = discountScenarios[pkg.type] || { low: 0, base: 0, high: 0 };
|
||||
|
||||
// Upgrade cost vs the Good baseline.
|
||||
const upgradeCost = Math.max(0, price.base - goodPrice);
|
||||
|
||||
// Avoided future roof-cycle reserve proxy: extra service life beyond Good,
|
||||
// valued at Good's price prorated (labeled a model estimate downstream).
|
||||
const extraLife = Math.max(0, (pkg.serviceLifeYears || goodLife) - goodLife);
|
||||
const avoidedReserve = Math.round((extraLife / goodLife) * goodPrice * 0.5);
|
||||
|
||||
// Per-scenario savings / annual / breakeven / lifecycle delta so the UI can
|
||||
// toggle low/base/high (spec §10: "allow toggling discount scenarios").
|
||||
const savings = {}, annualSavings = {}, breakeven = {}, lifecycleDelta = {};
|
||||
SCEN_KEYS.forEach((k) => {
|
||||
const pct = scen[k] || 0;
|
||||
const annual = showSavings ? premium * (pct / 100) : 0;
|
||||
const tenYr = Math.round(annual * YEARS);
|
||||
annualSavings[k] = Math.round(annual);
|
||||
savings[k] = tenYr;
|
||||
// Breakeven (years) — only when both pricing and a positive discount exist.
|
||||
breakeven[k] = (upgradeCost > 0 && annual > 0) ? +(upgradeCost / annual).toFixed(1) : null;
|
||||
// Lifecycle value delta = savings + avoided reserve − upgrade cost.
|
||||
lifecycleDelta[k] = Math.round(tenYr + avoidedReserve - upgradeCost);
|
||||
});
|
||||
|
||||
return {
|
||||
type: pkg.type,
|
||||
name: pkg.name,
|
||||
price,
|
||||
// Assumptions surfaced to the customer (spec §10 "show assumptions").
|
||||
discountScenario: scen, // { low, base, high } discount % assumption
|
||||
discountPct: scen.base,
|
||||
serviceLifeYears: pkg.serviceLifeYears,
|
||||
hailResistance: pkg.hailResistance,
|
||||
valueView: VALUE_VIEW[pkg.type] || '',
|
||||
upgradeCost,
|
||||
avoidedReserve,
|
||||
// Per-scenario outputs (null savings when not shown, for back-compat callers).
|
||||
savings: showSavings ? savings : null,
|
||||
annualSavings,
|
||||
breakeven,
|
||||
lifecycleDelta,
|
||||
// Back-compat scalar (base scenario).
|
||||
breakevenYears: breakeven.base,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
years: YEARS,
|
||||
premiumKnown: !!premium,
|
||||
conservativeMode: !!conservativeMode,
|
||||
scenarios: SCEN_KEYS,
|
||||
rows,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* useWizardHailRisk — produces a frozen hail-risk snapshot for the wizard.
|
||||
*
|
||||
* Pipeline (all read-only against existing services):
|
||||
* 1. geocode the address (module-local geocode.js → Nominatim)
|
||||
* 2. GET /api/storm-history (EXISTING storm module endpoint — not modified)
|
||||
* 3. run computeHailRisk() (module-local Poisson model, spec §6)
|
||||
*
|
||||
* Returns { loading, error, property, snapshot }. The caller freezes `snapshot`
|
||||
* into the session (spec §12 hail_risk_snapshots) so the report stays immutable.
|
||||
*/
|
||||
import { useState, useEffect } from 'react';
|
||||
import { geocodeAddress } from '../api/geocode';
|
||||
import { computeHailRisk } from '../engine/hailProbability';
|
||||
|
||||
export function useWizardHailRisk({ address, property, enabled = true, stormSettings = {} }) {
|
||||
const [loading, setLoading] = useState(enabled);
|
||||
const [error, setError] = useState(null);
|
||||
const [resolvedProperty, setResolvedProperty] = useState(property || null);
|
||||
const [snapshot, setSnapshot] = useState(null);
|
||||
|
||||
const radiusMiles = stormSettings.radiusMiles ?? 20;
|
||||
// storm-history caps months at 60 (5 years); window honesty is stored in the snapshot.
|
||||
const months = 60;
|
||||
const windowYears = months / 12;
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
// 1. Resolve coordinates (reuse stored property if already geocoded).
|
||||
let prop = property;
|
||||
if (!prop || prop.lat == null || prop.lon == null) {
|
||||
prop = await geocodeAddress(address);
|
||||
}
|
||||
if (cancelled) return;
|
||||
setResolvedProperty(prop);
|
||||
|
||||
// 2. Existing keyless storm endpoint (read-only).
|
||||
let hailCount = 0;
|
||||
let maxHail = null;
|
||||
let lastEventDate = null;
|
||||
let source = 'NOAA / IEM Local Storm Reports';
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/storm-history?lat=${prop.lat}&lng=${prop.lon}&months=${months}&radiusMi=${Math.min(radiusMiles, 50)}`
|
||||
);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
hailCount = data?.summary?.byType?.hail ?? 0;
|
||||
maxHail = data?.summary?.maxHail ?? null;
|
||||
lastEventDate = data?.summary?.lastEvent?.date ?? null;
|
||||
}
|
||||
} catch {
|
||||
// Leave defaults (count 0) — computeHailRisk handles the empty case honestly.
|
||||
}
|
||||
if (cancelled) return;
|
||||
|
||||
// 3. Conservative Poisson model.
|
||||
const snap = computeHailRisk({
|
||||
eventCount: hailCount,
|
||||
windowYears,
|
||||
radiusMiles,
|
||||
geocodeConfidence: prop.confidence,
|
||||
maxHailInches: maxHail,
|
||||
lastEventDate,
|
||||
source,
|
||||
generatedAt: new Date().toISOString(),
|
||||
});
|
||||
if (!cancelled) setSnapshot(snap);
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(e.message || 'Could not load storm history.');
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [address, enabled, radiusMiles]);
|
||||
|
||||
return { loading, error, property: resolvedProperty, snapshot };
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* wizardReportExport.js — branded customer report PDF (spec §11, §12 customer_reports).
|
||||
*
|
||||
* Sections (spec §11): cover, property summary, storm/hail snapshot, Good/Better/Best
|
||||
* options, AI recommendation, 10-year comparison, selected add-ons, disclaimers
|
||||
* (verbatim §15 template), next steps. Mirrors the app's jsPDF pattern (estimateExport.js).
|
||||
*
|
||||
* The same computed inputs drive the on-screen preview and the PDF so they match
|
||||
* exactly (spec §18: "report generated by email and print must match the saved version").
|
||||
*/
|
||||
import { pricePackage, addonsTotal, formatUSD } from '../engine/pricing';
|
||||
import { computeTenYearValue } from '../engine/valueModel';
|
||||
|
||||
const PKG_LABEL = { GOOD: 'Good', BETTER: 'Better', BEST: 'Best' };
|
||||
|
||||
function reportId(sessionId) {
|
||||
return `RPT-${(sessionId || '').replace(/[^a-zA-Z0-9]/g, '').slice(-8).toUpperCase() || 'PREVIEW'}`;
|
||||
}
|
||||
|
||||
/** Builds the structured report model — single source of truth for PDF + preview. */
|
||||
export function buildReportModel(session, tenantConfig) {
|
||||
const i = session.inputs;
|
||||
const value = session.recommendation?.value || computeTenYearValue({ inputs: i, tenantConfig });
|
||||
const recommended = session.recommendation?.selectedPackage || session.selectedPackage;
|
||||
const selected = session.selectedPackage || recommended;
|
||||
|
||||
const selectedPkg = tenantConfig.packages.find((p) => p.type === selected);
|
||||
const base = selectedPkg ? pricePackage(selectedPkg, tenantConfig.pricing).base : 0;
|
||||
const addonsSum = addonsTotal(tenantConfig.addons, session.selectedAddons || []);
|
||||
|
||||
return {
|
||||
reportId: reportId(session.id),
|
||||
generatedAt: new Date().toISOString(),
|
||||
branding: tenantConfig.branding,
|
||||
disclaimer: tenantConfig.disclaimer,
|
||||
contact: { name: i.name, address: i.address, phone: i.phone, email: i.email },
|
||||
property: session.property,
|
||||
roof: { ageYears: i.roofAgeYears, ownership: i.ownershipHorizon, issue: i.issueType, premium: i.premiumUnknown ? null : i.premiumAmount },
|
||||
hail: session.hailSnapshot,
|
||||
packages: tenantConfig.packages.map((p) => ({
|
||||
type: p.type, name: p.name, category: p.category, warranty: p.warrantyLabel,
|
||||
hailResistance: p.hailResistance, price: pricePackage(p, tenantConfig.pricing),
|
||||
})),
|
||||
recommended,
|
||||
selected,
|
||||
explanation: session.recommendation?.explanation || null,
|
||||
reasons: session.recommendation?.reasons || [],
|
||||
value,
|
||||
addons: (tenantConfig.addons || []).filter((a) => (session.selectedAddons || []).includes(a.id)),
|
||||
totals: { base, addonsSum, total: base + addonsSum },
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateReportPdf(session, tenantConfig) {
|
||||
const m = buildReportModel(session, tenantConfig);
|
||||
const { default: jsPDF } = await import('jspdf');
|
||||
const { default: autoTable } = await import('jspdf-autotable');
|
||||
|
||||
const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' });
|
||||
const W = doc.internal.pageSize.getWidth();
|
||||
const MARGIN = 14;
|
||||
const accent = tenantConfig.branding.primaryColor || '#fda913';
|
||||
let y = 0;
|
||||
|
||||
const text = (s, x, yy, opts) => doc.text(String(s ?? ''), x, yy, opts);
|
||||
const ensure = (needed) => { if (y + needed > 285) { doc.addPage(); y = 18; } };
|
||||
const heading = (label) => {
|
||||
ensure(14);
|
||||
doc.setFont('helvetica', 'bold'); doc.setFontSize(11);
|
||||
doc.setTextColor(20); text(label, MARGIN, y); y += 2;
|
||||
doc.setDrawColor(accent); doc.setLineWidth(0.6); doc.line(MARGIN, y, W - MARGIN, y); y += 6;
|
||||
};
|
||||
|
||||
// ── Cover band ──
|
||||
doc.setFillColor(17, 17, 19); doc.rect(0, 0, W, 34, 'F');
|
||||
doc.setTextColor(255); doc.setFont('helvetica', 'bold'); doc.setFontSize(18);
|
||||
text(m.branding.company, MARGIN, 16);
|
||||
doc.setFont('helvetica', 'normal'); doc.setFontSize(10);
|
||||
text('Your Roof Options Report', MARGIN, 24);
|
||||
doc.setFontSize(8);
|
||||
text(`${m.reportId} · ${new Date(m.generatedAt).toLocaleDateString()}`, MARGIN, 30);
|
||||
y = 44;
|
||||
|
||||
doc.setTextColor(40); doc.setFont('helvetica', 'bold'); doc.setFontSize(12);
|
||||
text(`Prepared for ${m.contact.name || 'Homeowner'}`, MARGIN, y); y += 6;
|
||||
doc.setFont('helvetica', 'normal'); doc.setFontSize(9); doc.setTextColor(90);
|
||||
text(m.contact.address || (m.property?.displayName ?? ''), MARGIN, y); y += 10;
|
||||
|
||||
// ── Property summary ──
|
||||
heading('Property summary');
|
||||
autoTable(doc, {
|
||||
startY: y, theme: 'plain', margin: { left: MARGIN, right: MARGIN },
|
||||
styles: { fontSize: 9, cellPadding: 1.5 },
|
||||
body: [
|
||||
['Roof age (estimated)', `${m.roof.ageYears ?? '—'} years`],
|
||||
['Planned ownership', m.roof.ownership ? `${m.roof.ownership} years` : '—'],
|
||||
['Current concern', m.roof.issue || '—'],
|
||||
['Annual insurance premium', m.roof.premium ? formatUSD(m.roof.premium) : 'Not provided'],
|
||||
],
|
||||
columnStyles: { 0: { fontStyle: 'bold', cellWidth: 60, textColor: 60 }, 1: { textColor: 30 } },
|
||||
});
|
||||
y = doc.lastAutoTable.finalY + 8;
|
||||
|
||||
// ── Storm / hail snapshot ──
|
||||
heading('Storm / hail risk snapshot (historical)');
|
||||
doc.setFont('helvetica', 'normal'); doc.setFontSize(9); doc.setTextColor(60);
|
||||
if (m.hail) {
|
||||
const p = m.hail.probability10yr;
|
||||
const lines = [
|
||||
m.hail.wording?.probability || '',
|
||||
`Annualized rate: ${m.hail.annualRate}/yr · 10-yr range: ${m.hail.eventCount === 0 ? '—' : `${p.low}–${p.high}%`} · Confidence: ${m.hail.confidence}`,
|
||||
`Source: ${m.hail.source} · ~${m.hail.radiusMiles} mi · ${m.hail.wording?.lastUpdated || ''}`,
|
||||
];
|
||||
lines.forEach((l) => { ensure(6); doc.splitTextToSize(l, W - MARGIN * 2).forEach((ln) => { ensure(5); text(ln, MARGIN, y); y += 4.6; }); });
|
||||
} else {
|
||||
text('Storm history will be reviewed by your rep during inspection.', MARGIN, y); y += 5;
|
||||
}
|
||||
y += 4;
|
||||
|
||||
// ── Good / Better / Best ──
|
||||
heading('Your Good / Better / Best options');
|
||||
autoTable(doc, {
|
||||
startY: y, margin: { left: MARGIN, right: MARGIN },
|
||||
head: [['Option', 'System', 'Warranty', 'Hail', 'Estimated range']],
|
||||
body: m.packages.map((p) => [
|
||||
PKG_LABEL[p.type] + (p.type === m.recommended ? ' ★' : ''),
|
||||
p.name, p.warranty, p.hailResistance, `${formatUSD(p.price.low)}–${formatUSD(p.price.high)}`,
|
||||
]),
|
||||
styles: { fontSize: 8.5, cellPadding: 2 },
|
||||
headStyles: { fillColor: [24, 24, 27], textColor: 255 },
|
||||
alternateRowStyles: { fillColor: [248, 248, 248] },
|
||||
});
|
||||
y = doc.lastAutoTable.finalY + 8;
|
||||
|
||||
// ── AI recommendation ──
|
||||
heading(`Recommendation: ${PKG_LABEL[m.recommended] || '—'}`);
|
||||
doc.setFont('helvetica', 'normal'); doc.setFontSize(9); doc.setTextColor(50);
|
||||
if (m.explanation?.customerExplanation) {
|
||||
doc.splitTextToSize(m.explanation.customerExplanation, W - MARGIN * 2).forEach((ln) => { ensure(5); text(ln, MARGIN, y); y += 4.8; });
|
||||
y += 2;
|
||||
}
|
||||
m.reasons.forEach((r) => {
|
||||
ensure(6);
|
||||
doc.setTextColor(accent); text('•', MARGIN, y); doc.setTextColor(60);
|
||||
doc.splitTextToSize(r, W - MARGIN * 2 - 4).forEach((ln, idx) => { ensure(5); text(ln, MARGIN + 4, y); if (idx >= 0) y += 4.6; });
|
||||
});
|
||||
y += 4;
|
||||
|
||||
// ── 10-year comparison ──
|
||||
heading('10-year value comparison');
|
||||
|
||||
// Assumptions table (spec §10 "show assumptions").
|
||||
autoTable(doc, {
|
||||
startY: y, margin: { left: MARGIN, right: MARGIN },
|
||||
head: [['Option', 'Upfront (base)', 'Discount assumption', 'Service life', 'Hail resistance', '10-yr value view']],
|
||||
body: m.value.rows.map((r) => [
|
||||
PKG_LABEL[r.type],
|
||||
formatUSD(r.price.base),
|
||||
`${r.discountScenario?.base ?? 0}% (if eligible)`,
|
||||
`${r.serviceLifeYears}+ yrs`,
|
||||
r.hailResistance,
|
||||
r.valueView,
|
||||
]),
|
||||
styles: { fontSize: 8, cellPadding: 1.8 },
|
||||
headStyles: { fillColor: [24, 24, 27], textColor: 255 },
|
||||
});
|
||||
y = doc.lastAutoTable.finalY + 4;
|
||||
|
||||
// Value outputs table — savings range, breakeven, lifecycle delta (model estimate).
|
||||
autoTable(doc, {
|
||||
startY: y, margin: { left: MARGIN, right: MARGIN },
|
||||
head: [['Option', 'Est. 10-yr savings (low–high)', 'Upgrade breakeven', 'Lifecycle value delta (model est.)']],
|
||||
body: m.value.rows.map((r) => [
|
||||
PKG_LABEL[r.type],
|
||||
r.savings ? `${formatUSD(r.savings.low)}–${formatUSD(r.savings.high)}` : '—',
|
||||
r.breakeven?.base != null ? `${r.breakeven.base} yrs` : '—',
|
||||
r.savings ? formatUSD(r.lifecycleDelta?.base ?? 0) : '—',
|
||||
]),
|
||||
styles: { fontSize: 8, cellPadding: 1.8 },
|
||||
headStyles: { fillColor: [24, 24, 27], textColor: 255 },
|
||||
});
|
||||
y = doc.lastAutoTable.finalY + 6;
|
||||
doc.setFont('helvetica', 'italic'); doc.setFontSize(7.5); doc.setTextColor(120);
|
||||
doc.splitTextToSize(
|
||||
'Savings scenarios are illustrative model estimates, not guaranteed. They depend on carrier approval, policy '
|
||||
+ 'terms, state rules, and documentation. Breakeven and lifecycle value delta are model estimates, not promises.',
|
||||
W - MARGIN * 2,
|
||||
).forEach((ln) => { ensure(5); text(ln, MARGIN, y); y += 4.2; });
|
||||
y += 4;
|
||||
|
||||
// ── Selected add-ons ──
|
||||
heading('Selected add-ons');
|
||||
if (m.addons.length) {
|
||||
autoTable(doc, {
|
||||
startY: y, margin: { left: MARGIN, right: MARGIN },
|
||||
body: m.addons.map((a) => [a.name, a.value, `+${formatUSD(a.price)}`]),
|
||||
styles: { fontSize: 8.5, cellPadding: 2 },
|
||||
columnStyles: { 1: { textColor: 90 }, 2: { halign: 'right', fontStyle: 'bold' } },
|
||||
});
|
||||
y = doc.lastAutoTable.finalY + 4;
|
||||
} else {
|
||||
doc.setFont('helvetica', 'normal'); doc.setFontSize(9); doc.setTextColor(90);
|
||||
text('No add-ons selected.', MARGIN, y); y += 6;
|
||||
}
|
||||
ensure(8);
|
||||
doc.setFont('helvetica', 'bold'); doc.setFontSize(10); doc.setTextColor(20);
|
||||
text(`Estimated total (${PKG_LABEL[m.selected]} + add-ons): ~${formatUSD(m.totals.total)}`, MARGIN, y); y += 10;
|
||||
|
||||
// ── Disclaimers (verbatim) ──
|
||||
heading('Important disclaimers');
|
||||
doc.setFont('helvetica', 'normal'); doc.setFontSize(8); doc.setTextColor(90);
|
||||
doc.splitTextToSize(m.disclaimer, W - MARGIN * 2).forEach((ln) => { ensure(5); text(ln, MARGIN, y); y += 4.2; });
|
||||
y += 6;
|
||||
|
||||
// ── Next steps ──
|
||||
heading('Next steps');
|
||||
doc.setFont('helvetica', 'normal'); doc.setFontSize(9); doc.setTextColor(50);
|
||||
['Book your free inspection to verify measurements and finalize pricing.',
|
||||
`Call or text us at ${m.branding.phone}.`,
|
||||
'Have your insurance documents handy if a hail claim may be relevant.'].forEach((s) => {
|
||||
ensure(6); doc.setTextColor(accent); text('→', MARGIN, y); doc.setTextColor(50); text(s, MARGIN + 5, y); y += 5.5;
|
||||
});
|
||||
|
||||
// Footer on every page
|
||||
const pages = doc.internal.getNumberOfPages();
|
||||
for (let pg = 1; pg <= pages; pg++) {
|
||||
doc.setPage(pg);
|
||||
doc.setFont('helvetica', 'normal'); doc.setFontSize(7); doc.setTextColor(150);
|
||||
text(`${m.branding.company} · ${m.branding.phone} · ${m.reportId}`, MARGIN, 292);
|
||||
text(`Page ${pg} of ${pages}`, W - MARGIN, 292, { align: 'right' });
|
||||
}
|
||||
|
||||
const filename = `Roof-Options-${(m.contact.name || 'Report').replace(/\s+/g, '-')}-${m.reportId}.pdf`;
|
||||
return { doc, filename, model: m };
|
||||
}
|
||||
|
||||
export async function downloadReportPdf(session, tenantConfig) {
|
||||
const { doc, filename, model } = await generateReportPdf(session, tenantConfig);
|
||||
doc.save(filename);
|
||||
return { filename, reportId: model.reportId };
|
||||
}
|
||||
|
||||
export async function getReportBlobUrl(session, tenantConfig) {
|
||||
const { doc, filename, model } = await generateReportPdf(session, tenantConfig);
|
||||
const url = doc.output('bloburl');
|
||||
return { url, filename, reportId: model.reportId };
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
# Payments Module — Stripe Hosted Checkout
|
||||
|
||||
A **completely independent** payments module using Stripe's official **hosted
|
||||
Checkout** flow. No custom card forms, no card data ever touches this app —
|
||||
the customer enters payment details only on Stripe's secure page.
|
||||
|
||||
> This is **real Stripe integration architecture**, not a simulation. It ships
|
||||
> with credential **placeholders**; the moment real keys are added it goes live.
|
||||
> Until then, the UI shows a clear "configure Stripe" notice.
|
||||
|
||||
## Flow
|
||||
```
|
||||
Customer → Pay Now
|
||||
→ POST /api/payments/create-checkout-session (amount resolved server-side)
|
||||
→ redirect to Stripe-hosted checkout page (session.url)
|
||||
→ customer pays on stripe.com
|
||||
→ Stripe redirects back:
|
||||
success_url → /payments/success?session_id=… (verifies + records)
|
||||
cancel_url → /payments/cancel
|
||||
→ Stripe also POSTs /api/payments/webhook (authoritative record)
|
||||
```
|
||||
|
||||
Statuses handled: **success**, **processing/pending** (async methods),
|
||||
**failed**, and **cancelled**.
|
||||
|
||||
## Structure
|
||||
```
|
||||
api/payments/ # Vercel serverless functions (backend)
|
||||
├── create-checkout-session.js # creates the Checkout Session, returns session.url
|
||||
├── session-status.js # verifies a session for the return page
|
||||
├── webhook.js # signature-verified Stripe event receiver
|
||||
└── transactions.js # serves webhook-written history (Vercel KV)
|
||||
|
||||
src/modules/payments/ # frontend (self-contained)
|
||||
├── config/stripeConfig.js # publishable key + isConfigured flag
|
||||
├── services/checkoutService.js # startCheckout() + getSessionStatus()
|
||||
├── context/PaymentProvider.jsx # history (server KV ∪ local), invoices
|
||||
├── data/invoices.js # sample invoices (display only)
|
||||
├── components/ # InvoiceList, TransactionHistory, ConfigNotice
|
||||
├── pages/ # PaymentsPage, PaymentSuccess, PaymentCancelled
|
||||
└── index.js
|
||||
```
|
||||
|
||||
## Routes (registered in `src/App.jsx`)
|
||||
| Path | Access | Purpose |
|
||||
|---|---|---|
|
||||
| `/portal/payments` | CUSTOMER | Invoice list + history |
|
||||
| `/payments/success` | public | Stripe success return (verifies session) |
|
||||
| `/payments/cancel` | public | Stripe cancel return |
|
||||
|
||||
The return pages are **public** on purpose: the full-page round-trip to Stripe
|
||||
loses the app's in-memory auth session, so the return pages must render without
|
||||
it. (With persisted/JWT auth in production this is seamless.)
|
||||
|
||||
## Configuration
|
||||
Add to your environment (see repo `.env.example`):
|
||||
```
|
||||
VITE_STRIPE_PUBLISHABLE_KEY=pk_test_… # frontend (safe to expose)
|
||||
STRIPE_SECRET_KEY=sk_test_… # backend only — never expose
|
||||
STRIPE_WEBHOOK_SECRET=whsec_… # backend only — webhook signature
|
||||
```
|
||||
In Vercel: add `STRIPE_SECRET_KEY` + `STRIPE_WEBHOOK_SECRET` as Project env vars,
|
||||
and `VITE_STRIPE_PUBLISHABLE_KEY` as a build-time var.
|
||||
|
||||
### Stripe Dashboard → Webhooks
|
||||
- Endpoint: `https://<your-app>/api/payments/webhook`
|
||||
- Events: `checkout.session.completed`, `checkout.session.async_payment_succeeded`,
|
||||
`checkout.session.async_payment_failed`, `checkout.session.expired`
|
||||
|
||||
### Persistence
|
||||
The webhook writes records to **Vercel KV** when connected (same optional pattern
|
||||
as `api/hail-webhook.js`); without KV it logs and the frontend falls back to its
|
||||
local record of the customer's own completed payments.
|
||||
|
||||
## Local testing
|
||||
```
|
||||
npm i # installs stripe + @stripe/stripe-js
|
||||
# put test keys in .env, then:
|
||||
vercel dev # runs the /api functions for real
|
||||
# webhook:
|
||||
stripe listen --forward-to localhost:3000/api/payments/webhook
|
||||
```
|
||||
`npm run dev` also bridges `create-checkout-session` and `session-status` via the
|
||||
existing Vite dev-API plugin (needs test keys in `.env`).
|
||||
|
||||
## Dependencies added
|
||||
- `@stripe/stripe-js` (frontend redirect fallback)
|
||||
- `stripe` (backend Node SDK)
|
||||
@@ -0,0 +1,142 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { FileText, Clock, Lock, Loader2, CheckCircle2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { SpotlightCard } from '../../../components/SpotlightCard';
|
||||
import { formatAmount } from '../data/invoices';
|
||||
import { stripeConfig } from '../config/stripeConfig';
|
||||
import { startCheckout } from '../services/checkoutService';
|
||||
|
||||
/**
|
||||
* Lists outstanding invoices with a "Pay Now" button that redirects the
|
||||
* customer to Stripe's hosted Checkout page. No card details are collected here.
|
||||
*/
|
||||
const InvoiceList = ({ invoices, paidIds = [], customerEmail }) => {
|
||||
const navigate = useNavigate();
|
||||
const [redirectingId, setRedirectingId] = useState(null);
|
||||
|
||||
const isPaid = (inv) => inv.status === 'paid' || paidIds.includes(inv.id);
|
||||
const due = invoices.filter((i) => !isPaid(i));
|
||||
const paid = invoices.filter(isPaid);
|
||||
|
||||
const handlePay = async (invoice) => {
|
||||
if (redirectingId) return;
|
||||
setRedirectingId(invoice.id);
|
||||
|
||||
// No Stripe keys → use the simulated hosted-checkout page so the demo
|
||||
// works without credentials. With keys, redirect to real Stripe.
|
||||
if (!stripeConfig.isConfigured) {
|
||||
navigate(`/payments/demo-checkout?invoice=${encodeURIComponent(invoice.id)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// On success this navigates away to Stripe; control won't return.
|
||||
await startCheckout({
|
||||
invoiceId: invoice.id,
|
||||
customerEmail,
|
||||
fallback: { title: invoice.title, amount: invoice.amount, currency: invoice.currency },
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error('Unable to start checkout', { description: err.message });
|
||||
setRedirectingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<section>
|
||||
<h3 className="text-lg font-bold mb-4 flex items-center text-zinc-800 dark:text-zinc-200">
|
||||
<Clock className="mr-2 text-blue-500" size={20} /> Outstanding Invoices
|
||||
</h3>
|
||||
|
||||
{due.length > 0 ? (
|
||||
<div className="grid gap-4">
|
||||
{due.map((inv) => {
|
||||
const busy = redirectingId === inv.id;
|
||||
return (
|
||||
<SpotlightCard key={inv.id} className="p-5 md:p-6">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div className="flex items-start gap-4 min-w-0">
|
||||
<div className="w-11 h-11 rounded-xl bg-blue-50 dark:bg-blue-900/20 flex items-center justify-center text-blue-600 dark:text-blue-400 border border-blue-100 dark:border-blue-500/20 shrink-0">
|
||||
<FileText size={20} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="font-bold truncate">{inv.title}</h4>
|
||||
<span className="text-[10px] font-mono text-zinc-400 shrink-0">
|
||||
{inv.id}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1 line-clamp-2">
|
||||
{inv.description}
|
||||
</p>
|
||||
<p className="text-xs text-zinc-400 mt-1">
|
||||
Due {new Date(inv.dueDate).toLocaleDateString()} · {inv.property}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between md:flex-col md:items-end gap-3 md:gap-2 shrink-0">
|
||||
<span className="text-xl font-black text-zinc-900 dark:text-white">
|
||||
{formatAmount(inv.amount, inv.currency, stripeConfig.locale)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handlePay(inv)}
|
||||
disabled={busy}
|
||||
className="flex items-center gap-1.5 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-bold shadow-lg shadow-blue-500/20 transition-all disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
{busy ? (
|
||||
<><Loader2 className="animate-spin" size={15} /> Redirecting…</>
|
||||
) : (
|
||||
<><Lock size={14} /> Pay Now</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</SpotlightCard>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-8 text-center border border-dashed border-zinc-300 dark:border-zinc-700 rounded-xl text-zinc-500">
|
||||
You're all caught up — no outstanding invoices. 🎉
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{paid.length > 0 && (
|
||||
<section>
|
||||
<h3 className="text-lg font-bold mb-4 flex items-center text-zinc-800 dark:text-zinc-200">
|
||||
<CheckCircle2 className="mr-2 text-emerald-500" size={20} /> Paid
|
||||
</h3>
|
||||
<div className="grid gap-3">
|
||||
{paid.map((inv) => (
|
||||
<div
|
||||
key={inv.id}
|
||||
className="flex items-center justify-between p-4 rounded-xl bg-zinc-50 dark:bg-zinc-900/40 border border-zinc-200 dark:border-white/5 opacity-80"
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<CheckCircle2 size={18} className="text-emerald-500 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{inv.title}</p>
|
||||
<p className="text-[11px] font-mono text-zinc-400">{inv.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm font-bold text-zinc-500 dark:text-zinc-400 shrink-0">
|
||||
{formatAmount(inv.amount, inv.currency, stripeConfig.locale)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<p className="flex items-center justify-center gap-1.5 text-[11px] text-zinc-400 pt-2">
|
||||
<Lock size={12} /> Payments are processed securely by Stripe. We never see or store your card details.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InvoiceList;
|
||||
@@ -0,0 +1,156 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { AlertCircle, CalendarClock, FileText, Lock, Loader2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { toast } from 'sonner';
|
||||
import { SpotlightCard } from '../../../components/SpotlightCard';
|
||||
import { formatAmount } from '../data/invoices';
|
||||
import { stripeConfig } from '../config/stripeConfig';
|
||||
import { startCheckout } from '../services/checkoutService';
|
||||
|
||||
/**
|
||||
* Outstanding tab — summarizes everything the customer still owes: a headline
|
||||
* total-due figure plus the list of unpaid invoices, with overdue ones flagged.
|
||||
* Reads the same invoice/paid data as the "Make a Payment" tab so the two views
|
||||
* never disagree.
|
||||
*/
|
||||
const OutstandingPanel = ({ invoices, paidIds = [], customerEmail }) => {
|
||||
const navigate = useNavigate();
|
||||
const [redirectingId, setRedirectingId] = useState(null);
|
||||
|
||||
const due = useMemo(
|
||||
() =>
|
||||
invoices
|
||||
.filter((i) => !(i.status === 'paid' || paidIds.includes(i.id)))
|
||||
.sort((a, b) => new Date(a.dueDate) - new Date(b.dueDate)),
|
||||
[invoices, paidIds]
|
||||
);
|
||||
|
||||
// Total owed, assuming all unpaid invoices share a currency (they do here).
|
||||
const totalCents = due.reduce((sum, i) => sum + (i.amount || 0), 0);
|
||||
const currency = due[0]?.currency || 'usd';
|
||||
|
||||
// Midnight today, so an invoice due today is not counted as overdue.
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const isOverdue = (inv) => new Date(inv.dueDate) < today;
|
||||
const overdueCount = due.filter(isOverdue).length;
|
||||
|
||||
const handlePay = async (invoice) => {
|
||||
if (redirectingId) return;
|
||||
setRedirectingId(invoice.id);
|
||||
|
||||
if (!stripeConfig.isConfigured) {
|
||||
navigate(`/payments/demo-checkout?invoice=${encodeURIComponent(invoice.id)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await startCheckout({
|
||||
invoiceId: invoice.id,
|
||||
customerEmail,
|
||||
fallback: { title: invoice.title, amount: invoice.amount, currency: invoice.currency },
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error('Unable to start checkout', { description: err.message });
|
||||
setRedirectingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (due.length === 0) {
|
||||
return (
|
||||
<div className="p-10 text-center border border-dashed border-zinc-300 dark:border-zinc-700 rounded-xl text-zinc-500">
|
||||
You have no outstanding balance — everything is paid up. 🎉
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Total due summary */}
|
||||
<SpotlightCard className="p-6 md:p-8">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs font-bold uppercase tracking-wider text-zinc-500 dark:text-zinc-400">
|
||||
Total Outstanding
|
||||
</p>
|
||||
<p className="text-4xl md:text-5xl font-black text-zinc-900 dark:text-white mt-1">
|
||||
{formatAmount(totalCents, currency, stripeConfig.locale)}
|
||||
</p>
|
||||
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-2">
|
||||
Across {due.length} invoice{due.length === 1 ? '' : 's'}
|
||||
{overdueCount > 0 && (
|
||||
<span className="text-red-500 dark:text-red-400 font-semibold">
|
||||
{' '}· {overdueCount} overdue
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-16 h-16 rounded-full bg-amber-50 dark:bg-amber-900/20 flex items-center justify-center text-amber-500 border border-amber-100 dark:border-amber-500/20 shrink-0">
|
||||
<CalendarClock size={30} />
|
||||
</div>
|
||||
</div>
|
||||
</SpotlightCard>
|
||||
|
||||
{/* Outstanding invoice list */}
|
||||
<div className="grid gap-4">
|
||||
{due.map((inv) => {
|
||||
const busy = redirectingId === inv.id;
|
||||
const overdue = isOverdue(inv);
|
||||
return (
|
||||
<SpotlightCard key={inv.id} className="p-5 md:p-6">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div className="flex items-start gap-4 min-w-0">
|
||||
<div className="w-11 h-11 rounded-xl bg-blue-50 dark:bg-blue-900/20 flex items-center justify-center text-blue-600 dark:text-blue-400 border border-blue-100 dark:border-blue-500/20 shrink-0">
|
||||
<FileText size={20} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="font-bold truncate">{inv.title}</h4>
|
||||
<span className="text-[10px] font-mono text-zinc-400 shrink-0">
|
||||
{inv.id}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1 line-clamp-2">
|
||||
{inv.description}
|
||||
</p>
|
||||
<p
|
||||
className={`text-xs mt-1 flex items-center gap-1 ${
|
||||
overdue
|
||||
? 'text-red-500 dark:text-red-400 font-semibold'
|
||||
: 'text-zinc-400'
|
||||
}`}
|
||||
>
|
||||
{overdue && <AlertCircle size={12} />}
|
||||
{overdue ? 'Overdue · ' : 'Due '}
|
||||
{new Date(inv.dueDate).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between md:flex-col md:items-end gap-3 md:gap-2 shrink-0">
|
||||
<span className="text-xl font-black text-zinc-900 dark:text-white">
|
||||
{formatAmount(inv.amount, inv.currency, stripeConfig.locale)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handlePay(inv)}
|
||||
disabled={busy}
|
||||
className="flex items-center gap-1.5 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-bold shadow-lg shadow-blue-500/20 transition-all disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
{busy ? (
|
||||
<><Loader2 className="animate-spin" size={15} /> Redirecting…</>
|
||||
) : (
|
||||
<><Lock size={14} /> Pay Now</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</SpotlightCard>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OutstandingPanel;
|
||||
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import { FlaskConical } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* Shown when no real Stripe publishable key is configured. Payments run through
|
||||
* the simulated checkout (no real charge); adding keys switches to live Stripe.
|
||||
*/
|
||||
const StripeConfigNotice = () => (
|
||||
<div className="rounded-2xl border border-amber-300/60 dark:border-amber-500/30 bg-amber-50 dark:bg-amber-500/10 p-4 md:p-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-amber-400/20 text-amber-600 dark:text-amber-300 flex items-center justify-center shrink-0">
|
||||
<FlaskConical size={18} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-bold text-amber-800 dark:text-amber-200">
|
||||
Demo mode — simulated Stripe Checkout
|
||||
</p>
|
||||
<p className="text-xs text-amber-700/80 dark:text-amber-200/70 mt-1 leading-relaxed">
|
||||
No Stripe keys are configured, so "Pay Now" runs a simulated checkout
|
||||
(no real charge). Add the keys below to switch to live Stripe-hosted
|
||||
Checkout — no code changes needed:
|
||||
</p>
|
||||
<ul className="mt-2 space-y-1 text-[11px] font-mono text-amber-800 dark:text-amber-200/90">
|
||||
<li>VITE_STRIPE_PUBLISHABLE_KEY <span className="opacity-60">(frontend)</span></li>
|
||||
<li>STRIPE_SECRET_KEY <span className="opacity-60">(backend)</span></li>
|
||||
<li>STRIPE_WEBHOOK_SECRET <span className="opacity-60">(backend / webhook)</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default StripeConfigNotice;
|
||||
@@ -0,0 +1,73 @@
|
||||
import React from 'react';
|
||||
import { Receipt, CreditCard, XCircle } from 'lucide-react';
|
||||
import { SpotlightCard } from '../../../components/SpotlightCard';
|
||||
import { stripeConfig } from '../config/stripeConfig';
|
||||
import { formatAmount } from '../data/invoices';
|
||||
|
||||
const STATUS_STYLES = {
|
||||
paid: {
|
||||
label: 'Paid',
|
||||
chip: 'bg-emerald-100 dark:bg-emerald-500/20 text-emerald-700 dark:text-emerald-300',
|
||||
icon: CreditCard,
|
||||
iconWrap: 'bg-emerald-50 dark:bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-100 dark:border-emerald-500/20',
|
||||
},
|
||||
failed: {
|
||||
label: 'Failed',
|
||||
chip: 'bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-300',
|
||||
icon: XCircle,
|
||||
iconWrap: 'bg-red-50 dark:bg-red-500/10 text-red-600 dark:text-red-400 border-red-100 dark:border-red-500/20',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Lists payment records (merged server + local history). Read-only.
|
||||
*/
|
||||
const TransactionHistory = ({ transactions }) => {
|
||||
if (!transactions.length) {
|
||||
return (
|
||||
<div className="p-10 text-center border border-dashed border-zinc-300 dark:border-zinc-700 rounded-xl text-zinc-500">
|
||||
<Receipt size={28} className="mx-auto mb-3 opacity-50" />
|
||||
No transactions yet. Completed payments will appear here.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
{transactions.map((t) => {
|
||||
const style = STATUS_STYLES[t.status] || STATUS_STYLES.paid;
|
||||
const Icon = style.icon;
|
||||
return (
|
||||
<SpotlightCard key={t.id} className="p-5">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4 min-w-0">
|
||||
<div className={`w-11 h-11 rounded-xl flex items-center justify-center border shrink-0 ${style.iconWrap}`}>
|
||||
<Icon size={18} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h4 className="font-bold truncate">{t.invoiceId || 'Payment'}</h4>
|
||||
<p className="text-xs text-zinc-500 dark:text-zinc-400 mt-0.5">
|
||||
{t.customerEmail ? `${t.customerEmail} · ` : ''}
|
||||
{t.createdAt ? new Date(t.createdAt).toLocaleString() : ''}
|
||||
</p>
|
||||
<p className="text-[10px] font-mono text-zinc-400 mt-0.5 truncate">{t.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-right shrink-0">
|
||||
<span className="block font-black text-zinc-900 dark:text-white">
|
||||
{formatAmount(t.amount, t.currency || stripeConfig.currency, stripeConfig.locale)}
|
||||
</span>
|
||||
<span className={`inline-block mt-1 px-2.5 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wide ${style.chip}`}>
|
||||
{style.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</SpotlightCard>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TransactionHistory;
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Stripe configuration (frontend) for the Payments module.
|
||||
*
|
||||
* Only the PUBLISHABLE key lives on the frontend — it is safe to expose. The
|
||||
* Secret Key and Webhook Secret are backend-only (see /api/payments/*).
|
||||
*
|
||||
* Set in `.env` (see .env.example):
|
||||
* VITE_STRIPE_PUBLISHABLE_KEY=pk_test_… or pk_live_…
|
||||
*
|
||||
* Until a real key is provided, `isConfigured` is false and the UI shows a
|
||||
* clear "configure Stripe" notice rather than attempting a broken redirect.
|
||||
*/
|
||||
|
||||
const PLACEHOLDER = 'pk_test_REPLACE_WITH_YOUR_PUBLISHABLE_KEY';
|
||||
|
||||
const publishableKey = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY || PLACEHOLDER;
|
||||
|
||||
export const stripeConfig = {
|
||||
publishableKey,
|
||||
|
||||
// Base path for the payment API endpoints (Vercel serverless functions).
|
||||
apiBase: '/api/payments',
|
||||
|
||||
// True only when a real publishable key is present.
|
||||
isConfigured:
|
||||
typeof publishableKey === 'string' &&
|
||||
publishableKey.startsWith('pk_') &&
|
||||
publishableKey !== PLACEHOLDER,
|
||||
|
||||
currency: 'usd',
|
||||
locale: 'en-US',
|
||||
|
||||
merchant: {
|
||||
name: 'LynkedUp Pro Roofing',
|
||||
supportEmail: 'billing@lynkeduppro.com',
|
||||
},
|
||||
};
|
||||
|
||||
export default stripeConfig;
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* PaymentProvider — module-local state for the customer Payments portal.
|
||||
*
|
||||
* Invoices and transactions are sourced from the shared paymentStore (localStorage)
|
||||
* so the customer sees payment requests created on the owner side, and payments
|
||||
* made here flow back to the owner's Payment Management page. The server-side
|
||||
* (webhook-written) transaction history is merged in on top when available.
|
||||
*/
|
||||
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||
import { stripeConfig } from '../config/stripeConfig';
|
||||
import {
|
||||
loadInvoices,
|
||||
loadTransactions,
|
||||
saveTransactions,
|
||||
subscribe,
|
||||
} from '../data/paymentStore';
|
||||
|
||||
const mergeById = (a, b) => {
|
||||
const map = new Map();
|
||||
[...a, ...b].forEach((t) => {
|
||||
if (t && t.id) map.set(t.id, { ...map.get(t.id), ...t });
|
||||
});
|
||||
return Array.from(map.values()).sort(
|
||||
(x, y) => new Date(y.createdAt || 0) - new Date(x.createdAt || 0)
|
||||
);
|
||||
};
|
||||
|
||||
const PaymentContext = createContext(null);
|
||||
|
||||
export const PaymentProvider = ({ children }) => {
|
||||
const [invoices, setInvoices] = useState(loadInvoices);
|
||||
const [transactions, setTransactions] = useState(loadTransactions);
|
||||
|
||||
// Re-read from the shared store whenever the owner adds an invoice, marks
|
||||
// one paid, or a payment is recorded (same-tab and cross-tab).
|
||||
useEffect(
|
||||
() =>
|
||||
subscribe(() => {
|
||||
setInvoices(loadInvoices());
|
||||
setTransactions((prev) => mergeById(prev, loadTransactions()));
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
// Pull the server-side (webhook-written) history and merge it in.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(`${stripeConfig.apiBase}/transactions`);
|
||||
if (!res.ok) return;
|
||||
const { transactions: server = [] } = await res.json();
|
||||
if (cancelled || !server.length) return;
|
||||
setTransactions((prev) => mergeById(prev, server));
|
||||
} catch {
|
||||
/* offline / endpoint unavailable — local history is fine. */
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
/** Record a transaction confirmed on the success return page. */
|
||||
const recordTransaction = useCallback((txn) => {
|
||||
if (!txn?.id) return;
|
||||
setTransactions((prev) => {
|
||||
const next = mergeById(prev, [txn]);
|
||||
saveTransactions(next); // persists to the shared store + notifies the owner side
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const value = { invoices, transactions, recordTransaction };
|
||||
|
||||
return <PaymentContext.Provider value={value}>{children}</PaymentContext.Provider>;
|
||||
};
|
||||
|
||||
export const usePayments = () => {
|
||||
const ctx = useContext(PaymentContext);
|
||||
if (!ctx) throw new Error('usePayments must be used within a <PaymentProvider>.');
|
||||
return ctx;
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Sample invoices for the customer to pay (display only). Amounts are stored in
|
||||
* cents to match Stripe's API. The SERVER independently re-resolves the amount
|
||||
* from its own copy (see api/payments/create-checkout-session.js) so the client
|
||||
* can never alter what is charged — these values are for rendering the list.
|
||||
*
|
||||
* In production this list would come from the CRM/billing backend.
|
||||
*/
|
||||
|
||||
export const INVOICES = [
|
||||
{
|
||||
id: 'INV-2041',
|
||||
title: 'Roof Repair — Leak Detection & Shingle Replacement',
|
||||
description: 'Emergency leak repair on north-facing slope, 12 shingles replaced.',
|
||||
property: '2612 Dunwick Dr, Plano, TX 75023',
|
||||
dueDate: '2026-06-15',
|
||||
amount: 184500,
|
||||
currency: 'usd',
|
||||
},
|
||||
{
|
||||
id: 'INV-2039',
|
||||
title: 'Full Roof Inspection & Storm Damage Report',
|
||||
description: 'Comprehensive drone inspection with insurance-ready documentation.',
|
||||
property: '2612 Dunwick Dr, Plano, TX 75023',
|
||||
dueDate: '2026-05-25',
|
||||
amount: 32500,
|
||||
currency: 'usd',
|
||||
},
|
||||
{
|
||||
id: 'INV-2012',
|
||||
title: 'Gutter Cleaning & Maintenance Plan (Q1)',
|
||||
description: 'Quarterly gutter clearing and downspout flush.',
|
||||
property: '2612 Dunwick Dr, Plano, TX 75023',
|
||||
dueDate: '2026-03-18',
|
||||
amount: 14900,
|
||||
currency: 'usd',
|
||||
},
|
||||
{
|
||||
id: 'INV-2055',
|
||||
title: 'Siding Replacement — West Wall',
|
||||
description: 'Storm-damaged vinyl siding replacement, 3 panels.',
|
||||
property: '2612 Dunwick Dr, Plano, TX 75023',
|
||||
dueDate: '2026-07-10',
|
||||
amount: 95000,
|
||||
currency: 'usd',
|
||||
},
|
||||
{
|
||||
id: 'INV-2061',
|
||||
title: 'Chimney Flashing Repair & Sealant',
|
||||
description: 'Re-flashed chimney base and applied weatherproof sealant.',
|
||||
property: '2612 Dunwick Dr, Plano, TX 75023',
|
||||
dueDate: '2026-05-30',
|
||||
amount: 42500,
|
||||
currency: 'usd',
|
||||
},
|
||||
{
|
||||
id: 'INV-2063',
|
||||
title: 'Skylight Installation — Living Room',
|
||||
description: 'Supply and install one energy-efficient skylight unit.',
|
||||
property: '2612 Dunwick Dr, Plano, TX 75023',
|
||||
dueDate: '2026-06-28',
|
||||
amount: 128000,
|
||||
currency: 'usd',
|
||||
},
|
||||
{
|
||||
id: 'INV-2067',
|
||||
title: 'Annual Roof Maintenance Plan',
|
||||
description: 'Twice-yearly inspection and minor repair coverage.',
|
||||
property: '2612 Dunwick Dr, Plano, TX 75023',
|
||||
dueDate: '2026-08-01',
|
||||
amount: 56000,
|
||||
currency: 'usd',
|
||||
},
|
||||
];
|
||||
|
||||
/** Format an integer cent amount into a localized currency string. */
|
||||
export const formatAmount = (cents, currency = 'usd', locale = 'en-US') =>
|
||||
new Intl.NumberFormat(locale, {
|
||||
style: 'currency',
|
||||
currency: (currency || 'usd').toUpperCase(),
|
||||
}).format((cents || 0) / 100);
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Shared payment store — the single source of truth that lets the OWNER/ADMIN
|
||||
* side and the CUSTOMER side talk to each other in this demo (no backend).
|
||||
*
|
||||
* Both invoices and transactions live in localStorage so a payment request the
|
||||
* owner creates appears in the customer's Outstanding list, and a payment the
|
||||
* customer makes (or the owner marks manually) shows back on the owner's
|
||||
* Payment Management page. In production these reads/writes would hit the CRM
|
||||
* billing API instead — the component surface would stay the same.
|
||||
*/
|
||||
|
||||
import { INVOICES } from './invoices';
|
||||
|
||||
const INVOICES_KEY = 'lynkeduppro.payments.invoices';
|
||||
const TXNS_KEY = 'lynkeduppro.payments.transactions';
|
||||
const UPDATE_EVENT = 'payments:updated';
|
||||
|
||||
/**
|
||||
* Default customer mobile number for the demo. The owner/admin "New Payment
|
||||
* Request" form prefills this so a request lands with the current demo
|
||||
* customer. In production this comes from a customer lookup instead.
|
||||
*/
|
||||
export const DEMO_CUSTOMER_PHONE = '214-555-2600';
|
||||
|
||||
/** Compare two phone numbers by digits only (ignores spaces, dashes, etc.). */
|
||||
export const normalizePhone = (phone) => String(phone || '').replace(/\D/g, '');
|
||||
|
||||
/**
|
||||
* Does this invoice belong to the given customer phone? Invoices without a
|
||||
* `customerPhone` are treated as unassigned and visible to any customer (keeps
|
||||
* the original seed invoices working).
|
||||
*/
|
||||
export const invoiceMatchesCustomer = (invoice, phone) =>
|
||||
!invoice.customerPhone ||
|
||||
normalizePhone(invoice.customerPhone) === normalizePhone(phone);
|
||||
|
||||
const read = (key, fallback) => {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
return raw ? JSON.parse(raw) : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
|
||||
const write = (key, value) => {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
} catch {
|
||||
/* localStorage unavailable (private mode) — best effort only. */
|
||||
}
|
||||
};
|
||||
|
||||
/** Notify same-tab listeners (the native `storage` event only fires cross-tab). */
|
||||
const emit = () => {
|
||||
try {
|
||||
window.dispatchEvent(new Event(UPDATE_EVENT));
|
||||
} catch {
|
||||
/* non-browser / SSR — nothing to notify. */
|
||||
}
|
||||
};
|
||||
|
||||
/** Subscribe to any invoice/transaction change. Returns an unsubscribe fn. */
|
||||
export const subscribe = (callback) => {
|
||||
window.addEventListener(UPDATE_EVENT, callback);
|
||||
window.addEventListener('storage', callback);
|
||||
return () => {
|
||||
window.removeEventListener(UPDATE_EVENT, callback);
|
||||
window.removeEventListener('storage', callback);
|
||||
};
|
||||
};
|
||||
|
||||
/* ------------------------------- Invoices -------------------------------- */
|
||||
|
||||
/** Load invoices, seeding from the static sample list on first run. */
|
||||
export const loadInvoices = () => {
|
||||
const stored = read(INVOICES_KEY, null);
|
||||
if (Array.isArray(stored)) return stored;
|
||||
write(INVOICES_KEY, INVOICES);
|
||||
return INVOICES;
|
||||
};
|
||||
|
||||
export const saveInvoices = (list) => {
|
||||
write(INVOICES_KEY, list);
|
||||
emit();
|
||||
};
|
||||
|
||||
export const getInvoice = (id) => loadInvoices().find((i) => i.id === id) || null;
|
||||
|
||||
/** Append a new invoice (payment request) and return it. */
|
||||
export const addInvoice = (invoice) => {
|
||||
const next = [...loadInvoices(), invoice];
|
||||
saveInvoices(next);
|
||||
return invoice;
|
||||
};
|
||||
|
||||
/** Next sequential invoice id, e.g. "INV-2068". */
|
||||
export const nextInvoiceId = () => {
|
||||
const max = loadInvoices().reduce((m, inv) => {
|
||||
const n = parseInt(String(inv.id).replace(/\D/g, ''), 10);
|
||||
return Number.isFinite(n) && n > m ? n : m;
|
||||
}, 2000);
|
||||
return `INV-${max + 1}`;
|
||||
};
|
||||
|
||||
/* ----------------------------- Transactions ------------------------------ */
|
||||
|
||||
export const loadTransactions = () => read(TXNS_KEY, []);
|
||||
|
||||
export const saveTransactions = (list) => {
|
||||
write(TXNS_KEY, list);
|
||||
emit();
|
||||
};
|
||||
|
||||
/** Has this invoice been paid (by the customer or marked by the owner)? */
|
||||
export const isInvoicePaid = (invoiceId) =>
|
||||
loadTransactions().some((t) => t.invoiceId === invoiceId && t.status === 'paid');
|
||||
|
||||
/**
|
||||
* Mark an invoice paid by writing a `paid` transaction — used both by the
|
||||
* customer success flow and by the owner's manual "Mark as Paid" action.
|
||||
* No-ops if a paid transaction for this invoice already exists.
|
||||
*/
|
||||
export const markInvoicePaid = (invoiceId, { method = 'manual' } = {}) => {
|
||||
const inv = getInvoice(invoiceId);
|
||||
if (!inv || isInvoicePaid(invoiceId)) return null;
|
||||
|
||||
const txn = {
|
||||
id: `${method}-${invoiceId}-${Date.now()}`,
|
||||
invoiceId,
|
||||
amount: inv.amount,
|
||||
currency: inv.currency,
|
||||
customerEmail: null,
|
||||
status: 'paid',
|
||||
createdAt: new Date().toISOString(),
|
||||
method,
|
||||
};
|
||||
saveTransactions([txn, ...loadTransactions()]);
|
||||
return txn;
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Payments Module — public surface.
|
||||
*
|
||||
* Self-contained Stripe Checkout integration. Import from here so the rest of
|
||||
* the app never reaches into the module internals.
|
||||
*/
|
||||
|
||||
export { default as PaymentsPage } from './pages/PaymentsPage';
|
||||
export { default as PaymentSuccess } from './pages/PaymentSuccess';
|
||||
export { default as PaymentCancelled } from './pages/PaymentCancelled';
|
||||
export { default as DemoCheckout } from './pages/DemoCheckout';
|
||||
export { PaymentProvider, usePayments } from './context/PaymentProvider';
|
||||
export { stripeConfig } from './config/stripeConfig';
|
||||
@@ -0,0 +1,130 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { Lock, Loader2, ArrowLeft, CheckCircle2 } from 'lucide-react';
|
||||
import { formatAmount } from '../data/invoices';
|
||||
import { getInvoice } from '../data/paymentStore';
|
||||
import { stripeConfig } from '../config/stripeConfig';
|
||||
|
||||
/**
|
||||
* Demo-only stand-in for Stripe's hosted Checkout page. Used ONLY when no
|
||||
* Stripe keys are configured, so the flow (Pay Now → checkout → success →
|
||||
* history) works for a demo without credentials. It collects no real card data
|
||||
* and makes no charge. When real keys are added, this page is never reached —
|
||||
* `startCheckout` redirects to the genuine Stripe-hosted page instead.
|
||||
*
|
||||
* Mirrors Stripe Checkout's two-column hosted layout for visual familiarity.
|
||||
*/
|
||||
const DemoCheckout = () => {
|
||||
const [params] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const invoiceId = params.get('invoice');
|
||||
const invoice = getInvoice(invoiceId);
|
||||
|
||||
const [paying, setPaying] = useState(false);
|
||||
|
||||
if (!invoice) {
|
||||
return (
|
||||
<Shell>
|
||||
<p className="text-center text-zinc-500">Invoice not found.</p>
|
||||
<BackButton onClick={() => navigate('/portal/payments')} />
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
const amountLabel = formatAmount(invoice.amount, invoice.currency, stripeConfig.locale);
|
||||
|
||||
const handlePay = () => {
|
||||
if (paying) return;
|
||||
setPaying(true);
|
||||
// Simulate Stripe's processing latency, then return to the success page.
|
||||
setTimeout(() => {
|
||||
navigate(
|
||||
`/payments/success?demo=1&invoice=${encodeURIComponent(invoice.id)}` +
|
||||
`&amount=${invoice.amount}¤cy=${invoice.currency}`
|
||||
);
|
||||
}, 1600);
|
||||
};
|
||||
|
||||
return (
|
||||
<Shell>
|
||||
<div className="grid md:grid-cols-2 gap-8">
|
||||
{/* Left: order summary (Stripe-style) */}
|
||||
<div className="md:pr-8 md:border-r border-zinc-200 dark:border-white/10">
|
||||
<p className="text-sm text-zinc-500 dark:text-zinc-400">{stripeConfig.merchant.name}</p>
|
||||
<p className="text-3xl font-black mt-1">{amountLabel}</p>
|
||||
|
||||
<div className="mt-6 flex items-start justify-between gap-4 py-3 border-t border-zinc-100 dark:border-white/5">
|
||||
<div className="min-w-0">
|
||||
<p className="font-semibold truncate">{invoice.title}</p>
|
||||
<p className="text-xs text-zinc-400 mt-0.5">{invoice.id}</p>
|
||||
</div>
|
||||
<span className="font-semibold shrink-0">{amountLabel}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-3 border-t border-zinc-100 dark:border-white/5 text-sm">
|
||||
<span className="text-zinc-500">Total due</span>
|
||||
<span className="font-bold">{amountLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: payment (read-only demo — no real card entry) */}
|
||||
<div>
|
||||
<p className="text-sm font-bold uppercase tracking-widest text-zinc-400 mb-3">
|
||||
Pay with card
|
||||
</p>
|
||||
|
||||
<div className="rounded-xl border border-zinc-200 dark:border-white/10 divide-y divide-zinc-100 dark:divide-white/5 bg-zinc-50 dark:bg-zinc-900/40 text-sm">
|
||||
<Row label="Card number" value="4242 4242 4242 4242" />
|
||||
<div className="grid grid-cols-2 divide-x divide-zinc-100 dark:divide-white/5">
|
||||
<Row label="Expiry" value="12 / 34" />
|
||||
<Row label="CVC" value="123" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handlePay}
|
||||
disabled={paying}
|
||||
className="mt-5 w-full flex items-center justify-center gap-2 px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-xl font-bold shadow-lg shadow-blue-500/20 transition-all disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
{paying ? (
|
||||
<><Loader2 className="animate-spin" size={18} /> Processing…</>
|
||||
) : (
|
||||
<><Lock size={16} /> Pay {amountLabel}</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<p className="flex items-center justify-center gap-1.5 text-[11px] text-zinc-400 mt-3">
|
||||
<Lock size={12} /> Powered by Stripe (simulated)
|
||||
</p>
|
||||
|
||||
{!paying && <BackButton onClick={() => navigate('/payments/cancel?invoice=' + invoice.id)} />}
|
||||
</div>
|
||||
</div>
|
||||
</Shell>
|
||||
);
|
||||
};
|
||||
|
||||
const Shell = ({ children }) => (
|
||||
<div className="min-h-screen bg-zinc-100 dark:bg-[#09090b] text-zinc-900 dark:text-white flex items-center justify-center p-6">
|
||||
<div className="w-full max-w-3xl bg-white dark:bg-zinc-950 rounded-3xl shadow-xl border border-zinc-200 dark:border-white/10 p-6 md:p-10">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const Row = ({ label, value }) => (
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<span className="text-zinc-400 text-xs uppercase tracking-wide">{label}</span>
|
||||
<span className="font-mono">{value}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const BackButton = ({ onClick }) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="mt-4 w-full flex items-center justify-center gap-1.5 text-sm font-medium text-zinc-500 hover:text-zinc-900 dark:hover:text-white transition-colors"
|
||||
>
|
||||
<ArrowLeft size={15} /> Cancel and go back
|
||||
</button>
|
||||
);
|
||||
|
||||
export default DemoCheckout;
|
||||
@@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { Ban, ArrowLeft } from 'lucide-react';
|
||||
import { SpotlightCard } from '../../../components/SpotlightCard';
|
||||
|
||||
/**
|
||||
* Stripe redirects here (cancel_url) when the customer backs out of the hosted
|
||||
* Checkout page without paying. Public route — no charge was made.
|
||||
*/
|
||||
const PaymentCancelled = () => {
|
||||
const [params] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const invoice = params.get('invoice');
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-50 dark:bg-[#09090b] text-zinc-900 dark:text-white flex items-center justify-center p-6">
|
||||
<div className="w-full max-w-lg">
|
||||
<SpotlightCard className="p-8 text-center">
|
||||
<div className="w-16 h-16 mx-auto rounded-full bg-zinc-100 dark:bg-white/5 flex items-center justify-center mb-4">
|
||||
<Ban size={34} className="text-zinc-400" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-black">Payment Cancelled</h2>
|
||||
<p className="text-zinc-500 dark:text-zinc-400 mt-1">
|
||||
No charge was made{invoice ? ` for ${invoice}` : ''}. You can complete the
|
||||
payment whenever you're ready.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => navigate('/portal/payments')}
|
||||
className="mt-7 w-full flex items-center justify-center gap-1.5 px-5 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-xl font-bold shadow-lg shadow-blue-500/20 transition-all"
|
||||
>
|
||||
<ArrowLeft size={16} /> Back to Payments
|
||||
</button>
|
||||
</SpotlightCard>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PaymentCancelled;
|
||||
@@ -0,0 +1,201 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { CheckCircle2, XCircle, Loader2, Clock, ArrowLeft, Receipt } from 'lucide-react';
|
||||
import { SpotlightCard } from '../../../components/SpotlightCard';
|
||||
import { stripeConfig } from '../config/stripeConfig';
|
||||
import { formatAmount } from '../data/invoices';
|
||||
import { getSessionStatus } from '../services/checkoutService';
|
||||
import { usePayments } from '../context/PaymentProvider';
|
||||
|
||||
/**
|
||||
* Stripe redirects here (success_url) after the customer leaves the hosted
|
||||
* Checkout page. We verify the session server-side, then show + record the
|
||||
* result. This route is PUBLIC so it renders even though the in-memory auth
|
||||
* session was lost during the full-page round-trip to Stripe.
|
||||
*
|
||||
* Handled states: verifying → paid | pending (async) | failed | error.
|
||||
*/
|
||||
const PaymentSuccess = () => {
|
||||
const [params] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const { recordTransaction } = usePayments();
|
||||
const sessionId = params.get('session_id');
|
||||
const isDemo = params.get('demo') === '1';
|
||||
|
||||
// Initialize from the URL so the effect never needs a synchronous setState
|
||||
// (which would trigger cascading renders).
|
||||
const [state, setState] = useState(() =>
|
||||
isDemo ? 'paid' : sessionId ? 'verifying' : 'error'
|
||||
); // verifying | paid | pending | failed | error
|
||||
const [details, setDetails] = useState(() =>
|
||||
isDemo
|
||||
? {
|
||||
id: `cs_demo_${Date.now()}`,
|
||||
invoiceId: params.get('invoice'),
|
||||
amountTotal: Number(params.get('amount')) || 0,
|
||||
currency: params.get('currency') || stripeConfig.currency,
|
||||
customerEmail: null,
|
||||
}
|
||||
: null
|
||||
);
|
||||
const [errorMsg, setErrorMsg] = useState(() =>
|
||||
sessionId || isDemo ? '' : 'Missing session reference.'
|
||||
);
|
||||
const recordedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Demo mode: no server to verify against — record the simulated payment.
|
||||
if (isDemo) {
|
||||
if (!recordedRef.current && details) {
|
||||
recordedRef.current = true;
|
||||
recordTransaction({
|
||||
id: details.id,
|
||||
invoiceId: details.invoiceId,
|
||||
amount: details.amountTotal,
|
||||
currency: details.currency,
|
||||
customerEmail: null,
|
||||
status: 'paid',
|
||||
createdAt: new Date().toISOString(),
|
||||
demo: true,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sessionId) return;
|
||||
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const data = await getSessionStatus(sessionId);
|
||||
if (cancelled) return;
|
||||
setDetails(data);
|
||||
|
||||
const paid = data.paymentStatus === 'paid' || data.status === 'complete';
|
||||
if (paid) {
|
||||
setState('paid');
|
||||
if (!recordedRef.current) {
|
||||
recordedRef.current = true;
|
||||
recordTransaction({
|
||||
id: data.id,
|
||||
invoiceId: data.invoiceId,
|
||||
amount: data.amountTotal,
|
||||
currency: data.currency,
|
||||
customerEmail: data.customerEmail,
|
||||
status: 'paid',
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
} else if (data.status === 'open') {
|
||||
// Payment still processing (e.g. async method).
|
||||
setState('pending');
|
||||
} else {
|
||||
setState('failed');
|
||||
}
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
setState('error');
|
||||
setErrorMsg(err.message);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [sessionId, isDemo, details, recordTransaction]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-50 dark:bg-[#09090b] text-zinc-900 dark:text-white flex items-center justify-center p-6">
|
||||
<div className="w-full max-w-lg">
|
||||
<SpotlightCard className="p-8 text-center">
|
||||
{state === 'verifying' && (
|
||||
<Body
|
||||
icon={<Loader2 size={36} className="animate-spin text-blue-500" />}
|
||||
title="Confirming your payment…"
|
||||
subtitle="Verifying with Stripe, one moment."
|
||||
/>
|
||||
)}
|
||||
|
||||
{state === 'pending' && (
|
||||
<Body
|
||||
icon={<Clock size={36} className="text-amber-500" />}
|
||||
title="Payment processing"
|
||||
subtitle="Your payment is being processed. We'll update your history once it settles."
|
||||
/>
|
||||
)}
|
||||
|
||||
{state === 'paid' && details && (
|
||||
<>
|
||||
<Body
|
||||
icon={<CheckCircle2 size={36} className="text-emerald-500" />}
|
||||
title="Payment Successful"
|
||||
subtitle={`${formatAmount(details.amountTotal, details.currency, stripeConfig.locale)} paid to ${stripeConfig.merchant.name}.`}
|
||||
/>
|
||||
<Receipt2 details={details} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{state === 'failed' && (
|
||||
<Body
|
||||
icon={<XCircle size={36} className="text-red-500" />}
|
||||
title="Payment not completed"
|
||||
subtitle="This payment didn't go through. You can try again from your invoices."
|
||||
/>
|
||||
)}
|
||||
|
||||
{state === 'error' && (
|
||||
<Body
|
||||
icon={<XCircle size={36} className="text-red-500" />}
|
||||
title="Couldn't verify payment"
|
||||
subtitle={errorMsg || 'Please check your transaction history in a moment.'}
|
||||
/>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => navigate('/portal/payments')}
|
||||
className="mt-7 w-full flex items-center justify-center gap-1.5 px-5 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-xl font-bold shadow-lg shadow-blue-500/20 transition-all"
|
||||
>
|
||||
<ArrowLeft size={16} /> Back to Payments
|
||||
</button>
|
||||
</SpotlightCard>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Body = ({ icon, title, subtitle }) => (
|
||||
<>
|
||||
<div className="w-16 h-16 mx-auto rounded-full bg-zinc-100 dark:bg-white/5 flex items-center justify-center mb-4">
|
||||
{icon}
|
||||
</div>
|
||||
<h2 className="text-2xl font-black">{title}</h2>
|
||||
<p className="text-zinc-500 dark:text-zinc-400 mt-1">{subtitle}</p>
|
||||
</>
|
||||
);
|
||||
|
||||
const Receipt2 = ({ details }) => {
|
||||
const rows = [
|
||||
{ label: 'Invoice', value: details.invoiceId || '—' },
|
||||
{ label: 'Email', value: details.customerEmail || '—' },
|
||||
{ label: 'Session', value: details.id, mono: true },
|
||||
];
|
||||
return (
|
||||
<div className="mt-6 text-left rounded-2xl border border-zinc-200 dark:border-white/10 divide-y divide-zinc-100 dark:divide-white/5 overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-4 py-3 bg-zinc-50 dark:bg-zinc-900/40 text-xs font-bold uppercase tracking-widest text-zinc-400">
|
||||
<Receipt size={14} /> Receipt
|
||||
</div>
|
||||
{rows.map((r) => (
|
||||
<div key={r.label} className="flex items-start justify-between gap-4 px-4 py-3">
|
||||
<span className="text-xs font-bold uppercase tracking-wide text-zinc-400 shrink-0">
|
||||
{r.label}
|
||||
</span>
|
||||
<span className={`text-sm text-zinc-800 dark:text-zinc-200 text-right break-all ${r.mono ? 'font-mono text-xs' : 'font-medium'}`}>
|
||||
{r.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PaymentSuccess;
|
||||
@@ -0,0 +1,92 @@
|
||||
import React, { useState } from 'react';
|
||||
import { CreditCard } from 'lucide-react';
|
||||
import { useAuth } from '../../../context/AuthContext';
|
||||
import { usePayments } from '../context/PaymentProvider';
|
||||
import { invoiceMatchesCustomer } from '../data/paymentStore';
|
||||
import InvoiceList from '../components/InvoiceList';
|
||||
import OutstandingPanel from '../components/OutstandingPanel';
|
||||
import TransactionHistory from '../components/TransactionHistory';
|
||||
|
||||
const TabButton = ({ id, label, activeTab, onSelect }) => (
|
||||
<button
|
||||
onClick={() => onSelect(id)}
|
||||
className={`px-4 md:px-6 py-3 text-xs md:text-sm font-bold uppercase tracking-wider border-b-2 transition-all whitespace-nowrap ${
|
||||
activeTab === id
|
||||
? 'border-blue-500 text-blue-600 dark:text-blue-400'
|
||||
: 'border-transparent text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
|
||||
/**
|
||||
* Payments module entry point (customer portal). Lists invoices to pay via
|
||||
* Stripe-hosted Checkout and shows transaction history. Shares no state with
|
||||
* the rest of the app beyond reading the logged-in user's email.
|
||||
*/
|
||||
const PaymentsPage = () => {
|
||||
const { user } = useAuth();
|
||||
const { invoices, transactions } = usePayments();
|
||||
const [activeTab, setActiveTab] = useState('pay');
|
||||
|
||||
// Only show invoices addressed to this customer's mobile number (plus any
|
||||
// unassigned seed invoices). Keeps one customer from seeing another's bills.
|
||||
const myInvoices = invoices.filter((inv) => invoiceMatchesCustomer(inv, user?.phone));
|
||||
|
||||
// Invoices marked paid by the (server) transaction history.
|
||||
const paidIds = transactions
|
||||
.filter((t) => t.status === 'paid' && t.invoiceId)
|
||||
.map((t) => t.invoiceId);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-50 dark:bg-[#09090b] text-zinc-900 dark:text-white p-8 pt-24 transition-colors duration-300">
|
||||
<div className="max-w-4xl mx-auto space-y-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center space-x-4 mb-8">
|
||||
<div className="w-16 h-16 rounded-full bg-gradient-to-br from-blue-500 to-indigo-600 flex items-center justify-center text-white shadow-lg">
|
||||
<CreditCard size={28} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-black text-transparent bg-clip-text bg-gradient-to-r from-zinc-900 to-zinc-600 dark:from-white dark:to-white/60">
|
||||
Payments
|
||||
</h1>
|
||||
<p className="text-zinc-500 dark:text-zinc-400">
|
||||
Pay invoices securely via Stripe and review past transactions.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex space-x-1 border-b border-zinc-200 dark:border-zinc-800 mb-6 overflow-x-auto scrollbar-hide">
|
||||
<TabButton id="pay" label="Make a Payment" activeTab={activeTab} onSelect={setActiveTab} />
|
||||
<TabButton id="outstanding" label="Outstanding" activeTab={activeTab} onSelect={setActiveTab} />
|
||||
<TabButton id="history" label="Transaction History" activeTab={activeTab} onSelect={setActiveTab} />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||
{activeTab === 'pay' && (
|
||||
<InvoiceList
|
||||
invoices={myInvoices}
|
||||
paidIds={paidIds}
|
||||
customerEmail={user?.email || user?.username}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'outstanding' && (
|
||||
<OutstandingPanel
|
||||
invoices={myInvoices}
|
||||
paidIds={paidIds}
|
||||
customerEmail={user?.email || user?.username}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'history' && (
|
||||
<TransactionHistory transactions={transactions} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PaymentsPage;
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Checkout service — the only place the frontend talks to Stripe / the payment
|
||||
* API. The UI calls these functions; it never handles card data.
|
||||
*
|
||||
* `startCheckout` follows Stripe's recommended hosted-checkout flow:
|
||||
* 1. Ask our backend to create a Checkout Session (amount resolved server-side).
|
||||
* 2. Redirect the browser to the Stripe-hosted `session.url`.
|
||||
* 3. Stripe handles payment, then redirects back to success_url / cancel_url.
|
||||
*
|
||||
* A Stripe.js `redirectToCheckout({ sessionId })` fallback (which uses the
|
||||
* publishable key) is kept for older API behavior, but `session.url` is the
|
||||
* current default and avoids loading Stripe.js for the redirect.
|
||||
*/
|
||||
|
||||
import { loadStripe } from '@stripe/stripe-js';
|
||||
import { stripeConfig } from '../config/stripeConfig';
|
||||
|
||||
let stripePromise;
|
||||
const getStripe = () => {
|
||||
if (!stripePromise) stripePromise = loadStripe(stripeConfig.publishableKey);
|
||||
return stripePromise;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a session for an invoice and redirect to Stripe's hosted page.
|
||||
* Throws (with a friendly message) if the backend isn't configured/reachable.
|
||||
*/
|
||||
export async function startCheckout({ invoiceId, customerEmail, fallback } = {}) {
|
||||
if (!stripeConfig.isConfigured) {
|
||||
const err = new Error(
|
||||
'Stripe is not configured yet. Add your publishable key to enable payments.'
|
||||
);
|
||||
err.code = 'stripe_not_configured';
|
||||
throw err;
|
||||
}
|
||||
|
||||
const res = await fetch(`${stripeConfig.apiBase}/create-checkout-session`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
// `fallback` carries the invoice's amount/title for demo invoices the
|
||||
// owner created in localStorage (the server's static map won't have them).
|
||||
body: JSON.stringify({ invoiceId, customerEmail, fallback }),
|
||||
});
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || 'Could not start checkout. Please try again.');
|
||||
}
|
||||
|
||||
// Preferred: redirect straight to the Stripe-hosted Checkout URL.
|
||||
if (data.url) {
|
||||
window.location.assign(data.url);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: Stripe.js redirect using the session id + publishable key.
|
||||
const stripe = await getStripe();
|
||||
if (!stripe) throw new Error('Unable to initialize Stripe.');
|
||||
const { error } = await stripe.redirectToCheckout({ sessionId: data.id });
|
||||
if (error) throw new Error(error.message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the verified status of a completed Checkout Session (used by the
|
||||
* success return page). Returns the trimmed status object from the backend.
|
||||
*/
|
||||
export async function getSessionStatus(sessionId) {
|
||||
const res = await fetch(
|
||||
`${stripeConfig.apiBase}/session-status?session_id=${encodeURIComponent(sessionId)}`
|
||||
);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || 'Could not verify payment status.');
|
||||
}
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* PublicAdLanding — the homeowner-facing ad lead-capture page (spec §4 Lead Form
|
||||
* Builder, §9.2 example campaign). Rendered chrome-less (no CRM sidebar) at the
|
||||
* public /ads/lp route, simulating where a Meta/Google/Nextdoor ad click lands.
|
||||
*
|
||||
* On submit it builds a fully-shaped ad_lead (with consent proof + a fresh
|
||||
* speed-to-lead SLA clock) and hands it to the session store; opening
|
||||
* /marketing#inbox afterwards shows it live at the top of the Unified Lead Inbox,
|
||||
* closing the spec's loop (§5). Front-end demo only — no real platform or backend.
|
||||
*/
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useSearchParams, useNavigate, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
ShieldCheck, CheckCircle2, ArrowRight, ArrowLeft, Loader2, Megaphone, Lock, Phone,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { BLUEPRINT_BY_ID, BLUEPRINTS } from './data/blueprints.js';
|
||||
import { getPlatform } from './data/platforms.js';
|
||||
import { addPublicLead } from './data/sessionStore.js';
|
||||
|
||||
const CONSENT_TEXT =
|
||||
'By submitting, I agree LynkedUp Roofing may contact me by call, text, and email about my request. Msg/data rates may apply. Reply STOP to opt out.';
|
||||
const PRIVACY_URL = 'https://lynkeduppro.com/privacy';
|
||||
|
||||
const ROLES = [
|
||||
{ id: 'homeowner', label: 'Homeowner' },
|
||||
{ id: 'tenant', label: 'Tenant' },
|
||||
{ id: 'property_manager', label: 'Property manager' },
|
||||
];
|
||||
|
||||
const SERVICE_OPTIONS = [
|
||||
{ id: 'storm', label: 'Storm / hail damage' },
|
||||
{ id: 'emergency', label: 'Active leak (emergency)' },
|
||||
{ id: 'replacement', label: 'Roof replacement' },
|
||||
{ id: 'commercial', label: 'Commercial / property' },
|
||||
];
|
||||
|
||||
const WINDOWS = ['Morning (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 navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
// Only show the "Back" control when this page was reached via in-app navigation
|
||||
// (i.e. a preview opened from the Ad Engine) — a real ad click lands with no app history.
|
||||
const cameFromApp = location.key !== 'default';
|
||||
|
||||
const blueprint = BLUEPRINT_BY_ID[params.get('c')] || BLUEPRINTS[0];
|
||||
const platformId = getPlatform(params.get('src')) ? params.get('src') : 'meta';
|
||||
const platform = getPlatform(platformId);
|
||||
|
||||
// Resolve merge tokens in the ad copy (e.g. {{City}}) for display.
|
||||
const mergeVars = { city: params.get('city') || '' };
|
||||
// A creative preview can pass its own headline (?hl) and media image (?img).
|
||||
const headline = params.get('hl') || fillTokens(blueprint.adCopy, mergeVars).split('.')[0];
|
||||
const offerText = fillTokens(blueprint.offer, mergeVars);
|
||||
const heroImg = params.get('img') || '';
|
||||
const [imgErr, setImgErr] = useState(false);
|
||||
|
||||
const [form, setForm] = useState({
|
||||
platform: platformId,
|
||||
serviceType: blueprint.serviceType === 'referral' || blueprint.serviceType === 'maintenance' ? 'storm' : blueprint.serviceType,
|
||||
name: '', phone: '', email: '', address: '',
|
||||
role: 'homeowner', activeLeak: false, window: WINDOWS[0], consent: false,
|
||||
});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitted, setSubmitted] = useState(null);
|
||||
|
||||
const set = (patch) => setForm(f => ({ ...f, ...patch }));
|
||||
|
||||
const isEmergencyLike = form.serviceType === 'emergency' || form.serviceType === 'storm';
|
||||
const valid = useMemo(() =>
|
||||
form.name.trim() && form.phone.trim() && form.email.trim() && form.address.trim() && form.consent,
|
||||
[form]);
|
||||
|
||||
const submit = (e) => {
|
||||
e.preventDefault();
|
||||
if (!valid) { toast.error('Please complete the required fields and consent.'); return; }
|
||||
setSubmitting(true);
|
||||
// Simulate the platform → webhook → CRM ingest latency.
|
||||
setTimeout(() => {
|
||||
const lead = addPublicLead(form);
|
||||
setSubmitting(false);
|
||||
setSubmitted(lead);
|
||||
}, 900);
|
||||
};
|
||||
|
||||
if (submitted) {
|
||||
return (
|
||||
<div className="min-h-[100dvh] w-full bg-zinc-50 dark:bg-[#09090b] flex items-center justify-center px-4 py-10">
|
||||
<div className="max-w-md text-center">
|
||||
<CheckCircle2 size={56} className="text-emerald-500 mx-auto mb-4" />
|
||||
<h1 className="text-2xl font-bold text-zinc-900 dark:text-white">
|
||||
Thanks{submitted.name ? `, ${submitted.name.split(' ')[0]}` : ''}!
|
||||
</h1>
|
||||
<p className="text-zinc-500 dark:text-zinc-400 mt-2">
|
||||
We received your request and our team has been notified. A rep will reach out shortly to confirm your free inspection window.
|
||||
</p>
|
||||
<a href="tel:+19725550100"
|
||||
className="mt-6 w-full sm:w-auto inline-flex items-center justify-center gap-1.5 rounded-xl bg-gradient-to-r from-amber-400 to-orange-500 text-white font-semibold px-5 py-3 sm:py-2.5 shadow-lg shadow-amber-500/25">
|
||||
<Phone size={16} /> Need it sooner? Call (972) 555-0100
|
||||
</a>
|
||||
<p className="text-[11px] text-zinc-400 mt-4 flex items-center justify-center gap-1">
|
||||
<ShieldCheck size={12} /> Your information is kept private and used only to contact you about this request.
|
||||
</p>
|
||||
{cameFromApp && (
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="mt-6 inline-flex items-center gap-1.5 text-sm font-semibold text-zinc-600 dark:text-zinc-300 hover:text-zinc-900 dark:hover:text-white transition-colors"
|
||||
>
|
||||
<ArrowLeft size={16} /> Back to Ad Engine
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-[100dvh] w-full bg-zinc-50 dark:bg-[#09090b]">
|
||||
{/* Back to Ad Engine — only when opened as a preview from inside the CRM */}
|
||||
{cameFromApp && (
|
||||
<div className="max-w-2xl mx-auto px-4 pt-4">
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="inline-flex items-center gap-1.5 text-sm font-semibold text-zinc-600 dark:text-zinc-300 hover:text-zinc-900 dark:hover:text-white transition-colors"
|
||||
>
|
||||
<ArrowLeft size={16} /> Back to Ad Engine
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sponsored banner — simulates the ad context */}
|
||||
<div className="w-full bg-zinc-900 text-white/80 text-[11px] py-1.5 px-4 flex items-center justify-center gap-1.5">
|
||||
<Megaphone size={12} />
|
||||
<span>Sponsored · {platform?.name} ad</span>
|
||||
</div>
|
||||
|
||||
<div className="max-w-2xl mx-auto px-4 py-6 sm:py-12">
|
||||
{/* Hero */}
|
||||
<div className="text-center mb-6 sm:mb-7">
|
||||
<div className="inline-flex items-center gap-2 mb-3">
|
||||
<div className="p-2 rounded-xl bg-gradient-to-tr from-amber-400 to-orange-600 text-white shadow-lg shadow-amber-500/25">
|
||||
<Megaphone size={20} />
|
||||
</div>
|
||||
<span className="font-bold text-lg text-zinc-900 dark:text-white">LynkedUp<span className="text-amber-500">Pro</span></span>
|
||||
</div>
|
||||
<h1 className="text-balance text-2xl leading-snug sm:text-3xl font-bold text-zinc-900 dark:text-white">{headline}{/[.?!]$/.test(headline) ? '' : '.'}</h1>
|
||||
<p className="text-zinc-500 dark:text-zinc-400 mt-2 text-sm text-balance">{offerText} · Free, no-obligation.</p>
|
||||
</div>
|
||||
|
||||
{/* Creative media (when previewing a creative with an attached image) */}
|
||||
{heroImg && !imgErr && (
|
||||
<img src={heroImg} alt="" onError={() => setImgErr(true)}
|
||||
className="w-full h-44 sm:h-56 object-cover rounded-2xl mb-6 shadow-lg" />
|
||||
)}
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={submit} className="rounded-2xl border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 p-4 sm:p-6 space-y-4 shadow-xl">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<Field label="Full name" required>
|
||||
<input className={inputCls} value={form.name} onChange={e => set({ name: e.target.value })} placeholder="Jane Homeowner" />
|
||||
</Field>
|
||||
<Field label="Phone" required>
|
||||
<input className={inputCls} value={form.phone} onChange={e => set({ phone: e.target.value })} placeholder="(972) 555-0100" />
|
||||
</Field>
|
||||
<Field label="Email" required>
|
||||
<input className={inputCls} type="email" value={form.email} onChange={e => set({ email: e.target.value })} placeholder="you@example.com" />
|
||||
</Field>
|
||||
<Field label="Property address" required>
|
||||
<input className={inputCls} value={form.address} onChange={e => set({ address: e.target.value })} placeholder="123 Main St, Plano, TX" />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field label="What do you need?">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{SERVICE_OPTIONS.map(s => (
|
||||
<button type="button" key={s.id} onClick={() => set({ serviceType: s.id })}
|
||||
className={`flex items-center justify-center text-center leading-tight min-h-[3rem] px-3 py-2 rounded-xl border-2 text-sm sm:text-xs font-semibold transition-all ${form.serviceType === s.id ? 'border-amber-500 bg-amber-500/5 text-amber-700 dark:text-amber-400' : 'border-zinc-200 dark:border-white/10 text-zinc-600 dark:text-zinc-300'}`}>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<Field label="You are the…">
|
||||
<select className={inputCls} value={form.role} onChange={e => set({ role: e.target.value })}>
|
||||
{ROLES.map(r => <option key={r.id} value={r.id}>{r.label}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Preferred contact window">
|
||||
<select className={inputCls} value={form.window} onChange={e => set({ window: e.target.value })}>
|
||||
{WINDOWS.map(w => <option key={w} value={w}>{w}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{isEmergencyLike && (
|
||||
<label className="flex items-center gap-3 rounded-xl bg-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-500/20 px-3.5 py-3 cursor-pointer">
|
||||
<input type="checkbox" checked={form.activeLeak} onChange={e => set({ activeLeak: e.target.checked })} className="w-5 h-5 shrink-0 accent-red-500" />
|
||||
<span className="text-sm text-zinc-700 dark:text-zinc-200">I have an <b>active leak</b> right now (we'll prioritize this)</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* Consent */}
|
||||
<label className="flex items-start gap-3 cursor-pointer">
|
||||
<input type="checkbox" checked={form.consent} onChange={e => set({ consent: e.target.checked })} className="w-5 h-5 mt-0.5 shrink-0 accent-amber-500" />
|
||||
<span className="text-[11px] text-zinc-500 dark:text-zinc-400 leading-relaxed">
|
||||
{CONSENT_TEXT} See our <a href={PRIVACY_URL} className="text-amber-600 underline">privacy policy</a>.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<button type="submit" disabled={submitting}
|
||||
className="w-full inline-flex items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-amber-400 to-orange-500 text-white font-semibold px-5 py-3 shadow-lg shadow-amber-500/25 disabled:opacity-60">
|
||||
{submitting ? <><Loader2 size={18} className="animate-spin" /> Sending…</> : <>Request my free inspection <ArrowRight size={16} /></>}
|
||||
</button>
|
||||
|
||||
<p className="text-[10px] text-zinc-400 flex items-center justify-center gap-1">
|
||||
<Lock size={11} /> Your info is used only to contact you about this request.
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-[11px] text-zinc-400 mt-4 flex items-center justify-center gap-1">
|
||||
<ShieldCheck size={12} /> Consent & privacy compliant · TCPA / CAN-SPAM aware
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, required, children }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="text-xs font-semibold text-zinc-600 dark:text-zinc-300 mb-1.5 block">
|
||||
{label}{required && <span className="text-red-500"> *</span>}
|
||||
</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,626 @@
|
||||
/**
|
||||
* SocialAdsContext — self-contained client state for the Social Ads Engine module.
|
||||
*
|
||||
* This is the front-end stand-in for the spec's backend entities (§7). All data
|
||||
* lives in React memory (seeded from data/seed.js); actions mutate local state and
|
||||
* fire sonner toasts so the demo feels live without any backend, OAuth, or webhooks.
|
||||
* Kept fully separate from the global MockStore — this is an isolated new module,
|
||||
* exactly like the Estimate Wizard. A reload resets to the seed.
|
||||
*/
|
||||
import React, { createContext, useContext, useState, useCallback, useMemo, useEffect } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
accounts as seedAccounts,
|
||||
campaigns as seedCampaigns,
|
||||
creatives as seedCreatives,
|
||||
leadForms as seedLeadForms,
|
||||
mediaAssets as seedMedia,
|
||||
leads as seedLeads,
|
||||
nurtureRules as seedRules,
|
||||
audiences as seedAudiences,
|
||||
conversations as seedConversations,
|
||||
reviews as seedReviews,
|
||||
journeys as seedJourneys,
|
||||
anomalies as seedAnomalies,
|
||||
consentVersions as seedConsentVersions,
|
||||
conversionEvents as seedConversions,
|
||||
approvals as seedApprovals,
|
||||
analyticsDaily as seedAnalytics,
|
||||
} from './data/seed.js';
|
||||
import { getPublicLeads, subscribeToPublicLeads } from './data/sessionStore.js';
|
||||
import { previewSegment, summarizeFilters } from './data/contactPool.js';
|
||||
|
||||
const SocialAdsContext = createContext(null);
|
||||
|
||||
const nextId = (prefix) => `${prefix}_${Math.floor(performance.now()).toString(36)}`;
|
||||
const nowIso = () => new Date().toISOString();
|
||||
|
||||
// Seed leads + any leads captured on the public landing page this session. The SLA
|
||||
// clock start (slaStartTs) is materialized here from each seed lead's slaSecondsAgo so
|
||||
// the speed-to-lead timers show a realistic mix on load and tick in real time.
|
||||
function initLeads() {
|
||||
const now = Date.now();
|
||||
const seeded = seedLeads.map(l => ({
|
||||
...l,
|
||||
slaStartTs: l.slaStartTs ?? now - (l.slaSecondsAgo || 0) * 1000,
|
||||
}));
|
||||
return [...getPublicLeads(), ...seeded];
|
||||
}
|
||||
|
||||
// Conversion-event mapping for the closed loop (spec §5, §10.1). Module-scoped so the
|
||||
// advanceLead callback stays stable.
|
||||
const STAGE_EVENT = {
|
||||
appointment: { eventName: 'AppointmentScheduled', category: 'Schedule / QualifiedLead' },
|
||||
showed: { eventName: 'AppointmentCompleted', category: 'Show / QualifiedAppointment' },
|
||||
estimate: { eventName: 'EstimateSent', category: 'SubmitApplication' },
|
||||
won: { eventName: 'JobWon', category: 'Purchase / ClosedWon' },
|
||||
};
|
||||
|
||||
export function SocialAdsProvider({ children }) {
|
||||
const [accounts, setAccounts] = useState(seedAccounts);
|
||||
// Backfill default spend guardrails (spec §3/§4) on seed campaigns that predate them.
|
||||
const [campaigns, setCampaigns] = useState(() => seedCampaigns.map(c => ({
|
||||
...c,
|
||||
guardrails: c.guardrails || {
|
||||
weeklyCap: c.budgetDaily * 7, monthlyCap: c.budgetDaily * 30,
|
||||
cplThreshold: 120, alertPct: 80,
|
||||
pauseOnDailyCap: true, pauseOnCplBreach: false, anomalyDetection: true,
|
||||
},
|
||||
})));
|
||||
const [creatives, setCreatives] = useState(seedCreatives);
|
||||
const [mediaAssets, setMediaAssets] = useState(seedMedia);
|
||||
const [leadForms, setLeadForms] = useState(seedLeadForms);
|
||||
const [leads, setLeads] = useState(initLeads);
|
||||
const [nurtureRules, setNurtureRules] = useState(seedRules);
|
||||
const [audiences, setAudiences] = useState(seedAudiences);
|
||||
const [conversations, setConversations] = useState(seedConversations);
|
||||
const [reviews, setReviews] = useState(seedReviews);
|
||||
const [journeys] = useState(seedJourneys);
|
||||
const [anomalies, setAnomalies] = useState(seedAnomalies);
|
||||
const [consentVersions, setConsentVersions] = useState(seedConsentVersions);
|
||||
const [conversionEvents, setConversionEvents] = useState(seedConversions);
|
||||
const [approvals, setApprovals] = useState(seedApprovals);
|
||||
const [analyticsDaily] = useState(seedAnalytics);
|
||||
|
||||
// Live-ingest public leads submitted on /ads/lp (same tab or another, via the
|
||||
// session store's broadcast) — they appear at the top of the inbox immediately,
|
||||
// no CRM reload. Dedup by id guards against re-mount + broadcast double-adds.
|
||||
useEffect(() => subscribeToPublicLeads((lead) => {
|
||||
setLeads(prev => (prev.some(l => l.id === lead.id) ? prev : [lead, ...prev]));
|
||||
if (lead.triage?.urgency === 'high') {
|
||||
toast.error(`New urgent lead: ${lead.name} — speed-to-lead SLA running`);
|
||||
} else {
|
||||
toast.success(`New ad lead: ${lead.name}`);
|
||||
}
|
||||
}), []);
|
||||
|
||||
// ── Connected Accounts ────────────────────────────────────────────────────
|
||||
const toggleAccount = useCallback((id) => {
|
||||
setAccounts(prev => prev.map(a => {
|
||||
if (a.id !== id) return a;
|
||||
const connecting = a.status !== 'connected';
|
||||
toast[connecting ? 'success' : 'message'](
|
||||
connecting ? `Connected ${a.name}` : `Disconnected ${a.name}`,
|
||||
);
|
||||
return connecting
|
||||
? { ...a, status: 'connected', tokenStatus: 'healthy', webhookStatus: 'verified', lastSyncAt: nowIso() }
|
||||
: { ...a, status: 'disconnected', tokenStatus: 'none', webhookStatus: 'not_configured' };
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// Reconnect / refresh a platform token (spec §11 token-expiration recovery).
|
||||
// Restores a healthy token, re-verifies the webhook, and re-grants default scopes
|
||||
// if the prior grant was revoked/empty.
|
||||
const reconnectAccount = useCallback((id) => {
|
||||
setAccounts(prev => prev.map(a => {
|
||||
if (a.id !== id) return a;
|
||||
const expiry = new Date(Date.now() + 90 * 864e5).toISOString().slice(0, 10);
|
||||
return {
|
||||
...a, status: 'connected', tokenStatus: 'healthy', webhookStatus: 'verified',
|
||||
lastSyncAt: nowIso(), tokenExpiresAt: expiry,
|
||||
scopes: a.scopes?.length ? a.scopes : ['ads_read', 'leads_retrieval'],
|
||||
};
|
||||
}));
|
||||
toast.success('Reconnected — token refreshed and permissions re-granted');
|
||||
}, []);
|
||||
|
||||
const testWebhook = useCallback((id) => {
|
||||
const acc = accounts.find(a => a.id === id);
|
||||
if (!acc) return;
|
||||
if (acc.status !== 'connected') { toast.error('Connect the account before testing its webhook.'); return; }
|
||||
toast.promise(new Promise(res => setTimeout(res, 900)), {
|
||||
loading: `Sending test lead to ${acc.short || acc.name}…`,
|
||||
success: 'Test lead received and mapped to CRM fields ✓',
|
||||
error: 'Webhook test failed',
|
||||
});
|
||||
}, [accounts]);
|
||||
|
||||
// ── Campaigns ─────────────────────────────────────────────────────────────
|
||||
const createCampaign = useCallback((draft) => {
|
||||
const camp = { id: nextId('camp'), status: 'draft', approvalStatus: 'pending', ...draft };
|
||||
setCampaigns(prev => [camp, ...prev]);
|
||||
// Route the draft into the Compliance approval queue — it cannot publish until a
|
||||
// reviewer approves this record, which flips campaign.approvalStatus (spec §6/§8).
|
||||
setApprovals(prev => [{
|
||||
id: nextId('apr'), type: 'campaign', refId: camp.id,
|
||||
title: `${camp.name} — publish approval`, status: 'pending',
|
||||
flags: [], submittedAt: nowIso(), reviewer: null,
|
||||
}, ...prev]);
|
||||
toast.success(`Campaign draft "${camp.name}" created — sent to Compliance for approval`);
|
||||
return camp;
|
||||
}, []);
|
||||
|
||||
const setCampaignStatus = useCallback((id, status) => {
|
||||
setCampaigns(prev => prev.map(c => c.id === id ? { ...c, status } : c));
|
||||
toast.message(`Campaign ${status}`);
|
||||
}, []);
|
||||
|
||||
const publishCampaign = useCallback((id) => {
|
||||
const camp = campaigns.find(c => c.id === id);
|
||||
if (!camp) return;
|
||||
if (camp.approvalStatus !== 'approved') {
|
||||
toast.error('Cannot publish — campaign and creatives must be approved first.');
|
||||
return;
|
||||
}
|
||||
setCampaigns(prev => prev.map(c => c.id === id ? { ...c, status: 'active' } : c));
|
||||
toast.success(`"${camp.name}" published`);
|
||||
}, [campaigns]);
|
||||
|
||||
// Manual-publish fallback (spec §5/§11): platforms without a full-publish API are set
|
||||
// up by hand from a generated checklist, then marked live here. Same approval gate.
|
||||
const completeManualSetup = useCallback((id) => {
|
||||
const camp = campaigns.find(c => c.id === id);
|
||||
if (!camp) return;
|
||||
if (camp.approvalStatus !== 'approved') {
|
||||
toast.error('Approve the campaign in Compliance before marking setup complete.');
|
||||
return;
|
||||
}
|
||||
setCampaigns(prev => prev.map(c => c.id === id ? { ...c, status: 'active', manualSetup: true } : c));
|
||||
toast.success(`"${camp.name}" marked live — manual setup complete`);
|
||||
}, [campaigns]);
|
||||
|
||||
// ── Creatives ─────────────────────────────────────────────────────────────
|
||||
const addCreative = useCallback((creative) => {
|
||||
const c = { id: nextId('cre'), approvalStatus: 'pending', versions: [], ...creative };
|
||||
setCreatives(prev => [c, ...prev]);
|
||||
return c;
|
||||
}, []);
|
||||
|
||||
// Snapshot the current copy as a version before applying an edit (spec §7 copy versions).
|
||||
const snapshotVersion = (c) => ({
|
||||
v: (c.versions?.length || 0) + 1,
|
||||
headline: c.headline, body: c.body, description: c.description || '',
|
||||
mediaAssetId: c.mediaAssetId || null,
|
||||
policyStatus: c.policyStatus, policyFlags: c.policyFlags || [],
|
||||
savedAt: nowIso(),
|
||||
});
|
||||
|
||||
const setCreativeApproval = useCallback((id, approvalStatus) => {
|
||||
setCreatives(prev => prev.map(c => c.id === id ? { ...c, approvalStatus } : c));
|
||||
toast[approvalStatus === 'approved' ? 'success' : 'message'](`Creative ${approvalStatus}`);
|
||||
}, []);
|
||||
|
||||
// Manual create/edit (spec §8/B2). Editing copy re-runs the guardrail and resets the
|
||||
// creative to pending approval, since the reviewed text has changed.
|
||||
const updateCreative = useCallback((id, patch) => {
|
||||
setCreatives(prev => prev.map(c => c.id === id
|
||||
? { ...c, ...patch, versions: [...(c.versions || []), snapshotVersion(c)], approvalStatus: 'pending' }
|
||||
: c));
|
||||
toast.success('Creative updated — previous version saved to history');
|
||||
}, []);
|
||||
|
||||
// Revert to a prior version: snapshot the current copy first, then restore the chosen
|
||||
// version. Re-enters approval since the live copy changed (spec §7/§8).
|
||||
const revertCreative = useCallback((id, v) => {
|
||||
setCreatives(prev => prev.map(c => {
|
||||
if (c.id !== id) return c;
|
||||
const target = (c.versions || []).find(x => x.v === v);
|
||||
if (!target) return c;
|
||||
return {
|
||||
...c,
|
||||
headline: target.headline, body: target.body, description: target.description,
|
||||
mediaAssetId: target.mediaAssetId, policyStatus: target.policyStatus, policyFlags: target.policyFlags,
|
||||
versions: [...(c.versions || []), snapshotVersion(c)],
|
||||
approvalStatus: 'pending',
|
||||
};
|
||||
}));
|
||||
toast.success(`Reverted to v${v} — sent back for approval`);
|
||||
}, []);
|
||||
|
||||
// Media library (spec §7 media_asset_id). Uploads add an in-memory object-URL preview.
|
||||
const addMediaAsset = useCallback((asset) => {
|
||||
const a = { id: nextId('med'), type: 'image', ...asset };
|
||||
setMediaAssets(prev => [a, ...prev]);
|
||||
toast.success(`Added “${a.name}” to the media library`);
|
||||
return a;
|
||||
}, []);
|
||||
|
||||
// ── Lead Form Mapper (editable builder, spec §7 lead_forms + field_mappings) ──
|
||||
const updateLeadForm = useCallback((id, patch) => {
|
||||
setLeadForms(prev => prev.map(f => f.id === id ? { ...f, ...patch } : f));
|
||||
}, []);
|
||||
|
||||
const updateFormField = useCallback((formId, source, patch) => {
|
||||
setLeadForms(prev => prev.map(f => f.id === formId
|
||||
? { ...f, fields: f.fields.map(fl => fl.source === source ? { ...fl, ...patch } : fl) }
|
||||
: f));
|
||||
}, []);
|
||||
|
||||
const addFormField = useCallback((formId, field) => {
|
||||
const newField = field
|
||||
? { transform: 'none', required: false, validation: '', ...field }
|
||||
: { source: nextId('field'), target: '', required: false, transform: 'none', validation: '' };
|
||||
setLeadForms(prev => prev.map(f => f.id === formId
|
||||
? { ...f, fields: [...f.fields, newField] }
|
||||
: f));
|
||||
}, []);
|
||||
|
||||
const removeFormField = useCallback((formId, source) => {
|
||||
setLeadForms(prev => prev.map(f => f.id === formId
|
||||
? { ...f, fields: f.fields.filter(fl => fl.source !== source) }
|
||||
: f));
|
||||
}, []);
|
||||
|
||||
const toggleFormStatus = useCallback((id) => {
|
||||
setLeadForms(prev => prev.map(f => {
|
||||
if (f.id !== id) return f;
|
||||
const status = f.status === 'live' ? 'draft' : 'live';
|
||||
toast[status === 'live' ? 'success' : 'message'](`Form set to ${status}`);
|
||||
return { ...f, status };
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const addLeadForm = useCallback((form = {}) => {
|
||||
const f = {
|
||||
id: nextId('form'), campaignId: null, platform: form.platform || 'meta',
|
||||
platformFormId: `manual_${nextId('lf')}`, title: form.title || 'New lead form', status: 'draft',
|
||||
consentText: 'By submitting, I agree LynkedUp Roofing may contact me by call, text, and email about my request. Msg/data rates may apply. Reply STOP to opt out.',
|
||||
privacyPolicyUrl: 'https://lynkeduppro.com/privacy',
|
||||
thankYou: 'We received your request. A rep will reach out shortly to confirm your appointment window.',
|
||||
fields: [
|
||||
{ source: 'full_name', target: 'contact.name', required: true, transform: 'titlecase', validation: '' },
|
||||
{ source: 'phone_number', target: 'contact.phone', required: true, transform: 'e164', validation: 'phone' },
|
||||
{ source: 'email', target: 'contact.email', required: true, transform: 'lowercase', validation: 'email' },
|
||||
],
|
||||
};
|
||||
setLeadForms(prev => [f, ...prev]);
|
||||
toast.success(`Created “${f.title}” (draft)`);
|
||||
return f;
|
||||
}, []);
|
||||
|
||||
// ── Leads (Unified Inbox) ─────────────────────────────────────────────────
|
||||
const assignLead = useCallback((id, repId) => {
|
||||
// Assigning is first permitted contact → record speed-to-lead (spec §10).
|
||||
const speed = 120 + Math.floor(performance.now() % 180); // demo: ~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));
|
||||
}, []);
|
||||
|
||||
// Manual import fallback (spec §11): when a platform webhook/token is down, leads
|
||||
// can be uploaded by CSV instead of being stranded. Imported leads land in the
|
||||
// Unified Lead Inbox; consent must be explicit per row or it's manual-only.
|
||||
const importLeads = useCallback((platform, rows) => {
|
||||
const base = nextId('lead');
|
||||
const newLeads = rows.map((r, i) => ({
|
||||
id: `${base}_${i}`, platform, campaignId: null, formId: null,
|
||||
externalLeadId: `manual_${base}_${i}`, receivedAt: nowIso(), status: 'new', stage: 'lead',
|
||||
name: r.name || 'Imported lead', phone: r.phone || '', email: r.email || '', address: r.address || '',
|
||||
consent: /^(y|yes|true|1)$/i.test(r.consent || ''), role: 'homeowner', activeLeak: false,
|
||||
triage: {
|
||||
serviceType: r.service || 'replacement', urgency: 'medium', role: 'homeowner',
|
||||
intent: 'inquiry', quality: 60, confidence: 0.7,
|
||||
},
|
||||
duplicateOf: null, assignedTo: null, dealValue: 0, premium: false,
|
||||
firstContactSec: null, slaStartTs: Date.now(), manualImport: true,
|
||||
}));
|
||||
setLeads(prev => [...newLeads, ...prev]);
|
||||
toast.success(`Imported ${newLeads.length} lead${newLeads.length > 1 ? 's' : ''} — now in the Lead Inbox`);
|
||||
return newLeads.length;
|
||||
}, []);
|
||||
|
||||
// Low-confidence AI triage (spec §5): instead of auto-nurturing on a shaky
|
||||
// classification, the admin either creates a rep review task or sends one
|
||||
// clarifying question. Either holds the lead from auto-nurture (reviewAction flag).
|
||||
const flagLeadReview = useCallback((id, mode) => {
|
||||
setLeads(prev => prev.map(l => l.id === id ? { ...l, reviewAction: mode } : l));
|
||||
toast.success(mode === 'question'
|
||||
? 'Sent 1 clarifying question — lead held from auto-nurture until answered'
|
||||
: 'Review task created for a rep — lead held from auto-nurture');
|
||||
}, []);
|
||||
|
||||
// ── Conversations / Comment-DM triage (spec §4, §6) ────────────────────────
|
||||
// Route a triaged comment/DM to the right human/queue. Criticism is never
|
||||
// auto-handled — complaints route to a person (spec §6).
|
||||
const routeConversation = useCallback((id, routedTo) => {
|
||||
setConversations(prev => prev.map(c => c.id === id ? { ...c, status: 'routed', routedTo } : c));
|
||||
toast.success(`Routed to ${routedTo}`);
|
||||
}, []);
|
||||
|
||||
// Promote a lead-intent comment/DM into the Unified Lead Inbox. A social comment
|
||||
// carries no form consent, so the lead is consent=false → manual follow-up only.
|
||||
const convertConversationToLead = useCallback((id) => {
|
||||
const cv = conversations.find(c => c.id === id);
|
||||
if (!cv) return;
|
||||
const lead = {
|
||||
id: nextId('lead'), platform: cv.platform, campaignId: null, formId: null,
|
||||
externalLeadId: cv.id, receivedAt: nowIso(), status: 'new', stage: 'lead',
|
||||
name: cv.author, phone: '', email: '', address: '',
|
||||
consent: false, role: 'homeowner', activeLeak: /leak/i.test(cv.text),
|
||||
triage: {
|
||||
serviceType: /commercial|flat roof|propert/i.test(cv.text) ? 'commercial' : /leak|emergency/i.test(cv.text) ? 'emergency' : 'replacement',
|
||||
urgency: /leak|emergency|asap|urgent/i.test(cv.text) ? 'high' : 'medium',
|
||||
role: /propert|facilit|manage/i.test(cv.text) ? 'property_manager' : 'homeowner',
|
||||
intent: 'inquiry', quality: Math.round((cv.classification.confidence || 0.7) * 80), confidence: cv.classification.confidence || 0.7,
|
||||
},
|
||||
duplicateOf: null, assignedTo: null, dealValue: 0, premium: false,
|
||||
firstContactSec: null, slaStartTs: Date.now(), fromConversation: true,
|
||||
};
|
||||
setLeads(prev => [lead, ...prev]);
|
||||
setConversations(prev => prev.map(c => c.id === id ? { ...c, status: 'converted' } : c));
|
||||
toast.success('Converted to lead — now in the Unified Lead Inbox (manual follow-up: no form consent)');
|
||||
}, [conversations]);
|
||||
|
||||
const dismissConversation = useCallback((id) => {
|
||||
setConversations(prev => prev.map(c => c.id === id ? { ...c, status: 'dismissed' } : c));
|
||||
toast.message('Marked as spam and dismissed');
|
||||
}, []);
|
||||
|
||||
const replyConversation = useCallback((id) => {
|
||||
setConversations(prev => prev.map(c => c.id === id && c.status === 'new' ? { ...c, status: 'routed', routedTo: 'Replied (approved template)' } : c));
|
||||
toast.success('Reply sent using an approved template');
|
||||
}, []);
|
||||
|
||||
// ── Ad-to-Nurture rules ───────────────────────────────────────────────────
|
||||
const toggleRule = useCallback((id) => {
|
||||
setNurtureRules(prev => prev.map(r => r.id === id ? { ...r, enabled: !r.enabled } : r));
|
||||
}, []);
|
||||
|
||||
// Editable routing rules (spec §8). Merges nested when/then patches.
|
||||
const updateRule = useCallback((id, patch) => {
|
||||
setNurtureRules(prev => prev.map(r => r.id === id ? {
|
||||
...r, ...patch,
|
||||
when: patch.when ? { ...r.when, ...patch.when } : r.when,
|
||||
then: patch.then ? { ...r.then, ...patch.then } : r.then,
|
||||
} : r));
|
||||
}, []);
|
||||
|
||||
const addRule = useCallback(() => {
|
||||
setNurtureRules(prev => {
|
||||
const maxP = Math.max(0, ...prev.filter(r => r.priority !== 99).map(r => r.priority));
|
||||
const rule = {
|
||||
id: nextId('rule'), priority: maxP + 1, enabled: true,
|
||||
when: { platform: 'any', serviceType: 'storm', campaignId: 'any', formId: 'any' },
|
||||
then: { playbook: 'New Nurture', rep: 'Auto-assign', speedToLead: 'Call < 5 min', education: 'None', scheduling: 'Send scheduling link' },
|
||||
};
|
||||
return [...prev, rule];
|
||||
});
|
||||
toast.success('Routing rule added');
|
||||
}, []);
|
||||
|
||||
const removeRule = useCallback((id) => {
|
||||
setNurtureRules(prev => prev.filter(r => r.id !== id));
|
||||
toast.message('Routing rule removed');
|
||||
}, []);
|
||||
|
||||
// Add a rule from an AI nurture suggestion (spec §6 — admin accepts the recommendation).
|
||||
const addRuleFromSuggestion = useCallback((when, then) => {
|
||||
setNurtureRules(prev => {
|
||||
const maxP = Math.max(0, ...prev.filter(r => r.priority !== 99).map(r => r.priority));
|
||||
const rule = {
|
||||
id: nextId('rule'), priority: maxP + 1, enabled: true,
|
||||
when: { platform: 'any', serviceType: 'any', campaignId: 'any', formId: 'any', ...when },
|
||||
then: { ...then },
|
||||
};
|
||||
return [...prev, rule];
|
||||
});
|
||||
toast.success('AI-suggested rule added');
|
||||
}, []);
|
||||
|
||||
// Reorder priority by swapping with the adjacent non-default rule.
|
||||
const moveRule = useCallback((id, dir) => {
|
||||
setNurtureRules(prev => {
|
||||
const movable = [...prev].filter(r => r.priority !== 99).sort((a, b) => a.priority - b.priority);
|
||||
const i = movable.findIndex(r => r.id === id);
|
||||
const j = i + dir;
|
||||
if (i < 0 || j < 0 || j >= movable.length) return prev;
|
||||
const a = movable[i], b = movable[j];
|
||||
return prev.map(r => r.id === a.id ? { ...r, priority: b.priority }
|
||||
: r.id === b.id ? { ...r, priority: a.priority } : r);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// ── Audiences (segment builder, spec §12 E1/E2 + §8 preview count) ─────────
|
||||
// Counts are computed from the mock CRM pool through previewSegment, which always
|
||||
// applies consent + suppression — so the stored figures honor opt-outs (spec §6).
|
||||
const buildAudience = (def, id) => {
|
||||
const p = previewSegment(def.filterDef);
|
||||
return {
|
||||
id: id || nextId('aud'),
|
||||
name: def.name?.trim() || 'New segment',
|
||||
segmentType: def.segmentType || 'crm_stage',
|
||||
filters: summarizeFilters(def.filterDef),
|
||||
filterDef: def.filterDef,
|
||||
totalCount: p.matched, consentCount: p.consented,
|
||||
suppressionCount: p.suppressed, eligibleCount: p.eligible,
|
||||
frequencyCap: def.filterDef.frequencyCap || null,
|
||||
lastCount: nowIso().slice(0, 10),
|
||||
syncStatus: 'draft', lastSyncedAt: null, syncDestination: null,
|
||||
};
|
||||
};
|
||||
|
||||
const createAudience = useCallback((def) => {
|
||||
const a = buildAudience(def);
|
||||
setAudiences(prev => [a, ...prev]);
|
||||
toast.success(`Created “${a.name}” — ${a.eligibleCount.toLocaleString()} eligible after consent + suppression`);
|
||||
return a;
|
||||
}, []);
|
||||
|
||||
const updateAudience = useCallback((id, def) => {
|
||||
setAudiences(prev => prev.map(a => a.id === id ? { ...a, ...buildAudience(def, id) } : a));
|
||||
toast.success('Segment updated');
|
||||
}, []);
|
||||
|
||||
// Persist the platform sync result (spec §8 "export/sync status"; §12 E2). Only the
|
||||
// eligible (consented, non-suppressed) contacts are pushed to the platform audience.
|
||||
const syncAudience = useCallback((id, destination) => {
|
||||
setAudiences(prev => prev.map(a => a.id === id
|
||||
? { ...a, syncStatus: 'synced', syncDestination: destination, lastSyncedAt: nowIso() }
|
||||
: a));
|
||||
toast.success(`Synced to ${destination} — eligible contacts only (consent + suppression applied)`);
|
||||
}, []);
|
||||
|
||||
const refreshAudience = useCallback((id) => {
|
||||
toast.promise(new Promise(res => setTimeout(res, 700)), {
|
||||
loading: 'Recomputing audience with consent + suppression filters…',
|
||||
success: 'Audience count refreshed ✓',
|
||||
error: 'Refresh failed',
|
||||
});
|
||||
setAudiences(prev => prev.map(a => a.id === id ? { ...a, lastCount: nowIso().slice(0, 10) } : a));
|
||||
}, []);
|
||||
|
||||
// ── Compliance / approvals ────────────────────────────────────────────────
|
||||
// Resolving a Compliance item also flips the underlying entity's approval state, so
|
||||
// an approved 'campaign' record unblocks publish and a 'creative' record stays in
|
||||
// sync with Creative Studio (spec §6/§8 — an unapproved campaign cannot publish).
|
||||
const resolveApproval = useCallback((id, decision) => {
|
||||
const appr = approvals.find(a => a.id === id);
|
||||
setApprovals(prev => prev.map(a => a.id === id ? { ...a, status: decision, reviewer: 'fe@lynkeduppro.com' } : a));
|
||||
if (appr?.refId && appr.type === 'campaign') {
|
||||
setCampaigns(prev => prev.map(c => c.id === appr.refId ? { ...c, approvalStatus: decision } : c));
|
||||
}
|
||||
if (appr?.refId && appr.type === 'creative') {
|
||||
setCreatives(prev => prev.map(c => c.id === appr.refId ? { ...c, approvalStatus: decision } : c));
|
||||
}
|
||||
toast[decision === 'approved' ? 'success' : 'message'](
|
||||
appr?.type === 'campaign'
|
||||
? (decision === 'approved' ? 'Campaign approved — ready to publish' : `Campaign approval ${decision}`)
|
||||
: `Item ${decision}`,
|
||||
);
|
||||
}, [approvals]);
|
||||
|
||||
// ── Conversion events ─────────────────────────────────────────────────────
|
||||
const retryConversion = useCallback((id) => {
|
||||
setConversionEvents(prev => prev.map(e => e.id === id ? { ...e, syncStatus: 'synced', syncedAt: nowIso() } : e));
|
||||
toast.success('Conversion event re-synced ✓');
|
||||
}, []);
|
||||
|
||||
// ── Anomaly alerts (spec §11) ─────────────────────────────────────────────
|
||||
// AI recommends pause/review; the tenant executes. Pausing here also stops the
|
||||
// underlying campaign, closing the optimization loop.
|
||||
const resolveAnomaly = useCallback((id, decision) => {
|
||||
const an = anomalies.find(a => a.id === id);
|
||||
setAnomalies(prev => prev.map(a => a.id === id ? { ...a, status: decision } : a));
|
||||
if (decision === 'paused' && an) {
|
||||
setCampaigns(prev => prev.map(c => c.id === an.campaignId ? { ...c, status: 'paused' } : c));
|
||||
}
|
||||
toast[decision === 'dismissed' ? 'message' : 'success'](
|
||||
decision === 'paused' ? 'Campaign paused on anomaly' : decision === 'reviewed' ? 'Anomaly marked reviewed' : 'Alert dismissed',
|
||||
);
|
||||
}, [anomalies]);
|
||||
|
||||
// ── Consent text versions (spec §8 "consent text versions") ───────────────
|
||||
// Approving a pending version supersedes the form's prior live version, so only
|
||||
// one approved consent language is ever live per form (spec §9).
|
||||
const approveConsentVersion = useCallback((id) => {
|
||||
setConsentVersions(prev => {
|
||||
const target = prev.find(v => v.id === id);
|
||||
if (!target) return prev;
|
||||
return prev.map(v => {
|
||||
if (v.id === id) return { ...v, status: 'approved', reviewer: 'fe@lynkeduppro.com' };
|
||||
if (v.formId === target.formId && v.status === 'approved') return { ...v, status: 'superseded' };
|
||||
return v;
|
||||
});
|
||||
});
|
||||
toast.success('Consent text approved — now the live version for this form');
|
||||
}, []);
|
||||
|
||||
const blockConsentVersion = useCallback((id) => {
|
||||
setConsentVersions(prev => prev.map(v => v.id === id ? { ...v, status: 'blocked' } : v));
|
||||
toast.message('Consent text version blocked');
|
||||
}, []);
|
||||
|
||||
// ── Review usage permission + FTC disclosure (spec §8, §11) ───────────────
|
||||
// A review used in an ad needs written usage permission AND an FTC disclosure;
|
||||
// until both are on file the review creative is blocked from publishing.
|
||||
const setReviewPermission = useCallback((id, usagePermission) => {
|
||||
setReviews(prev => prev.map(r => r.id === id ? { ...r, usagePermission } : r));
|
||||
toast[usagePermission === 'denied' ? 'message' : 'success'](`Review usage permission ${usagePermission}`);
|
||||
}, []);
|
||||
|
||||
const setReviewDisclosure = useCallback((id, disclosureStatus) => {
|
||||
setReviews(prev => prev.map(r => r.id === id ? { ...r, disclosureStatus } : r));
|
||||
toast.success(`FTC disclosure marked ${disclosureStatus}`);
|
||||
}, []);
|
||||
|
||||
const value = useMemo(() => ({
|
||||
accounts, campaigns, creatives, mediaAssets, leadForms, leads, nurtureRules,
|
||||
audiences, conversations, reviews, journeys, anomalies, consentVersions, conversionEvents, approvals, analyticsDaily,
|
||||
toggleAccount, testWebhook, reconnectAccount,
|
||||
createCampaign, setCampaignStatus, publishCampaign, completeManualSetup,
|
||||
addCreative, setCreativeApproval, updateCreative, revertCreative, addMediaAsset,
|
||||
updateLeadForm, updateFormField, addFormField, removeFormField, toggleFormStatus, addLeadForm,
|
||||
assignLead, mergeLead, setLeadStatus, advanceLead, flagLeadReview, importLeads,
|
||||
routeConversation, convertConversationToLead, dismissConversation, replyConversation,
|
||||
toggleRule, updateRule, addRule, removeRule, moveRule, addRuleFromSuggestion,
|
||||
createAudience, updateAudience, syncAudience, refreshAudience, resolveApproval, retryConversion, resolveAnomaly,
|
||||
approveConsentVersion, blockConsentVersion, setReviewPermission, setReviewDisclosure,
|
||||
}), [
|
||||
accounts, campaigns, creatives, mediaAssets, leadForms, leads, nurtureRules,
|
||||
audiences, conversations, reviews, journeys, anomalies, consentVersions, conversionEvents, approvals, analyticsDaily,
|
||||
toggleAccount, testWebhook, reconnectAccount, createCampaign, setCampaignStatus, publishCampaign, completeManualSetup,
|
||||
addCreative, setCreativeApproval, updateCreative, revertCreative, addMediaAsset,
|
||||
updateLeadForm, updateFormField, addFormField, removeFormField, toggleFormStatus, addLeadForm,
|
||||
assignLead, mergeLead, setLeadStatus, advanceLead, flagLeadReview, importLeads,
|
||||
routeConversation, convertConversationToLead, dismissConversation, replyConversation,
|
||||
toggleRule, updateRule, addRule, removeRule, moveRule, addRuleFromSuggestion,
|
||||
createAudience, updateAudience, syncAudience, refreshAudience, resolveApproval, retryConversion, resolveAnomaly,
|
||||
approveConsentVersion, blockConsentVersion, setReviewPermission, setReviewDisclosure,
|
||||
]);
|
||||
|
||||
return <SocialAdsContext.Provider value={value}>{children}</SocialAdsContext.Provider>;
|
||||
}
|
||||
|
||||
export function useSocialAds() {
|
||||
const ctx = useContext(SocialAdsContext);
|
||||
if (!ctx) throw new Error('useSocialAds must be used within SocialAdsProvider');
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* SocialAdsEngine — entry shell for the Social Media + Advertisement Engine module.
|
||||
*
|
||||
* Renders inside the CRM Layout (sidebar + chrome) at /marketing. A single horizontal
|
||||
* tab bar switches between the spec's 10 admin screens (§8); each screen is a
|
||||
* self-contained component reading from SocialAdsProvider. URL hash keeps the active
|
||||
* tab deep-linkable and refresh-stable without adding ten router entries.
|
||||
*/
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
LayoutDashboard, Plug, Megaphone, Wand2, ClipboardList,
|
||||
GitBranch, Inbox, Users, BarChart3, ShieldCheck,
|
||||
} from 'lucide-react';
|
||||
import { SocialAdsProvider } from './SocialAdsContext.jsx';
|
||||
|
||||
import MarketingHubHome from './screens/MarketingHubHome.jsx';
|
||||
import ConnectedAccounts from './screens/ConnectedAccounts.jsx';
|
||||
import CampaignBuilder from './screens/CampaignBuilder.jsx';
|
||||
import CreativeStudio from './screens/CreativeStudio.jsx';
|
||||
import LeadFormMapper from './screens/LeadFormMapper.jsx';
|
||||
import AdToNurtureRules from './screens/AdToNurtureRules.jsx';
|
||||
import UnifiedLeadInbox from './screens/UnifiedLeadInbox.jsx';
|
||||
import AudienceSegments from './screens/AudienceSegments.jsx';
|
||||
import AttributionDashboard from './screens/AttributionDashboard.jsx';
|
||||
import ComplianceCenter from './screens/ComplianceCenter.jsx';
|
||||
|
||||
const TABS = [
|
||||
{ id: 'home', label: 'Marketing Hub', icon: LayoutDashboard, Component: MarketingHubHome },
|
||||
{ id: 'accounts', label: 'Connected Accounts', icon: Plug, Component: ConnectedAccounts },
|
||||
{ id: 'campaigns', label: 'Campaign Builder', icon: Megaphone, Component: CampaignBuilder },
|
||||
{ id: 'creative', label: 'Creative Studio', icon: Wand2, Component: CreativeStudio },
|
||||
{ id: 'forms', label: 'Lead Form Mapper', icon: ClipboardList, Component: LeadFormMapper },
|
||||
{ id: 'rules', label: 'Ad-to-Nurture', icon: GitBranch, Component: AdToNurtureRules },
|
||||
{ id: 'inbox', label: 'Lead Inbox', icon: Inbox, Component: UnifiedLeadInbox },
|
||||
{ id: 'audiences', label: 'Audiences', icon: Users, Component: AudienceSegments },
|
||||
{ id: 'attribution', label: 'Attribution', icon: BarChart3, Component: AttributionDashboard },
|
||||
{ id: 'compliance', label: 'Compliance', icon: ShieldCheck, Component: ComplianceCenter },
|
||||
];
|
||||
|
||||
export default function SocialAdsEngine() {
|
||||
const [active, setActive] = useState(() => {
|
||||
const hash = window.location.hash.replace('#', '');
|
||||
return TABS.some(t => t.id === hash) ? hash : 'home';
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
window.history.replaceState(null, '', `#${active}`);
|
||||
}, [active]);
|
||||
|
||||
const ActiveComponent = TABS.find(t => t.id === active)?.Component || MarketingHubHome;
|
||||
|
||||
return (
|
||||
<SocialAdsProvider>
|
||||
<div className="min-h-full bg-zinc-50 dark:bg-[#09090b]">
|
||||
{/* Header */}
|
||||
<div className="border-b border-zinc-200 dark:border-white/5 bg-white/60 dark:bg-zinc-900/40 backdrop-blur-md sticky top-0 z-30">
|
||||
<div className="max-w-content mx-auto px-4 sm:px-6 pt-5 pb-0">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 rounded-xl bg-gradient-to-tr from-amber-400 to-orange-600 text-white shadow-lg shadow-amber-500/25">
|
||||
<Megaphone size={22} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl sm:text-2xl font-bold text-zinc-900 dark:text-white">
|
||||
Social Ads <span className="text-amber-500">Engine</span>
|
||||
</h1>
|
||||
<p className="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
Closed-loop acquisition — campaign → lead → nurture → job → attribution
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab bar */}
|
||||
<div className="flex gap-1 overflow-x-auto custom-scrollbar -mb-px">
|
||||
{TABS.map(t => {
|
||||
const Icon = t.icon;
|
||||
const isActive = t.id === active;
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => setActive(t.id)}
|
||||
className={`relative flex items-center gap-1.5 px-3 py-2.5 text-xs sm:text-sm font-semibold whitespace-nowrap transition-colors ${isActive
|
||||
? 'text-amber-600 dark:text-amber-400'
|
||||
: 'text-zinc-500 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-white'}`}
|
||||
>
|
||||
<Icon size={15} />
|
||||
{t.label}
|
||||
{isActive && (
|
||||
<motion.div layoutId="adEngineTab" className="absolute left-0 right-0 -bottom-px h-0.5 bg-amber-500 rounded-full" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active screen — `isolate` creates a stacking context so the cards'
|
||||
internal z-indexes stay contained below the sticky header above. */}
|
||||
<div className="max-w-content mx-auto px-4 sm:px-6 py-6 isolate">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={active}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<ActiveComponent onNavigate={setActive} />
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</SocialAdsProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* audienceAdvisor.js — mock "Audience suggestion" AI (spec §6).
|
||||
*
|
||||
* Recommends the four ready-made retargeting/prospecting segments the spec calls
|
||||
* out: past-customer maintenance retargeting, unsold-estimate retargeting,
|
||||
* storm-event neighborhoods, and a commercial property-manager list. Each carries a
|
||||
* filterDef (fed through previewSegment so consent + suppression always apply),
|
||||
* visible AI reasoning, and a confidence score. Front-end demo: static recipes, no
|
||||
* real model. Guardrail (spec §6): no sensitive-attribute targeting — only CRM fields.
|
||||
*/
|
||||
import { History, FileX2, CloudHail, Building2 } from 'lucide-react';
|
||||
|
||||
export const AUDIENCE_SUGGESTIONS = [
|
||||
{
|
||||
key: 'maint_retarget',
|
||||
name: 'Past customers — maintenance due',
|
||||
segmentType: 'reactivation',
|
||||
icon: History,
|
||||
confidence: 0.9,
|
||||
filterDef: { stage: 'won', source: 'any', service: 'maintenance', geo: [], lastJobWithinDays: 540, frequencyCap: 2 },
|
||||
rationale: 'Closed jobs aging 12–18 months are prime for a maintenance/tune-up retarget before warranty lapses.',
|
||||
reasons: [
|
||||
'Targets won deals with a completed job in the last 18 months',
|
||||
'Maintenance service interest → seasonal tune-up offer',
|
||||
'Low frequency cap (2/wk) keeps existing customers warm, not spammed',
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'unsold_retarget',
|
||||
name: 'Unsold estimates — retarget',
|
||||
segmentType: 'crm_stage',
|
||||
icon: FileX2,
|
||||
confidence: 0.88,
|
||||
filterDef: { stage: 'estimate', source: 'any', service: 'any', geo: [], lastJobWithinDays: 180, frequencyCap: 3 },
|
||||
rationale: 'Estimates that never closed are the cheapest pipeline to reactivate with a financing or limited-offer nudge.',
|
||||
reasons: [
|
||||
'Targets the estimate stage that has not advanced to won',
|
||||
'Recency < 180 days → still in-market',
|
||||
'Pair with a financing / good-better-best creative',
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'storm_geo',
|
||||
name: 'Storm-event neighborhoods',
|
||||
segmentType: 'storm_geo',
|
||||
icon: CloudHail,
|
||||
confidence: 0.92,
|
||||
filterDef: { stage: 'any', source: 'any', service: 'storm', geo: ['75023', '75025'], lastJobWithinDays: null, frequencyCap: 4 },
|
||||
rationale: 'Hail-affected ZIPs concentrate high-intent inspection demand right after an event.',
|
||||
reasons: [
|
||||
'Geo-scoped to storm-hit ZIPs (75023 / 75025)',
|
||||
'Storm service intent → inspection + insurance education',
|
||||
'Higher cap (4/wk) to capture a short demand window',
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'commercial_pm',
|
||||
name: 'Commercial property managers',
|
||||
segmentType: 'b2b',
|
||||
icon: Building2,
|
||||
confidence: 0.85,
|
||||
filterDef: { stage: 'any', source: 'linkedin', service: 'commercial', geo: [], lastJobWithinDays: null, frequencyCap: 2 },
|
||||
rationale: 'B2B facilities/property managers from LinkedIn map to the commercial bid track with a decision-maker offer.',
|
||||
reasons: [
|
||||
'LinkedIn-sourced commercial contacts',
|
||||
'Commercial service intent → capability statement offer',
|
||||
'Lower cap (2/wk) suits a considered B2B buying cycle',
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* bannedClaims.js — compliance guardrail for AI/admin ad copy (spec §6, §11).
|
||||
*
|
||||
* The Compliance reviewer "flags missing opt-out, risky guarantee, undisclosed
|
||||
* incentive, deceptive scarcity, or unsupported insurance claim" and blocks
|
||||
* publish/send until resolved. This is a lightweight client-side stand-in: a set
|
||||
* of regex rules that scan copy and return policy flags. Demo only — not legal advice.
|
||||
*/
|
||||
|
||||
const RULES = [
|
||||
{ id: 'guarantee', test: /\b(guarantee[d]?|100%|risk[-\s]?free|always|never fail)\b/i, severity: 'block', message: 'Unsupported guarantee / absolute claim.' },
|
||||
{ id: 'scarcity', test: /\b(limited (slots|time|spots)|act now|last chance|only \d+ (left|spots)|hurry)\b/i, severity: 'block', message: 'Deceptive scarcity / pressure language.' },
|
||||
{ id: 'price_claim', test: /\b(prices? (jump|rising|going up)|\d+% (off|discount)|cheapest|lowest price)\b/i, severity: 'block', message: 'Unverified discount / price claim.' },
|
||||
{ id: 'insurance', test: /\b(insurance will (pay|cover)|free roof|we handle your claim|guaranteed approval)\b/i, severity: 'block', message: 'Unsupported insurance claim.' },
|
||||
{ id: 'fear', test: /\b(disaster|catastrophe|your family.s safety|before it.s too late|danger)\b/i, severity: 'warn', message: 'Fear/panic language — soften per platform tone rules.' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Scan ad copy. Returns { status: 'clear'|'review'|'blocked', flags: [{id,severity,message}] }.
|
||||
*/
|
||||
export function scanCopy(...parts) {
|
||||
const text = parts.filter(Boolean).join(' ');
|
||||
const flags = RULES.filter(r => r.test.test(text)).map(({ id, severity, message }) => ({ id, severity, message }));
|
||||
const status = flags.some(f => f.severity === 'block')
|
||||
? 'blocked'
|
||||
: flags.length > 0 ? 'review' : 'clear';
|
||||
return { status, flags };
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* briefGenerator.js — mock "AI Campaign Brief Generator" (spec §6).
|
||||
*
|
||||
* The admin types a natural-language brief ("storm inspection campaign for hail-hit
|
||||
* ZIPs in Plano") and the assistant drafts objective, audience notes, CTA, ad copy,
|
||||
* recommended form fields, and the nurture path. Everything is composed from the
|
||||
* tenant-approved blueprint library and run through the bannedClaims guardrail (via
|
||||
* generateVariants), and nothing is created until the admin approves it (spec §6).
|
||||
*
|
||||
* Front-end demo: deterministic keyword matching, no real LLM call.
|
||||
*/
|
||||
import { BLUEPRINT_BY_ID } from '../data/blueprints.js';
|
||||
import { generateVariants } from './creativeGenerator.js';
|
||||
|
||||
export const OBJECTIVE_LABEL = {
|
||||
lead_generation: 'Lead generation',
|
||||
retargeting: 'Retargeting',
|
||||
engagement: 'Engagement',
|
||||
reactivation: 'Reactivation',
|
||||
};
|
||||
|
||||
// Keyword → blueprint intent. First match wins, so order encodes priority.
|
||||
const INTENT_RULES = [
|
||||
{ id: 'bp_emergency', re: /\b(emergency|leak|leaking|tarp|urgent|flood)\b/i },
|
||||
{ id: 'bp_commercial', re: /\b(commercial|property manager|facilit|building|b2b|portfolio|hoa)\b/i },
|
||||
{ id: 'bp_storm', re: /\b(storm|hail|wind|tornado|hurricane|damage)\b/i },
|
||||
{ id: 'bp_unsold', re: /\b(unsold|retarget|re-?target|follow.?up|warm lead|stalled|estimate)\b/i },
|
||||
{ id: 'bp_review', re: /\b(review|referral|testimonial|word of mouth)\b/i },
|
||||
{ id: 'bp_seasonal', re: /\b(seasonal|maintenance|reactivat|annual|check.?up|past customer)\b/i },
|
||||
{ id: 'bp_retail', re: /\b(replace|replacement|new roof|aging|old roof|upgrade)\b/i },
|
||||
];
|
||||
|
||||
const CTA = {
|
||||
storm: 'Request a free inspection',
|
||||
replacement: 'Get a free quote',
|
||||
emergency: 'Get urgent help now',
|
||||
commercial: 'Request a site review',
|
||||
referral: 'Share your experience',
|
||||
maintenance: 'Book a seasonal check-up',
|
||||
};
|
||||
|
||||
const SERVICE_LABEL = {
|
||||
storm: 'recent storm or hail activity',
|
||||
replacement: 'an aging or failing roof',
|
||||
emergency: 'an active roof leak',
|
||||
commercial: 'commercial roofing needs',
|
||||
referral: 'a recently completed job',
|
||||
maintenance: 'overdue roof maintenance',
|
||||
};
|
||||
|
||||
function detectBlueprint(text) {
|
||||
for (const rule of INTENT_RULES) if (rule.re.test(text)) return BLUEPRINT_BY_ID[rule.id];
|
||||
return BLUEPRINT_BY_ID.bp_storm;
|
||||
}
|
||||
|
||||
// Pull a 5-digit ZIP or a capitalized place name ("... in Plano ...") from the prompt.
|
||||
function detectGeo(text) {
|
||||
const zip = text.match(/\b\d{5}\b/);
|
||||
if (zip) return zip[0];
|
||||
const city = text.match(/\bin ([A-Z][a-zA-Z.-]+(?: [A-Z][a-zA-Z.-]+)?)/);
|
||||
return city ? city[1].trim() : '';
|
||||
}
|
||||
|
||||
/** Draft a full campaign brief from a free-text prompt. */
|
||||
export function generateBrief(prompt) {
|
||||
const text = (prompt || '').trim();
|
||||
const blueprint = detectBlueprint(text);
|
||||
const geo = detectGeo(text);
|
||||
const serviceType = blueprint.serviceType;
|
||||
const platform = blueprint.bestChannels[0];
|
||||
const city = geo || 'your area';
|
||||
|
||||
const variants = generateVariants({ platformId: platform, serviceType, city, count: 3 });
|
||||
// Prefer a policy-clear variant; fall back to the first draft.
|
||||
const adCopy = variants.find(v => v.policy.status === 'clear') || variants[0];
|
||||
|
||||
const audienceNotes =
|
||||
`Households with ${SERVICE_LABEL[serviceType] || 'roofing needs'}${geo ? ` in/around ${geo}` : ''}. ` +
|
||||
`Exclude existing customers and anyone on the suppression list; only consented contacts sync.`;
|
||||
|
||||
return {
|
||||
prompt: text,
|
||||
blueprintId: blueprint.id,
|
||||
blueprintName: blueprint.name,
|
||||
serviceType,
|
||||
platform,
|
||||
geo,
|
||||
objective: blueprint.objective,
|
||||
objectiveLabel: OBJECTIVE_LABEL[blueprint.objective] || blueprint.objective,
|
||||
offer: blueprint.offer,
|
||||
cta: CTA[serviceType] || 'Request a free inspection',
|
||||
audienceNotes,
|
||||
adCopy, // { headline, body, description, charCounts, policy, ... }
|
||||
formFields: blueprint.formQuestions,
|
||||
nurturePath: blueprint.nurture,
|
||||
};
|
||||
}
|
||||
|
||||
export const BRIEF_EXAMPLES = [
|
||||
'Storm inspection campaign for hail-hit ZIPs in Plano',
|
||||
'Same-day emergency leak response in Dallas',
|
||||
'Commercial roof bids for property managers',
|
||||
'Retarget unsold estimates with financing',
|
||||
];
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* commentTriage.js — mock "Comment / DM triage" (spec §6).
|
||||
*
|
||||
* Classifies an inbound social comment or direct message as one of five intents:
|
||||
* lead, complaint, spam, referral, or support. Front-end demo: keyword scoring, no
|
||||
* real model. Guardrail (spec §6): AI never auto-deletes criticism — complaints are
|
||||
* always routed to a human; only clear spam is dismissable.
|
||||
*/
|
||||
import { MessageSquare, AlertOctagon, Ban, Gift, LifeBuoy } from 'lucide-react';
|
||||
|
||||
export const CONVO_TYPES = {
|
||||
lead: { label: 'Lead', tone: 'blue', icon: MessageSquare, action: 'Create lead', route: 'Sales / nurture' },
|
||||
complaint: { label: 'Complaint', tone: 'red', icon: AlertOctagon, action: 'Route to human', route: 'Customer success (human)', human: true },
|
||||
referral: { label: 'Referral', tone: 'emerald', icon: Gift, action: 'Send promoter path', route: 'Referral nurture' },
|
||||
support: { label: 'Support', tone: 'amber', icon: LifeBuoy, action: 'Route to support', route: 'Support queue' },
|
||||
spam: { label: 'Spam', tone: 'zinc', icon: Ban, action: 'Dismiss', route: null },
|
||||
};
|
||||
|
||||
const SIGNALS = [
|
||||
{ type: 'complaint', words: ['terrible', 'worst', 'scam', 'rude', 'refund', 'disappointed', 'avoid', 'never again', 'damaged my', 'complaint', 'unhappy', 'ripoff', 'overcharged'] },
|
||||
{ type: 'spam', words: ['http://', 'https://', 'crypto', 'bitcoin', 'click here', 'free money', 'make $', 'loan', 'dm me to earn', 'followers', 'promo code 🔥'] },
|
||||
{ type: 'referral', words: ['recommend', 'referral', 'referred', 'my neighbor', 'told my', 'a friend', 'gave them your', 'highly recommend', 'sent you'] },
|
||||
{ type: 'support', words: ['warranty', 'reschedule', 'invoice', 'when will', 'appointment time', 'already a customer', 'my existing', 'follow up on my', 'still waiting', 'status of'] },
|
||||
{ type: 'lead', words: ['quote', 'estimate', 'how much', 'price', 'interested', 'my roof', 'need a', 'leak', 'inspection', 'replace', 'cost to', 'do you service'] },
|
||||
];
|
||||
|
||||
/** Classify a comment/DM. Returns { type, confidence (0-1), rationale }. */
|
||||
export function classifyComment(text = '') {
|
||||
const t = text.toLowerCase();
|
||||
const scores = {};
|
||||
for (const sig of SIGNALS) {
|
||||
scores[sig.type] = sig.words.reduce((n, w) => n + (t.includes(w) ? 1 : 0), 0);
|
||||
}
|
||||
let best = 'support', hits = 0;
|
||||
for (const [type, n] of Object.entries(scores)) {
|
||||
if (n > hits) { hits = n; best = type; }
|
||||
}
|
||||
// No keyword hit at all → low-confidence support catch-all (never auto-dismiss).
|
||||
if (hits === 0) return { type: 'support', confidence: 0.45, rationale: 'No strong intent signal — routed to a human to review.' };
|
||||
const confidence = Math.min(0.97, 0.6 + hits * 0.12);
|
||||
return { type: best, confidence, rationale: RATIONALE[best] };
|
||||
}
|
||||
|
||||
const RATIONALE = {
|
||||
lead: 'Buying-intent language (quote / price / my roof) → treat as a new lead.',
|
||||
complaint: 'Negative-sentiment language detected → never auto-handle; route to a human.',
|
||||
referral: 'Word-of-mouth language (recommend / referred) → promoter path.',
|
||||
support: 'Existing-customer / service-status language → support queue.',
|
||||
spam: 'Promotional / link-spam signals → dismissable, not a real lead.',
|
||||
};
|
||||
|
||||
/** Suggested approved-template reply per type (spec §6: approved templates, human for complex). */
|
||||
export function suggestReply(type) {
|
||||
switch (type) {
|
||||
case 'lead': return 'Thanks for reaching out! We’d love to help with your roof. What’s the best number to text you a quick quote?';
|
||||
case 'referral': return 'That means a lot — thank you for the referral! Send them our way and we’ll take great care of them.';
|
||||
case 'support': return 'Thanks for the message — pulling up your account now. A team member will follow up shortly with the details.';
|
||||
case 'complaint': return '(Routed to a human — complaints are handled personally, not by template.)';
|
||||
default: return '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* creativeGenerator.js — mock "AI Creative Assistant" (spec §4, §6).
|
||||
*
|
||||
* The real engine drafts headlines/body/descriptions from approved tenant knowledge
|
||||
* with platform tone + character limits. For the front-end demo this returns
|
||||
* deterministic, platform-aware variants from a template bank — every output is then
|
||||
* run through bannedClaims.scanCopy() so the compliance guardrail is exercised.
|
||||
*
|
||||
* AI cannot invent offers or guarantee outcomes (spec §2 non-negotiables); all copy
|
||||
* here is generic and approval-gated downstream.
|
||||
*/
|
||||
import { getPlatform } from '../data/platforms.js';
|
||||
import { scanCopy } from './bannedClaims.js';
|
||||
|
||||
// Platform character ceilings (approximate, demo values).
|
||||
export const CHAR_LIMITS = {
|
||||
meta: { headline: 40, body: 125, description: 30 },
|
||||
google: { headline: 30, body: 90, description: 90 },
|
||||
linkedin: { headline: 70, body: 150, description: 70 },
|
||||
reddit: { headline: 60, body: 140, description: 0 },
|
||||
whatsapp: { headline: 0, body: 1024, description: 0 },
|
||||
nextdoor: { headline: 50, body: 130, description: 0 },
|
||||
};
|
||||
|
||||
// Tone-shaped template bank per service type. {{City}} is a tenant merge field.
|
||||
const BANK = {
|
||||
storm: [
|
||||
{ headline: 'Hail in {{City}}? Document the damage.', body: 'A free roof inspection documents visible hail damage, ventilation concerns, and your repair or replacement options. Request an inspection window.', description: 'Free inspection • Local crew' },
|
||||
{ headline: 'Storm rolled through {{City}}', body: 'Our local team checks for hail bruising, lifted shingles, and ventilation issues, then walks you through the options. Same-week appointments.', description: 'Same-week appointments' },
|
||||
{ headline: 'Free post-storm roof check', body: 'Not sure if the storm hurt your roof? We will document what we find and explain your choices clearly. No obligation.', description: 'Clear, honest options' },
|
||||
{ headline: 'Hail check for {{City}} homes', body: 'A quick, no-obligation inspection tells you whether the recent storm caused damage worth addressing. We document everything with photos.', description: 'Photo-documented' },
|
||||
{ headline: 'Worried about storm damage?', body: 'Our local crew inspects, photographs, and explains your repair or replacement options in plain language. Book a convenient window.', description: 'Plain-language options' },
|
||||
],
|
||||
replacement: [
|
||||
{ headline: 'Roof replacement quotes — {{City}}', body: 'Compare Good / Better / Best options with transparent pricing and financing. Free, no-obligation quote.', description: 'Financing available' },
|
||||
{ headline: 'Time for a new roof?', body: 'See how Class 4 impact-rated options protect your home and how financing changes the monthly number.', description: 'Good / Better / Best' },
|
||||
{ headline: 'Transparent roof pricing', body: 'Know your options before you decide. Request a free quote and a side-by-side comparison.', description: 'No-obligation quote' },
|
||||
{ headline: 'Planning a new roof?', body: 'Compare materials, warranties, and financing side by side. We help you choose what fits your home and budget.', description: 'Side-by-side options' },
|
||||
{ headline: 'Roof nearing the end?', body: 'Get a clear replacement quote with Good / Better / Best choices and financing details so you can plan ahead.', description: 'Plan-ahead quote' },
|
||||
],
|
||||
emergency: [
|
||||
{ headline: 'Roof leaking right now?', body: 'Our emergency crew can tarp and stop the damage fast. Request urgent help and keep your phone handy.', description: 'Same-day response' },
|
||||
{ headline: 'Active leak? Get help fast', body: 'Send a safe photo and your address — our dispatcher will call to coordinate emergency tarping.', description: 'Emergency dispatch' },
|
||||
{ headline: 'Stop the leak today', body: 'Our emergency team responds quickly to tarp and protect your home. Tell us where the leak is and we will coordinate the next step.', description: 'Quick response' },
|
||||
{ headline: 'Roof emergency? We can help', body: 'Send your address and a safe photo — a dispatcher will call to arrange emergency tarping and protect your interior.', description: 'Dispatcher will call' },
|
||||
{ headline: 'Water coming in?', body: 'Do not wait for more damage. Our crew can tarp the area quickly and assess what needs to happen next.', description: 'Limit the damage' },
|
||||
],
|
||||
commercial: [
|
||||
{ headline: 'Commercial roof assessment + bid', body: 'Request a site review and receive a decision-maker checklist before your meeting. Capability statement on request.', description: 'For property managers' },
|
||||
{ headline: 'Need a commercial roof bid?', body: 'Professional site assessment, scope documentation, and a clear bid package for owners and facilities teams.', description: 'Owners & facilities' },
|
||||
{ headline: 'Commercial roof, clear bid', body: 'We document the scope, photograph problem areas, and deliver a bid package your team can act on. Schedule a site review.', description: 'Scope + bid package' },
|
||||
{ headline: 'Property managers: roof help', body: 'Site assessments, capability statements, and clear timelines for owners and facilities teams. Request a review.', description: 'Owners & facilities' },
|
||||
{ headline: 'Bid-ready roof assessments', body: 'Get a documented assessment and a decision-maker checklist before your next meeting.', description: 'Decision-maker checklist' },
|
||||
],
|
||||
referral: [
|
||||
{ headline: 'Loved your new roof?', body: 'Share your experience with neighbors and pass along a referral if you know someone who needs us.', description: 'Neighborly referrals' },
|
||||
{ headline: 'Happy with your roof?', body: 'A quick review helps your neighbors find a contractor they can trust — and we appreciate it.', description: 'Help your neighbors' },
|
||||
{ headline: 'Know someone who needs us?', body: 'Pass along a referral. We will take great care of them, just like we did for you.', description: 'Refer a neighbor' },
|
||||
{ headline: 'Share your experience', body: 'Tell other homeowners how your project went. Your honest review means a lot.', description: 'Your honest review' },
|
||||
{ headline: 'Spread the word', body: 'If we did right by you, a quick review or referral helps your community find reliable roofing.', description: 'Community trust' },
|
||||
],
|
||||
maintenance: [
|
||||
{ headline: 'Roof check-up due in {{City}}', body: 'It has been a while since your last inspection. Book a seasonal check-up and keep your warranty on track.', description: 'Annual inspection' },
|
||||
{ headline: 'Time for a roof check-up?', body: 'A seasonal inspection catches small issues early and helps keep your warranty in good standing.', description: 'Catch issues early' },
|
||||
{ headline: 'Keep your roof on track', body: 'Book a maintenance visit to check for wear, sealant, and ventilation before the next season.', description: 'Seasonal visit' },
|
||||
{ headline: 'Roof maintenance reminder', body: 'It has been a while — a quick inspection now can prevent bigger repairs later.', description: 'Prevent bigger repairs' },
|
||||
{ headline: 'Protect your investment', body: 'Regular check-ups extend roof life and keep warranties valid. Schedule yours today.', description: 'Extend roof life' },
|
||||
],
|
||||
};
|
||||
|
||||
function fit(text, limit) {
|
||||
if (!limit || text.length <= limit) return text;
|
||||
return text.slice(0, Math.max(0, limit - 1)).trimEnd() + '…';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate N platform-aware creative variants for a service type.
|
||||
* Returns [{ headline, body, description, charCounts, policy }].
|
||||
*/
|
||||
export function generateVariants({ platformId, serviceType, city = 'your area', count = 3 }) {
|
||||
const platform = getPlatform(platformId);
|
||||
const limits = CHAR_LIMITS[platformId] || CHAR_LIMITS.meta;
|
||||
const bank = BANK[serviceType] || BANK.storm;
|
||||
|
||||
return bank.slice(0, count).map((tpl) => {
|
||||
const merge = (s) => s.replaceAll('{{City}}', city);
|
||||
const headline = fit(merge(tpl.headline), limits.headline);
|
||||
const body = fit(merge(tpl.body), limits.body);
|
||||
const description = fit(merge(tpl.description), limits.description);
|
||||
return {
|
||||
headline,
|
||||
body,
|
||||
description,
|
||||
aiGenerated: true,
|
||||
platform: platformId,
|
||||
platformTone: platform?.copyGuidance || '',
|
||||
charCounts: {
|
||||
headline: { used: headline.length, limit: limits.headline },
|
||||
body: { used: body.length, limit: limits.body },
|
||||
description: { used: description.length, limit: limits.description },
|
||||
},
|
||||
policy: scanCopy(headline, body, description),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* nurtureAdvisor.js — mock "Next best nurture selection" (spec §6).
|
||||
*
|
||||
* Given a sample lead (platform + service), it first checks the deterministic routing
|
||||
* rules — those override AI (spec §6). If only the catch-all default would catch the
|
||||
* lead, it recommends a tailored nurture path the admin can add as a rule.
|
||||
* Front-end demo: deterministic mapping, no real model.
|
||||
*/
|
||||
|
||||
const PATHS = {
|
||||
storm: { playbook: 'Storm Inspection Nurture', rep: 'Field agent (territory)', speedToLead: 'Text < 5 min', education: 'Storm checklist + insurance', scheduling: 'Auto-offer next available slot' },
|
||||
emergency: { playbook: 'Emergency Dispatch Nurture', rep: 'On-call dispatcher', speedToLead: 'WhatsApp + call < 10 min', education: 'Leak mitigation tips', scheduling: 'Human callback within SLA' },
|
||||
commercial: { playbook: 'Commercial Bid Nurture', rep: 'Commercial estimator', speedToLead: 'Email < 1 hr', education: 'Capability statement', scheduling: 'Manual booking by rep' },
|
||||
replacement: { playbook: 'Retail Replacement Nurture', rep: 'Inside sales', speedToLead: 'Call < 5 min', education: 'Good/Better/Best + financing', scheduling: 'Send scheduling link' },
|
||||
referral: { playbook: 'Completion Promoter Path', rep: 'Customer success', speedToLead: 'Email/SMS same day', education: 'Review + referral ask', scheduling: 'No scheduling (info only)' },
|
||||
maintenance: { playbook: 'Reactivation Nurture', rep: 'Inside sales', speedToLead: 'Email < 1 day', education: 'Seasonal maintenance value', scheduling: 'Send scheduling link' },
|
||||
};
|
||||
|
||||
const RATIONALE = {
|
||||
storm: 'Storm / hail intent → fast field inspection with insurance education.',
|
||||
emergency: 'Leak / emergency intent → human dispatch within the 10-minute SLA.',
|
||||
commercial: 'Commercial / facilities intent → estimator with a decision-maker track.',
|
||||
replacement: 'Replacement intent → inside sales with financing education.',
|
||||
referral: 'Referral / review intent → promoter path, no hard scheduling.',
|
||||
maintenance: 'Maintenance / reactivation intent → seasonal value nurture.',
|
||||
};
|
||||
|
||||
/** First enabled rule (by priority) whose conditions match the lead; may be the default. */
|
||||
export function matchRule(rules, { platform, serviceType }) {
|
||||
const enabled = [...rules].filter(r => r.enabled).sort((a, b) => a.priority - b.priority);
|
||||
for (const r of enabled) {
|
||||
const pOk = !r.when.platform || r.when.platform === 'any' || r.when.platform === platform;
|
||||
const sOk = !r.when.serviceType || r.when.serviceType === 'any' || r.when.serviceType === serviceType;
|
||||
if (pOk && sOk) return { rule: r, isDefault: r.priority === 99 };
|
||||
}
|
||||
return { rule: null, isDefault: false };
|
||||
}
|
||||
|
||||
/** Recommend a nurture path for a service type. Returns { then, rationale }. */
|
||||
export function recommendPath(serviceType) {
|
||||
const t = PATHS[serviceType] ? serviceType : 'storm';
|
||||
return { then: PATHS[t], rationale: RATIONALE[t] };
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* questionOptimizer.js — mock "AI Lead form question optimizer" (spec §6).
|
||||
*
|
||||
* Suggests 2–4 qualifying questions for a lead form based on its service type
|
||||
* (owner/tenant, urgency, address, insurance status, etc). Guardrails (spec §6):
|
||||
* - never request sensitive data (SSN, income, DOB, financial details);
|
||||
* - don't over-friction the form — capped at 4 suggestions, and the UI warns when a
|
||||
* form already has many fields.
|
||||
* Front-end demo: deterministic, derived from the blueprint service type.
|
||||
*/
|
||||
|
||||
// Sensitive fields the optimizer will never suggest (documented for the guardrail note).
|
||||
export const SENSITIVE_EXCLUDED = ['SSN', 'income', 'date of birth', 'credit score', 'bank details'];
|
||||
|
||||
const GENERAL = [
|
||||
{ source: 'owner_or_tenant', target: 'lead.role', label: 'Are you the homeowner or tenant?', transform: 'none', validation: '', rationale: 'Routes decision-makers vs. renters to the right rep.' },
|
||||
{ source: 'service_urgency', target: 'lead.urgency', label: 'How urgent is this?', transform: 'none', validation: '', rationale: 'Drives the speed-to-lead SLA and triage priority.' },
|
||||
{ source: 'property_address', target: 'property.address', label: 'Property address', transform: 'none', validation: 'required', rationale: 'Confirms the service area and enables routing.' },
|
||||
];
|
||||
|
||||
const BY_SERVICE = {
|
||||
storm: [
|
||||
{ source: 'active_leak', target: 'lead.urgencyFlag', label: 'Do you have an active leak right now?', transform: 'bool', validation: '', rationale: 'Flags emergencies for faster dispatch.' },
|
||||
{ source: 'storm_date', target: 'lead.stormDate', label: 'Storm date (if known)', transform: 'date', validation: '', rationale: 'Supports insurance documentation windows.' },
|
||||
{ source: 'insurance_status', target: 'lead.insuranceStatus', label: 'Have you filed an insurance claim yet?', transform: 'none', validation: '', rationale: 'Qualifies insurance-track leads (claim status only — not financial data).' },
|
||||
],
|
||||
replacement: [
|
||||
{ source: 'roof_age', target: 'lead.roofAge', label: 'Approximate roof age?', transform: 'int', validation: '', rationale: 'Separates replacement-ready from early research.' },
|
||||
{ source: 'financing_interest', target: 'lead.financingInterest', label: 'Interested in financing options?', transform: 'bool', validation: '', rationale: 'Surfaces financing intent without asking for financial details.' },
|
||||
{ source: 'timeline', target: 'lead.timeline', label: 'What is your project timeline?', transform: 'none', validation: '', rationale: 'Prioritizes near-term buyers.' },
|
||||
],
|
||||
emergency: [
|
||||
{ source: 'active_leak', target: 'lead.urgencyFlag', label: 'Is water coming in right now?', transform: 'bool', validation: '', rationale: 'Triggers emergency dispatch within the SLA.' },
|
||||
{ source: 'best_callback', target: 'contact.phone', label: 'Best callback number?', transform: 'e164', validation: 'phone', rationale: 'Enables immediate human escalation.' },
|
||||
],
|
||||
commercial: [
|
||||
{ source: 'property_type', target: 'lead.propertyType', label: 'What type of property is it?', transform: 'none', validation: '', rationale: 'Qualifies the commercial scope.' },
|
||||
{ source: 'square_footage', target: 'lead.squareFootage', label: 'Approximate square footage?', transform: 'int', validation: '', rationale: 'Sizes the bid and assigns the right estimator.' },
|
||||
{ source: 'decision_timeline', target: 'lead.timeline', label: 'Decision timeline?', transform: 'none', validation: '', rationale: 'Prioritizes active buyers.' },
|
||||
],
|
||||
referral: [],
|
||||
maintenance: [
|
||||
{ source: 'past_customer', target: 'lead.pastCustomer', label: 'Are you a past customer?', transform: 'bool', validation: '', rationale: 'Routes reactivation vs. new prospect.' },
|
||||
{ source: 'warranty_status', target: 'lead.warrantyStatus', label: 'Is your warranty still active?', transform: 'none', validation: '', rationale: 'Targets warranty-driven check-ups.' },
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Suggest up to 4 qualifying questions not already on the form.
|
||||
* Returns [{ source, target, label, transform, validation, rationale }].
|
||||
*/
|
||||
export function suggestQuestions(form, serviceType = 'storm') {
|
||||
const existing = new Set((form.fields || []).flatMap(f => [f.source, f.target]));
|
||||
const pool = [...(BY_SERVICE[serviceType] || []), ...GENERAL];
|
||||
const picks = [];
|
||||
for (const q of pool) {
|
||||
if (existing.has(q.source) || existing.has(q.target)) continue;
|
||||
if (picks.some(p => p.target === q.target || p.source === q.source)) continue;
|
||||
picks.push(q);
|
||||
if (picks.length >= 4) break;
|
||||
}
|
||||
return picks;
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* ui.jsx — shared presentational primitives for the Social Ads Engine screens.
|
||||
*
|
||||
* Reuses the CRM's SpotlightCard look and Tailwind tokens (zinc surfaces, amber/blue
|
||||
* accents) so the module feels native to the rest of the app. Pure UI, no state.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { SpotlightCard } from '../../../components/SpotlightCard';
|
||||
import { getPlatform, CAPABILITY_LABEL } from '../data/platforms.js';
|
||||
|
||||
// ── Card ───────────────────────────────────────────────────────────────────
|
||||
export function Card({ title, subtitle, icon: Icon, action, children, className = '', pad = 'p-5' }) {
|
||||
return (
|
||||
<SpotlightCard className={`h-full ${className}`}>
|
||||
<div className={`${pad} flex-1 flex flex-col`}>
|
||||
{(title || action) && (
|
||||
<div className="flex items-start justify-between mb-4 gap-3">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{Icon && <Icon size={18} className="text-amber-500 shrink-0" />}
|
||||
<div className="min-w-0">
|
||||
{title && <h3 className="text-base font-bold text-zinc-900 dark:text-white truncate">{title}</h3>}
|
||||
{subtitle && <p className="text-xs text-zinc-500 dark:text-zinc-400">{subtitle}</p>}
|
||||
</div>
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
</SpotlightCard>
|
||||
);
|
||||
}
|
||||
|
||||
// ── KPI tile ─────────────────────────────────────────────────────────────────
|
||||
const KPI_COLORS = {
|
||||
blue: 'text-blue-500 bg-blue-500/10',
|
||||
emerald: 'text-emerald-500 bg-emerald-500/10',
|
||||
amber: 'text-amber-500 bg-amber-500/10',
|
||||
red: 'text-red-500 bg-red-500/10',
|
||||
purple: 'text-purple-500 bg-purple-500/10',
|
||||
cyan: 'text-cyan-500 bg-cyan-500/10',
|
||||
};
|
||||
|
||||
export function KpiTile({ label, value, sub, icon: Icon, color = 'blue', trend }) {
|
||||
const cls = KPI_COLORS[color] || KPI_COLORS.blue;
|
||||
const [text, bg] = cls.split(' ');
|
||||
return (
|
||||
<SpotlightCard className="h-full">
|
||||
<div className="p-4 sm:p-5 flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-zinc-500 dark:text-zinc-400 truncate">{label}</p>
|
||||
<p className={`mt-1.5 text-2xl font-mono font-bold ${text} tracking-tight`}>{value}</p>
|
||||
{sub && <p className="text-[11px] text-zinc-400 mt-1">{sub}</p>}
|
||||
{typeof trend === 'number' && (
|
||||
<span className={`text-[11px] font-semibold ${trend >= 0 ? 'text-emerald-500' : 'text-red-500'}`}>
|
||||
{trend >= 0 ? '▲' : '▼'} {Math.abs(trend)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{Icon && <div className={`p-2.5 rounded-xl ${bg} ${text} shrink-0`}><Icon size={20} /></div>}
|
||||
</div>
|
||||
</SpotlightCard>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Platform badge ───────────────────────────────────────────────────────────
|
||||
export function PlatformBadge({ platformId, size = 'md', showName = true }) {
|
||||
const p = getPlatform(platformId);
|
||||
if (!p) return null;
|
||||
const Icon = p.icon;
|
||||
const dim = size === 'sm' ? 14 : 16;
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 font-semibold text-zinc-700 dark:text-zinc-200">
|
||||
<span className="inline-flex items-center justify-center rounded-md p-1" style={{ backgroundColor: `${p.color}1A`, color: p.color }}>
|
||||
<Icon size={dim} />
|
||||
</span>
|
||||
{showName && <span className={size === 'sm' ? 'text-xs' : 'text-sm'}>{p.short}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Generic status chip ──────────────────────────────────────────────────────
|
||||
const CHIP = {
|
||||
// account / generic
|
||||
connected: 'bg-emerald-100 dark:bg-emerald-500/15 text-emerald-700 dark:text-emerald-400',
|
||||
disconnected: 'bg-zinc-100 dark:bg-zinc-700/40 text-zinc-500 dark:text-zinc-400',
|
||||
action_required: 'bg-amber-100 dark:bg-amber-500/15 text-amber-700 dark:text-amber-400',
|
||||
// campaign
|
||||
active: 'bg-emerald-100 dark:bg-emerald-500/15 text-emerald-700 dark:text-emerald-400',
|
||||
paused: 'bg-amber-100 dark:bg-amber-500/15 text-amber-700 dark:text-amber-400',
|
||||
draft: 'bg-zinc-100 dark:bg-zinc-700/40 text-zinc-500 dark:text-zinc-300',
|
||||
// approval / policy
|
||||
approved: 'bg-emerald-100 dark:bg-emerald-500/15 text-emerald-700 dark:text-emerald-400',
|
||||
pending: 'bg-amber-100 dark:bg-amber-500/15 text-amber-700 dark:text-amber-400',
|
||||
review: 'bg-amber-100 dark:bg-amber-500/15 text-amber-700 dark:text-amber-400',
|
||||
blocked: 'bg-red-100 dark:bg-red-500/15 text-red-700 dark:text-red-400',
|
||||
clear: 'bg-emerald-100 dark:bg-emerald-500/15 text-emerald-700 dark:text-emerald-400',
|
||||
// lead / sync
|
||||
new: 'bg-blue-100 dark:bg-blue-500/15 text-blue-700 dark:text-blue-400',
|
||||
assigned: 'bg-emerald-100 dark:bg-emerald-500/15 text-emerald-700 dark:text-emerald-400',
|
||||
merged: 'bg-zinc-100 dark:bg-zinc-700/40 text-zinc-500 dark:text-zinc-400',
|
||||
synced: 'bg-emerald-100 dark:bg-emerald-500/15 text-emerald-700 dark:text-emerald-400',
|
||||
queued: 'bg-amber-100 dark:bg-amber-500/15 text-amber-700 dark:text-amber-400',
|
||||
failed: 'bg-red-100 dark:bg-red-500/15 text-red-700 dark:text-red-400',
|
||||
};
|
||||
|
||||
export function StatusChip({ status, label }) {
|
||||
const cls = CHIP[status] || CHIP.draft;
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider ${cls}`}>
|
||||
{label || String(status).replace(/_/g, ' ')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Capability badge (spec §11 — honest automation level) ──────────────────────
|
||||
export function CapabilityBadge({ capability, active }) {
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-md text-[10px] font-semibold border ${active
|
||||
? 'border-emerald-300 dark:border-emerald-500/30 text-emerald-700 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-500/10'
|
||||
: 'border-zinc-200 dark:border-zinc-700 text-zinc-400 dark:text-zinc-600 line-through'}`}>
|
||||
{CAPABILITY_LABEL[capability] || capability}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Section header for screens ─────────────────────────────────────────────────
|
||||
export function SectionHeader({ icon: Icon, title, description, children }) {
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-5">
|
||||
<div className="flex items-center gap-3">
|
||||
{Icon && (
|
||||
<div className="p-2 rounded-xl bg-amber-500/10 text-amber-500 shrink-0">
|
||||
<Icon size={22} />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h2 className="text-lg sm:text-xl font-bold text-zinc-900 dark:text-white">{title}</h2>
|
||||
{description && <p className="text-xs sm:text-sm text-zinc-500 dark:text-zinc-400">{description}</p>}
|
||||
</div>
|
||||
</div>
|
||||
{children && <div className="flex items-center gap-2 shrink-0">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Small button ───────────────────────────────────────────────────────────────
|
||||
export function Btn({ children, onClick, variant = 'primary', size = 'md', icon: Icon, disabled, type = 'button', className = '', title }) {
|
||||
const base = 'inline-flex items-center justify-center gap-1.5 font-semibold rounded-xl transition-all focus:outline-none focus:ring-2 focus:ring-amber-500 disabled:opacity-40 disabled:cursor-not-allowed';
|
||||
const sizes = { sm: 'px-2.5 py-1.5 text-xs', md: 'px-4 py-2 text-sm' };
|
||||
const variants = {
|
||||
primary: 'bg-gradient-to-r from-amber-400 to-orange-500 text-white shadow-lg shadow-amber-500/25 hover:shadow-amber-500/40',
|
||||
ghost: 'text-zinc-600 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-white/10',
|
||||
outline: 'border border-zinc-200 dark:border-white/15 text-zinc-700 dark:text-zinc-200 hover:bg-zinc-100 dark:hover:bg-white/5',
|
||||
danger: 'bg-red-500/10 text-red-600 dark:text-red-400 hover:bg-red-500/20',
|
||||
success: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-500/20',
|
||||
};
|
||||
return (
|
||||
<button type={type} title={title} onClick={onClick} disabled={disabled} className={`${base} ${sizes[size]} ${variants[variant]} ${className}`}>
|
||||
{Icon && <Icon size={size === 'sm' ? 14 : 16} />}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* blueprints.js — prebuilt roofing/construction campaign recipes (spec §4, §9).
|
||||
*
|
||||
* These are the tenant-cloneable templates surfaced in the Campaign Builder.
|
||||
* Lead-form questions and mapped nurture paths come straight from spec §9.1.
|
||||
* Front-end demo data only.
|
||||
*/
|
||||
|
||||
export const BLUEPRINTS = [
|
||||
{
|
||||
id: 'bp_storm',
|
||||
name: 'Storm inspection response',
|
||||
objective: 'lead_generation',
|
||||
serviceType: 'storm',
|
||||
bestChannels: ['meta', 'google', 'nextdoor'],
|
||||
offer: 'Free post-storm roof inspection',
|
||||
formQuestions: [
|
||||
'Property address', 'Storm date (if known)', 'Active leak?',
|
||||
'Homeowner / tenant', 'Preferred contact time',
|
||||
],
|
||||
nurture: 'Social Lead → Appointment → Storm Inspection Nurture → Insurance Education',
|
||||
adCopy: 'Recent hail or wind in {{City}}? A roof inspection can document visible damage, ventilation concerns, and repair/replacement options. Request an inspection window.',
|
||||
icon: 'CloudLightning',
|
||||
accent: 'amber',
|
||||
},
|
||||
{
|
||||
id: 'bp_retail',
|
||||
name: 'Retail roof replacement',
|
||||
objective: 'lead_generation',
|
||||
serviceType: 'replacement',
|
||||
bestChannels: ['google', 'meta', 'nextdoor'],
|
||||
offer: 'Free replacement quote + financing options',
|
||||
formQuestions: [
|
||||
'Roof age', 'Leak / aging / planned upgrade', 'Timeline', 'Financing interest',
|
||||
],
|
||||
nurture: 'Retail Replacement Nurture → Upgrade Education → Appointment Reminder',
|
||||
adCopy: 'Roof showing its age? Compare Good / Better / Best replacement options with transparent pricing and financing. Get a free quote.',
|
||||
icon: 'Home',
|
||||
accent: 'blue',
|
||||
},
|
||||
{
|
||||
id: 'bp_emergency',
|
||||
name: 'Emergency leak / tarping',
|
||||
objective: 'lead_generation',
|
||||
serviceType: 'emergency',
|
||||
bestChannels: ['google', 'meta', 'whatsapp'],
|
||||
offer: 'Same-day emergency leak response',
|
||||
formQuestions: [
|
||||
'Active leak?', 'Safe photo upload', 'Address', 'Urgency', 'Best callback number',
|
||||
],
|
||||
nurture: 'Emergency Dispatch Nurture → Human escalation within 10 minutes',
|
||||
adCopy: 'Roof leaking right now? Our emergency crew can tarp and stop the damage fast. Request urgent help.',
|
||||
icon: 'Siren',
|
||||
accent: 'red',
|
||||
},
|
||||
{
|
||||
id: 'bp_commercial',
|
||||
name: 'Commercial / property manager bid',
|
||||
objective: 'lead_generation',
|
||||
serviceType: 'commercial',
|
||||
bestChannels: ['linkedin', 'google', 'reddit'],
|
||||
offer: 'Commercial roof assessment + decision-maker checklist',
|
||||
formQuestions: [
|
||||
'Company', 'Role', 'Property type', 'Square footage (if known)',
|
||||
'Site access', 'Decision timeline',
|
||||
],
|
||||
nurture: 'Commercial Bid Nurture → Decision-maker Confirmation → Capability Statement',
|
||||
adCopy: 'Need a commercial roof assessment or bid package? Request a site review and receive a decision-maker checklist before the meeting.',
|
||||
icon: 'Building2',
|
||||
accent: 'purple',
|
||||
},
|
||||
{
|
||||
id: 'bp_unsold',
|
||||
name: 'Unsold estimate retargeting',
|
||||
objective: 'retargeting',
|
||||
serviceType: 'replacement',
|
||||
bestChannels: ['meta', 'google', 'reddit', 'linkedin'],
|
||||
offer: 'No new form — uses CRM audience',
|
||||
formQuestions: ['No new form required; uses CRM audience'],
|
||||
nurture: 'Unsold Estimate Warm/Cold Nurture → Financing or Options Compare',
|
||||
adCopy: 'Still weighing your roof options? See how financing changes the monthly number — and what a Class 4 upgrade protects.',
|
||||
icon: 'RefreshCw',
|
||||
accent: 'cyan',
|
||||
},
|
||||
{
|
||||
id: 'bp_review',
|
||||
name: 'Review / referral flywheel',
|
||||
objective: 'engagement',
|
||||
serviceType: 'referral',
|
||||
bestChannels: ['meta', 'nextdoor'],
|
||||
offer: 'Referral landing page / review CTA',
|
||||
formQuestions: ['Referral landing page or review CTA'],
|
||||
nurture: 'Completion Promoter Path → Review → Referral Offer (if allowed)',
|
||||
adCopy: 'Loved your new roof? Share your experience with neighbors — and pass along a referral if you know someone who needs us.',
|
||||
icon: 'Star',
|
||||
accent: 'emerald',
|
||||
},
|
||||
{
|
||||
id: 'bp_seasonal',
|
||||
name: 'Seasonal maintenance / reactivation',
|
||||
objective: 'reactivation',
|
||||
serviceType: 'maintenance',
|
||||
bestChannels: ['nextdoor', 'meta', 'google', 'whatsapp'],
|
||||
offer: 'Annual inspection check-in',
|
||||
formQuestions: ['Past customer check-in', 'Inspection request', 'Warranty check'],
|
||||
nurture: 'Reactivation → Annual Inspection → Storm Trigger Retarget',
|
||||
adCopy: 'It has been a while since your last roof check-up. Book a seasonal inspection and keep your warranty on track.',
|
||||
icon: 'CalendarClock',
|
||||
accent: 'amber',
|
||||
},
|
||||
];
|
||||
|
||||
export const BLUEPRINT_BY_ID = Object.fromEntries(BLUEPRINTS.map(b => [b.id, b]));
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* contactPool.js — mock CRM contact universe for the Audience segment builder
|
||||
* (spec §12 E1 "filter CRM contacts by stage, source, service, last job,
|
||||
* geography, consent" and §8 preview count).
|
||||
*
|
||||
* Front-end demo: a deterministic, stable pool generated from each index (no
|
||||
* Math.random / Date so it never shifts between reloads). previewSegment() filters
|
||||
* it and returns counts AFTER opt-out and suppression filtering — exactly what the
|
||||
* spec's /v1/audience-segments/preview endpoint is meant to return.
|
||||
*/
|
||||
|
||||
export const FILTER_OPTIONS = {
|
||||
stages: ['lead', 'appointment', 'estimate', 'won', 'lost', 'customer'],
|
||||
sources: ['meta', 'google', 'linkedin', 'nextdoor', 'reddit', 'referral', 'organic'],
|
||||
services: ['storm', 'replacement', 'emergency', 'commercial', 'maintenance', 'referral'],
|
||||
zips: ['75023', '75024', '75025', '75074', '75075', '75093', '75094'],
|
||||
};
|
||||
|
||||
const POOL_SIZE = 1400;
|
||||
|
||||
// Knuth multiplicative hash → stable per-index pseudo-randomness.
|
||||
const hash = (i) => (Math.imul(i + 1, 2654435761) >>> 0);
|
||||
|
||||
export const CONTACT_POOL = Array.from({ length: POOL_SIZE }, (_, i) => {
|
||||
const h = hash(i);
|
||||
return {
|
||||
id: `ct_${i}`,
|
||||
stage: FILTER_OPTIONS.stages[h % FILTER_OPTIONS.stages.length],
|
||||
source: FILTER_OPTIONS.sources[(h >> 3) % FILTER_OPTIONS.sources.length],
|
||||
service: FILTER_OPTIONS.services[(h >> 6) % FILTER_OPTIONS.services.length],
|
||||
zip: FILTER_OPTIONS.zips[(h >> 9) % FILTER_OPTIONS.zips.length],
|
||||
lastJobDaysAgo: (h >> 12) % 540, // 0–540 days
|
||||
consent: ((h >> 16) % 100) < 88, // ~88% have marketing consent
|
||||
suppressed: ((h >> 20) % 100) < 9, // ~9% on the suppression list
|
||||
};
|
||||
});
|
||||
|
||||
const isAny = (v) => !v || v === 'any';
|
||||
|
||||
/**
|
||||
* Estimate a segment's reach. Returns matched / consented / suppressed / eligible,
|
||||
* where eligible = matched ∩ consented ∩ not-suppressed (consent + suppression are
|
||||
* mandatory per spec §6, so the eligible figure always applies both).
|
||||
*/
|
||||
export function previewSegment(f = {}) {
|
||||
const geo = Array.isArray(f.geo) && f.geo.length ? f.geo : null;
|
||||
const matched = CONTACT_POOL.filter(c =>
|
||||
(isAny(f.stage) || c.stage === f.stage) &&
|
||||
(isAny(f.source) || c.source === f.source) &&
|
||||
(isAny(f.service) || c.service === f.service) &&
|
||||
(!geo || geo.includes(c.zip)) &&
|
||||
(!f.lastJobWithinDays || c.lastJobDaysAgo <= f.lastJobWithinDays),
|
||||
);
|
||||
const consented = matched.filter(c => c.consent).length;
|
||||
const suppressed = matched.filter(c => c.suppressed).length;
|
||||
const eligible = matched.filter(c => c.consent && !c.suppressed).length;
|
||||
return { matched: matched.length, consented, suppressed, eligible };
|
||||
}
|
||||
|
||||
/** Human-readable one-liner for a segment's filters (shown on the card). */
|
||||
export function summarizeFilters(f = {}) {
|
||||
const bits = [];
|
||||
if (!isAny(f.stage)) bits.push(`stage: ${f.stage}`);
|
||||
if (!isAny(f.source)) bits.push(f.source);
|
||||
if (!isAny(f.service)) bits.push(f.service);
|
||||
if (Array.isArray(f.geo) && f.geo.length) bits.push(`ZIP ${f.geo.join('/')}`);
|
||||
if (f.lastJobWithinDays) bits.push(`last job < ${f.lastJobWithinDays}d`);
|
||||
if (f.frequencyCap) bits.push(`freq cap ${f.frequencyCap}/wk`);
|
||||
bits.push('consented • suppression applied');
|
||||
return bits.join(' • ');
|
||||
}
|
||||
|
||||
export const DEFAULT_FILTER_DEF = {
|
||||
stage: 'any', source: 'any', service: 'any', geo: [],
|
||||
lastJobWithinDays: null, frequencyCap: 3,
|
||||
};
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* platforms.js — the six ad platforms the Social Ads Engine targets (spec §3).
|
||||
*
|
||||
* Per spec §11, the UI must never pretend full automation exists where a platform
|
||||
* only supports a subset. Each platform therefore declares an explicit set of
|
||||
* CAPABILITIES, and every screen reads these flags to honestly show what is
|
||||
* automated vs. manual/import-only. Front-end demo data only — no real OAuth or APIs.
|
||||
*
|
||||
* Manufacturer/brand colors are used purely for recognizability in the demo.
|
||||
*/
|
||||
import { Facebook, Search, Linkedin, MessageCircle, Phone, Home } from 'lucide-react';
|
||||
|
||||
// Capability vocabulary (spec §11 feature flags by platform capability).
|
||||
export const CAPABILITY = {
|
||||
INGEST: 'ingest', // can receive leads (webhook / lead sync)
|
||||
REPORT: 'report', // can pull spend/impressions/clicks/leads
|
||||
CONVERSION_SYNC: 'conversion-sync', // can push offline/conversion events back
|
||||
DRAFT_BUILDER: 'draft-builder', // can assemble a platform-ready draft in-app
|
||||
FULL_PUBLISH: 'full-publish', // can publish a campaign directly via API
|
||||
};
|
||||
|
||||
export const CAPABILITY_LABEL = {
|
||||
[CAPABILITY.INGEST]: 'Lead ingest',
|
||||
[CAPABILITY.REPORT]: 'Reporting',
|
||||
[CAPABILITY.CONVERSION_SYNC]: 'Conversion sync',
|
||||
[CAPABILITY.DRAFT_BUILDER]: 'Draft builder',
|
||||
[CAPABILITY.FULL_PUBLISH]: 'Full publish',
|
||||
};
|
||||
|
||||
export const PLATFORMS = [
|
||||
{
|
||||
id: 'meta',
|
||||
name: 'Facebook + Instagram',
|
||||
short: 'Meta',
|
||||
icon: Facebook,
|
||||
color: '#1877F2',
|
||||
role: 'Local awareness, storm response, instant-form leads, retargeting, social proof.',
|
||||
integrationPath: 'Meta Lead Ads / Instant Forms + webhook ingestion; Marketing API; Conversions API.',
|
||||
cautions: 'App review, business verification, token lifecycle, lead retention windows.',
|
||||
capabilities: [
|
||||
CAPABILITY.INGEST, CAPABILITY.REPORT, CAPABILITY.CONVERSION_SYNC,
|
||||
CAPABILITY.DRAFT_BUILDER, CAPABILITY.FULL_PUBLISH,
|
||||
],
|
||||
copyGuidance: 'Local, visual, simple offer, fast callback. Avoid panic/fear language after storms.',
|
||||
},
|
||||
{
|
||||
id: 'google',
|
||||
name: 'Google Ads',
|
||||
short: 'Google',
|
||||
icon: Search,
|
||||
color: '#4285F4',
|
||||
role: 'High-intent search, local services, lead-form assets, retargeting.',
|
||||
integrationPath: 'Google Ads Lead Form Webhook; Ads API for reporting; offline conversion upload.',
|
||||
cautions: 'Webhook key validation, OAuth/developer token, call tracking, conversion dedup.',
|
||||
capabilities: [
|
||||
CAPABILITY.INGEST, CAPABILITY.REPORT, CAPABILITY.CONVERSION_SYNC, CAPABILITY.DRAFT_BUILDER,
|
||||
],
|
||||
copyGuidance: 'Intent matching. Align ad text and landing/form to the exact search need.',
|
||||
},
|
||||
{
|
||||
id: 'linkedin',
|
||||
name: 'LinkedIn',
|
||||
short: 'LinkedIn',
|
||||
icon: Linkedin,
|
||||
color: '#0A66C2',
|
||||
role: 'Commercial roofing, property managers, builders, facilities, HOA and B2B.',
|
||||
integrationPath: 'Lead Gen Forms + Lead Sync APIs; campaign/reporting via Marketing API program.',
|
||||
cautions: 'Partner/API access, B2B field mapping, leadSync permissions, lower volume.',
|
||||
capabilities: [CAPABILITY.INGEST, CAPABILITY.REPORT, CAPABILITY.DRAFT_BUILDER],
|
||||
copyGuidance: 'Professional and proof-driven. Focus owners, facilities, associations, builders.',
|
||||
},
|
||||
{
|
||||
id: 'reddit',
|
||||
name: 'Reddit',
|
||||
short: 'Reddit',
|
||||
icon: MessageCircle,
|
||||
color: '#FF4500',
|
||||
role: 'Niche local research, homeowner communities, storm/insurance discussion, B2B education.',
|
||||
integrationPath: 'Reddit Lead Generation Ads where available; Zapier/partner path; Conversions API.',
|
||||
cautions: 'Community tone differs; avoid promotional copy; beta limitations.',
|
||||
capabilities: [CAPABILITY.INGEST, CAPABILITY.REPORT],
|
||||
copyGuidance: 'Value-first, specific, less polished. Offer checklist/calculator. Avoid corporate hype.',
|
||||
},
|
||||
{
|
||||
id: 'whatsapp',
|
||||
name: 'WhatsApp',
|
||||
short: 'WhatsApp',
|
||||
icon: Phone,
|
||||
color: '#25D366',
|
||||
role: 'Conversational follow-up in markets where customers prefer WhatsApp.',
|
||||
integrationPath: 'WhatsApp Business Platform / Cloud API, approved templates, session messaging.',
|
||||
cautions: 'Template approval, opt-in, conversation windows, category rules, pricing.',
|
||||
capabilities: [CAPABILITY.INGEST],
|
||||
copyGuidance: 'Conversational, short, service-oriented. Use approved templates; shift to human for complex issues.',
|
||||
},
|
||||
{
|
||||
id: 'nextdoor',
|
||||
name: 'Nextdoor',
|
||||
short: 'Nextdoor',
|
||||
icon: Home,
|
||||
color: '#8B6F47',
|
||||
role: 'Hyperlocal neighborhood credibility, storm response, referral/recommendation demand.',
|
||||
integrationPath: 'Nextdoor developer/Ads API where eligible; conversion API; manual/import fallback.',
|
||||
cautions: 'API access may require approval; do not scrape neighborhood content; community etiquette.',
|
||||
capabilities: [CAPABILITY.REPORT],
|
||||
copyGuidance: 'Neighborly, hyperlocal, trust- and reputation-focused. Avoid spammy automation or scraping.',
|
||||
},
|
||||
];
|
||||
|
||||
export const PLATFORM_BY_ID = Object.fromEntries(PLATFORMS.map(p => [p.id, p]));
|
||||
|
||||
export const getPlatform = (id) => PLATFORM_BY_ID[id] ?? null;
|
||||
|
||||
export const hasCapability = (platformId, capability) =>
|
||||
!!PLATFORM_BY_ID[platformId]?.capabilities.includes(capability);
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* publishChecklist.js — generates a platform-ready manual setup checklist for
|
||||
* campaigns on channels without a full-publish API (spec §5 fallback, §11 capability
|
||||
* honesty). The steps are filled with the campaign's real objective, budget caps,
|
||||
* schedule, approved creative, form fields, tracking link, and webhook so an admin can
|
||||
* reproduce the campaign in the platform's native ads manager, then mark it live.
|
||||
*/
|
||||
import { getPlatform } from './platforms.js';
|
||||
import { BLUEPRINT_BY_ID } from './blueprints.js';
|
||||
import { buildUtm } from './utm.js';
|
||||
|
||||
const PLATFORM_TIP = {
|
||||
reddit: 'Reddit lead APIs are limited — drive clicks to the tracked LynkedUp landing page and ingest submissions via webhook/CSV rather than native lead forms.',
|
||||
whatsapp: 'Use Click-to-WhatsApp ads or the WhatsApp Business app; route inbound conversations into LynkedUp through the WhatsApp Business webhook.',
|
||||
nextdoor: 'Nextdoor Ads are managed in Nextdoor Business Center; use a tracked landing-page link and confirm neighborhood-level targeting.',
|
||||
default: 'This platform has no full-publish API — complete setup in its native ads manager, then return to mark the campaign live.',
|
||||
};
|
||||
|
||||
const money = (n) => `$${Number(n || 0).toLocaleString('en-US')}`;
|
||||
|
||||
/** Build a structured, value-filled checklist for a manual-publish campaign. */
|
||||
export function buildChecklist(campaign, { creative, form } = {}) {
|
||||
const p = getPlatform(campaign.platform);
|
||||
const g = campaign.guardrails || {};
|
||||
const pauseRules = [
|
||||
g.pauseOnDailyCap && 'daily cap',
|
||||
g.pauseOnCplBreach && 'CPL breach',
|
||||
g.anomalyDetection && 'spend anomaly',
|
||||
].filter(Boolean).join(', ') || 'none';
|
||||
|
||||
const formFields = form?.fields?.map(f => f.target || f.source)
|
||||
|| BLUEPRINT_BY_ID[campaign.blueprintId]?.formQuestions
|
||||
|| ['name', 'phone', 'email', 'address', 'consent'];
|
||||
|
||||
const steps = [
|
||||
{
|
||||
title: `Open ${p?.name || 'the platform'} Ads Manager`,
|
||||
detail: `Create a new ${p?.short || ''} campaign. Account capability: ${p?.role || 'manual setup only'}.`,
|
||||
},
|
||||
{
|
||||
title: 'Set objective & campaign name',
|
||||
detail: `Objective: ${(campaign.objective || 'lead_generation').replace(/_/g, ' ')}. Name: “${campaign.name}”.`,
|
||||
},
|
||||
{
|
||||
title: 'Set geography / targeting',
|
||||
detail: campaign.serviceArea || 'Define the service-area ZIPs, city, or radius.',
|
||||
},
|
||||
{
|
||||
title: 'Set budget & spend caps',
|
||||
detail: `Daily ${money(campaign.budgetDaily)} (total ${money(campaign.budgetTotal)}). Enforce caps — weekly ${money(g.weeklyCap)}, monthly ${money(g.monthlyCap)}. Alert at ${g.alertPct || 80}% of cap or CPL > ${money(g.cplThreshold)}. Auto-pause on: ${pauseRules}.`,
|
||||
},
|
||||
{
|
||||
title: 'Set flight dates',
|
||||
detail: `${campaign.startAt} → ${campaign.endAt}.`,
|
||||
},
|
||||
{
|
||||
title: 'Add the approved creative',
|
||||
detail: creative
|
||||
? `Headline: “${creative.headline}”\nBody: “${creative.body}”${creative.description ? `\nDescription: “${creative.description}”` : ''}`
|
||||
: 'Paste a policy-clear, approved creative from Creative Studio.',
|
||||
},
|
||||
{
|
||||
title: 'Configure the lead form & consent',
|
||||
detail: `Capture fields: ${formFields.join(', ')}. ${form ? 'Apply the locked consent text + privacy URL from the Lead Form Mapper.' : 'Use the default capture form’s consent text + privacy URL.'}`,
|
||||
},
|
||||
{
|
||||
title: 'Add the tracking link',
|
||||
detail: `Point the ad to the tracked landing page (UTM parameters pre-filled for attribution):\n${buildUtm(campaign, { creative }).url}`,
|
||||
},
|
||||
{
|
||||
title: 'Connect lead ingestion',
|
||||
detail: `In Connected Accounts → ${p?.short || 'this platform'}, verify the webhook so submissions ingest into the Unified Lead Inbox.`,
|
||||
},
|
||||
{
|
||||
title: 'Submit & launch in-platform',
|
||||
detail: `Submit for ${p?.short || 'platform'} review, then set the campaign live. Return here to mark setup complete.`,
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
platform: campaign.platform,
|
||||
platformName: p?.name || campaign.platform,
|
||||
tip: PLATFORM_TIP[campaign.platform] || PLATFORM_TIP.default,
|
||||
steps,
|
||||
};
|
||||
}
|
||||
|
||||
/** Flatten a checklist to copyable plain text (for "Copy instructions"). */
|
||||
export function checklistToText(campaign, checklist) {
|
||||
const lines = [
|
||||
`Manual setup checklist — ${campaign.name}`,
|
||||
`Platform: ${checklist.platformName}`,
|
||||
`Note: ${checklist.tip}`,
|
||||
'',
|
||||
...checklist.steps.map((s, i) => `${i + 1}. ${s.title}\n ${s.detail.replace(/\n/g, '\n ')}`),
|
||||
];
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -0,0 +1,514 @@
|
||||
/**
|
||||
* seed.js — initial mock data for the Social Ads Engine demo (spec §7 data model).
|
||||
*
|
||||
* Each export maps to a spec entity:
|
||||
* accounts → ad_accounts
|
||||
* campaigns → ad_campaigns
|
||||
* creatives → ad_creatives
|
||||
* leadForms → lead_forms (+ field_mappings inline)
|
||||
* leads → ad_leads
|
||||
* nurtureRules → ad-to-nurture rules (spec §5, §8)
|
||||
* audiences → audience_segments
|
||||
* conversionEvents → conversion_events
|
||||
* approvals → creative_reviews / compliance queue
|
||||
* analyticsDaily → campaign_analytics_daily
|
||||
*
|
||||
* Dates are fixed strings so the demo never drifts. Front-end only; no backend.
|
||||
*/
|
||||
|
||||
// ── ad_accounts ──────────────────────────────────────────────────────────────
|
||||
export const accounts = [
|
||||
{
|
||||
id: 'acc_meta', platform: 'meta', name: 'LynkedUp Roofing — Meta Business',
|
||||
externalAccountId: 'act_109_445_882', status: 'connected',
|
||||
tokenStatus: 'healthy', tokenExpiresAt: '2026-09-02', webhookStatus: 'verified',
|
||||
lastSyncAt: '2026-06-10T07:40:00Z', scopes: ['leads_retrieval', 'ads_read', 'ads_management'],
|
||||
},
|
||||
{
|
||||
id: 'acc_google', platform: 'google', name: 'LynkedUp Roofing — Google Ads',
|
||||
externalAccountId: '742-553-9981', status: 'connected',
|
||||
tokenStatus: 'healthy', tokenExpiresAt: '2026-08-19', webhookStatus: 'verified',
|
||||
lastSyncAt: '2026-06-10T07:38:00Z', scopes: ['adwords', 'lead_form_webhook'],
|
||||
},
|
||||
{
|
||||
id: 'acc_linkedin', platform: 'linkedin', name: 'LynkedUp Commercial — LinkedIn',
|
||||
externalAccountId: '510882', status: 'connected',
|
||||
tokenStatus: 'expiring', tokenExpiresAt: '2026-06-15', webhookStatus: 'verified',
|
||||
lastSyncAt: '2026-06-09T22:10:00Z', scopes: ['r_ads', 'r_leadgen', 'rw_ads'],
|
||||
},
|
||||
{
|
||||
id: 'acc_reddit', platform: 'reddit', name: 'LynkedUp — Reddit Ads',
|
||||
externalAccountId: 't2_8h3k1', status: 'action_required',
|
||||
tokenStatus: 'revoked', tokenExpiresAt: null, webhookStatus: 'failing',
|
||||
lastSyncAt: '2026-06-07T14:02:00Z', scopes: [],
|
||||
},
|
||||
{
|
||||
id: 'acc_whatsapp', platform: 'whatsapp', name: 'WhatsApp Business — (972) 555-0100',
|
||||
externalAccountId: 'waba_338201', status: 'disconnected',
|
||||
tokenStatus: 'none', tokenExpiresAt: null, webhookStatus: 'not_configured',
|
||||
lastSyncAt: null, scopes: [],
|
||||
},
|
||||
{
|
||||
id: 'acc_nextdoor', platform: 'nextdoor', name: 'LynkedUp Roofing — Nextdoor',
|
||||
externalAccountId: 'nd_ads_44120', status: 'connected',
|
||||
tokenStatus: 'healthy', tokenExpiresAt: '2026-12-01', webhookStatus: 'manual',
|
||||
lastSyncAt: '2026-06-10T06:00:00Z', scopes: ['ads_read'],
|
||||
},
|
||||
];
|
||||
|
||||
// ── ad_campaigns ─────────────────────────────────────────────────────────────
|
||||
export const campaigns = [
|
||||
{
|
||||
id: 'camp_1', blueprintId: 'bp_storm', platform: 'meta', name: 'Plano Hail Response — June',
|
||||
objective: 'lead_generation', serviceType: 'storm', status: 'active',
|
||||
budgetDaily: 120, budgetTotal: 3600, startAt: '2026-06-01', endAt: '2026-06-30',
|
||||
playbook: 'Storm Inspection Nurture', approvalStatus: 'approved',
|
||||
serviceArea: 'Plano, TX 75023 / 75025 (hail-hit ZIPs)',
|
||||
},
|
||||
{
|
||||
id: 'camp_2', blueprintId: 'bp_retail', platform: 'google', name: 'Roof Replacement — North Dallas Search',
|
||||
objective: 'lead_generation', serviceType: 'replacement', status: 'active',
|
||||
budgetDaily: 95, budgetTotal: 2850, startAt: '2026-05-20', endAt: '2026-06-30',
|
||||
playbook: 'Retail Replacement Nurture', approvalStatus: 'approved',
|
||||
serviceArea: 'Plano / Allen / Frisco, TX',
|
||||
},
|
||||
{
|
||||
id: 'camp_3', blueprintId: 'bp_commercial', platform: 'linkedin', name: 'Commercial Bid — Property Managers',
|
||||
objective: 'lead_generation', serviceType: 'commercial', status: 'active',
|
||||
budgetDaily: 70, budgetTotal: 2100, startAt: '2026-05-25', endAt: '2026-07-15',
|
||||
playbook: 'Commercial Bid Nurture', approvalStatus: 'approved',
|
||||
serviceArea: 'DFW Metro commercial corridors',
|
||||
},
|
||||
{
|
||||
id: 'camp_4', blueprintId: 'bp_emergency', platform: 'google', name: 'Emergency Leak — Always On',
|
||||
objective: 'lead_generation', serviceType: 'emergency', status: 'active',
|
||||
budgetDaily: 45, budgetTotal: 1350, startAt: '2026-05-01', endAt: '2026-07-31',
|
||||
playbook: 'Emergency Dispatch Nurture', approvalStatus: 'approved',
|
||||
serviceArea: 'Plano + 15mi radius',
|
||||
},
|
||||
{
|
||||
id: 'camp_5', blueprintId: 'bp_review', platform: 'nextdoor', name: 'Neighborhood Review Flywheel',
|
||||
objective: 'engagement', serviceType: 'referral', status: 'paused',
|
||||
budgetDaily: 25, budgetTotal: 750, startAt: '2026-05-10', endAt: '2026-06-30',
|
||||
playbook: 'Completion Promoter Path', approvalStatus: 'approved',
|
||||
serviceArea: 'Completed-job neighborhoods',
|
||||
},
|
||||
{
|
||||
id: 'camp_6', blueprintId: 'bp_unsold', platform: 'meta', name: 'Unsold Estimate Retarget — Q2',
|
||||
objective: 'retargeting', serviceType: 'replacement', status: 'draft',
|
||||
budgetDaily: 40, budgetTotal: 1200, startAt: '2026-06-15', endAt: '2026-07-15',
|
||||
playbook: 'Unsold Estimate Warm Nurture', approvalStatus: 'pending',
|
||||
serviceArea: 'CRM audience: unsold estimates < 90 days',
|
||||
},
|
||||
{
|
||||
// LinkedIn has no full-publish API (ingest/report only), so this draft surfaces
|
||||
// the "Manual setup" checklist instead of "Publish". Approved → can go live.
|
||||
id: 'camp_7', blueprintId: 'bp_commercial', platform: 'linkedin', name: 'Commercial Roofing — Property Managers (Draft)',
|
||||
objective: 'lead_generation', serviceType: 'commercial', status: 'draft',
|
||||
budgetDaily: 60, budgetTotal: 1800, startAt: '2026-06-20', endAt: '2026-07-20',
|
||||
playbook: 'Commercial Bid Nurture', approvalStatus: 'approved',
|
||||
serviceArea: 'DFW commercial corridors — property managers',
|
||||
creativeId: 'cre_4', formId: 'form_2',
|
||||
},
|
||||
];
|
||||
|
||||
// ── ad_creatives ─────────────────────────────────────────────────────────────
|
||||
export const creatives = [
|
||||
{
|
||||
id: 'cre_1', campaignId: 'camp_1', platform: 'meta', aiGenerated: true,
|
||||
headline: 'Hail in Plano? Document the damage.',
|
||||
body: 'Recent hail or wind? A free roof inspection documents visible damage, ventilation concerns, and your repair/replacement options. Request an inspection window today.',
|
||||
description: 'Free inspection • Local crew • Insurance-savvy',
|
||||
approvalStatus: 'approved', policyStatus: 'clear', policyFlags: [],
|
||||
},
|
||||
{
|
||||
id: 'cre_2', campaignId: 'camp_1', platform: 'meta', aiGenerated: true,
|
||||
headline: 'Storm rolled through. Is your roof okay?',
|
||||
body: 'Our local team checks for hail bruising, lifted shingles, and ventilation issues — then walks you through options. No pressure, just facts.',
|
||||
description: 'Same-week appointments',
|
||||
approvalStatus: 'pending', policyStatus: 'review',
|
||||
policyFlags: ['Verify "no pressure" claim against approved messaging'],
|
||||
},
|
||||
{
|
||||
id: 'cre_3', campaignId: 'camp_2', platform: 'google', aiGenerated: true,
|
||||
headline: 'Roof Replacement Quotes — Plano',
|
||||
body: 'Compare Good / Better / Best options with transparent pricing and financing. Free, no-obligation quote.',
|
||||
description: 'Financing available',
|
||||
approvalStatus: 'approved', policyStatus: 'clear', policyFlags: [],
|
||||
},
|
||||
{
|
||||
id: 'cre_4', campaignId: 'camp_3', platform: 'linkedin', aiGenerated: false,
|
||||
headline: 'Commercial Roof Assessment + Bid Package',
|
||||
body: 'Request a site review and receive a decision-maker checklist before your meeting. Capability statement and COI on request.',
|
||||
description: 'For property managers & facilities',
|
||||
approvalStatus: 'approved', policyStatus: 'clear', policyFlags: [],
|
||||
},
|
||||
{
|
||||
id: 'cre_5', campaignId: 'camp_6', platform: 'meta', aiGenerated: true,
|
||||
headline: 'Your roof quote is about to expire',
|
||||
body: 'Lock in this season\'s pricing before material costs jump 18%. Limited inspection slots left this week!',
|
||||
description: 'Act now — prices rising',
|
||||
approvalStatus: 'blocked', policyStatus: 'blocked',
|
||||
policyFlags: ['Unverified discount / price-jump claim', 'Deceptive scarcity ("limited slots")'],
|
||||
},
|
||||
];
|
||||
|
||||
// ── lead_forms (+ inline field_mappings) ──────────────────────────────────────
|
||||
export const leadForms = [
|
||||
{
|
||||
id: 'form_1', campaignId: 'camp_1', platform: 'meta', platformFormId: 'fb_form_88231',
|
||||
title: 'Storm Inspection Request', status: 'live',
|
||||
consentText: 'By submitting, I agree LynkedUp Roofing may contact me by call, text, and email about my inspection. Msg/data rates may apply. Reply STOP to opt out.',
|
||||
privacyPolicyUrl: 'https://lynkeduppro.com/privacy',
|
||||
thankYou: 'We received your request. Choose an appointment window or watch for a scheduling text.',
|
||||
fields: [
|
||||
{ source: 'full_name', target: 'contact.name', required: true, transform: 'titlecase' },
|
||||
{ source: 'phone_number', target: 'contact.phone', required: true, transform: 'e164' },
|
||||
{ source: 'email', target: 'contact.email', required: true, transform: 'lowercase' },
|
||||
{ source: 'street_address', target: 'property.address', required: true, transform: 'none' },
|
||||
{ source: 'active_leak', target: 'lead.urgencyFlag', required: false, transform: 'bool' },
|
||||
{ source: 'owner_or_tenant', target: 'lead.role', required: true, transform: 'none' },
|
||||
{ source: 'preferred_window', target: 'appointment.preferredWindow', required: false, transform: 'none' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'form_2', campaignId: 'camp_3', platform: 'linkedin', platformFormId: 'li_form_5521',
|
||||
title: 'Commercial Bid Request', status: 'live',
|
||||
consentText: 'I agree to be contacted regarding a commercial roofing assessment. LynkedUp will store my details per its privacy policy.',
|
||||
privacyPolicyUrl: 'https://lynkeduppro.com/privacy',
|
||||
thankYou: 'Thanks — a commercial estimator will reach out with a decision-maker checklist.',
|
||||
fields: [
|
||||
{ source: 'company', target: 'contact.company', required: true, transform: 'none' },
|
||||
{ source: 'job_title', target: 'lead.role', required: true, transform: 'none' },
|
||||
{ source: 'work_email', target: 'contact.email', required: true, transform: 'lowercase' },
|
||||
{ source: 'property_type', target: 'lead.propertyType', required: true, transform: 'none' },
|
||||
{ source: 'building_count', target: 'lead.buildingCount', required: false, transform: 'int' },
|
||||
{ source: 'decision_timeline', target: 'lead.timeline', required: false, transform: 'none' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'form_3', campaignId: 'camp_4', platform: 'google', platformFormId: 'ggl_lf_70012',
|
||||
title: 'Emergency Leak — Get Help Now', status: 'live',
|
||||
consentText: 'I consent to urgent contact by phone and text about my roof emergency. Reply STOP to opt out.',
|
||||
privacyPolicyUrl: 'https://lynkeduppro.com/privacy',
|
||||
thankYou: 'Help is on the way — keep your phone handy, our dispatcher will call shortly.',
|
||||
fields: [
|
||||
{ source: 'name', target: 'contact.name', required: true, transform: 'titlecase' },
|
||||
{ source: 'phone', target: 'contact.phone', required: true, transform: 'e164' },
|
||||
{ source: 'address', target: 'property.address', required: true, transform: 'none' },
|
||||
{ source: 'active_leak', target: 'lead.urgencyFlag', required: true, transform: 'bool' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// ── ad_leads ──────────────────────────────────────────────────────────────────
|
||||
// triage: { serviceType, urgency, role, intent, quality (0-100), confidence (0-1) }
|
||||
// stage : lifecycle position (spec §5/§10.1) — lead → appointment → showed →
|
||||
// estimate → won | lost. Advancing a stage emits a conversion_event and
|
||||
// live-updates the dashboard KPIs. dealValue = estimate/won $; premium =
|
||||
// won with a premium package; firstContactSec = speed-to-lead.
|
||||
export const leads = [
|
||||
{
|
||||
id: 'lead_1', platform: 'meta', campaignId: 'camp_1', formId: 'form_1',
|
||||
externalLeadId: 'fbl_99120', receivedAt: '2026-06-10T13:42:00Z', status: 'new', stage: 'lead',
|
||||
name: 'Marcus Webb', phone: '(972) 555-0188', email: 'm.webb@example.com',
|
||||
address: '2612 Dunwick Dr, Plano, TX 75023',
|
||||
consent: true, role: 'homeowner', activeLeak: false,
|
||||
triage: { serviceType: 'storm', urgency: 'medium', role: 'homeowner', intent: 'inspection', quality: 82, confidence: 0.91 },
|
||||
duplicateOf: null, assignedTo: null, dealValue: 0, premium: false, firstContactSec: null,
|
||||
slaSecondsAgo: 220, // storm target 300s → ~80s left (ticking toward breach)
|
||||
},
|
||||
{
|
||||
id: 'lead_2', platform: 'google', campaignId: 'camp_4', formId: 'form_3',
|
||||
externalLeadId: 'ggl_44021', receivedAt: '2026-06-10T13:05:00Z', status: 'new', stage: 'lead',
|
||||
name: 'Priya Nair', phone: '(469) 555-0143', email: 'priya.nair@example.com',
|
||||
address: '6609 Phoenix Pl, Plano, TX 75023',
|
||||
consent: true, role: 'homeowner', activeLeak: true,
|
||||
triage: { serviceType: 'emergency', urgency: 'high', role: 'homeowner', intent: 'emergency', quality: 95, confidence: 0.97 },
|
||||
duplicateOf: null, assignedTo: null, dealValue: 0, premium: false, firstContactSec: null,
|
||||
slaSecondsAgo: 700, // emergency target 600s → already breached (+100s)
|
||||
},
|
||||
{
|
||||
id: 'lead_3', platform: 'linkedin', campaignId: 'camp_3', formId: 'form_2',
|
||||
externalLeadId: 'lil_30221', receivedAt: '2026-06-10T11:20:00Z', status: 'assigned', stage: 'appointment',
|
||||
name: 'Dana Cole', phone: '(214) 555-0177', email: 'dcole@summitproperties.com',
|
||||
address: 'Summit Properties, 1200 Legacy Dr, Plano, TX',
|
||||
consent: true, role: 'property_manager', activeLeak: false,
|
||||
triage: { serviceType: 'commercial', urgency: 'low', role: 'property_manager', intent: 'bid', quality: 88, confidence: 0.89 },
|
||||
duplicateOf: null, assignedTo: 'rep_jordan', dealValue: 0, premium: false, firstContactSec: 240,
|
||||
},
|
||||
{
|
||||
id: 'lead_4', platform: 'meta', campaignId: 'camp_1', formId: 'form_1',
|
||||
externalLeadId: 'fbl_99131', receivedAt: '2026-06-10T10:58:00Z', status: 'review', stage: 'lead',
|
||||
name: 'Marcus W.', phone: '(972) 555-0188', email: 'm.webb@example.com',
|
||||
address: '2612 Dunwick Dr, Plano TX',
|
||||
consent: true, role: 'homeowner', activeLeak: false,
|
||||
triage: { serviceType: 'storm', urgency: 'medium', role: 'homeowner', intent: 'inspection', quality: 60, confidence: 0.42 },
|
||||
duplicateOf: 'lead_1', assignedTo: null, dealValue: 0, premium: false, firstContactSec: null,
|
||||
},
|
||||
{
|
||||
id: 'lead_5', platform: 'reddit', campaignId: null, formId: null,
|
||||
externalLeadId: 'rdl_1102', receivedAt: '2026-06-09T18:30:00Z', status: 'new', stage: 'lead',
|
||||
name: 'Anon User', phone: '', email: 'roofcurious@example.com',
|
||||
address: 'ZIP 75025',
|
||||
consent: false, role: 'homeowner', activeLeak: false,
|
||||
triage: { serviceType: 'replacement', urgency: 'low', role: 'homeowner', intent: 'research', quality: 35, confidence: 0.55 },
|
||||
duplicateOf: null, assignedTo: null, dealValue: 0, premium: false, firstContactSec: null,
|
||||
},
|
||||
{
|
||||
id: 'lead_6', platform: 'google', campaignId: 'camp_2', formId: null,
|
||||
externalLeadId: 'ggl_44090', receivedAt: '2026-06-09T16:12:00Z', status: 'assigned', stage: 'won',
|
||||
name: 'Helen Ortiz', phone: '(972) 555-0210', email: 'helen.ortiz@example.com',
|
||||
address: '3913 Arizona Pl, Plano, TX 75023',
|
||||
consent: true, role: 'homeowner', activeLeak: false,
|
||||
triage: { serviceType: 'replacement', urgency: 'medium', role: 'homeowner', intent: 'quote', quality: 78, confidence: 0.84 },
|
||||
duplicateOf: null, assignedTo: 'rep_sam', dealValue: 28100, premium: true, firstContactSec: 180,
|
||||
},
|
||||
{
|
||||
id: 'lead_7', platform: 'meta', campaignId: 'camp_1', formId: 'form_1',
|
||||
externalLeadId: 'fbl_99140', receivedAt: '2026-06-08T15:20:00Z', status: 'assigned', stage: 'showed',
|
||||
name: 'Greg Tanaka', phone: '(972) 555-0233', email: 'greg.tanaka@example.com',
|
||||
address: '2608 Dunwick Dr, Plano, TX 75023',
|
||||
consent: true, role: 'homeowner', activeLeak: false,
|
||||
triage: { serviceType: 'storm', urgency: 'medium', role: 'homeowner', intent: 'inspection', quality: 80, confidence: 0.9 },
|
||||
duplicateOf: null, assignedTo: 'rep_jordan', dealValue: 0, premium: false, firstContactSec: 300,
|
||||
},
|
||||
{
|
||||
id: 'lead_8', platform: 'google', campaignId: 'camp_2', formId: null,
|
||||
externalLeadId: 'ggl_44120', receivedAt: '2026-06-07T12:10:00Z', status: 'assigned', stage: 'estimate',
|
||||
name: 'Lena Brooks', phone: '(469) 555-0299', email: 'lena.brooks@example.com',
|
||||
address: '3917 Arizona Pl, Plano, TX 75023',
|
||||
consent: true, role: 'homeowner', activeLeak: false,
|
||||
triage: { serviceType: 'replacement', urgency: 'medium', role: 'homeowner', intent: 'quote', quality: 85, confidence: 0.88 },
|
||||
duplicateOf: null, assignedTo: 'rep_sam', dealValue: 31200, premium: false, firstContactSec: 220,
|
||||
},
|
||||
];
|
||||
|
||||
// ── ad-to-nurture rules (spec §5, §8) ──────────────────────────────────────────
|
||||
export const nurtureRules = [
|
||||
{
|
||||
id: 'rule_1', priority: 1,
|
||||
when: { platform: 'any', serviceType: 'emergency' },
|
||||
then: { playbook: 'Emergency Dispatch Nurture', rep: 'On-call dispatcher', speedToLead: 'WhatsApp + call < 10 min', education: 'Leak mitigation tips' },
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'rule_2', priority: 2,
|
||||
when: { platform: 'linkedin', serviceType: 'commercial' },
|
||||
then: { playbook: 'Commercial Bid Nurture', rep: 'Commercial estimator', speedToLead: 'Email < 1 hr', education: 'Capability statement' },
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'rule_3', priority: 3,
|
||||
when: { platform: 'meta', serviceType: 'storm' },
|
||||
then: { playbook: 'Storm Inspection Nurture', rep: 'Field agent (territory)', speedToLead: 'Text < 5 min', education: 'Storm checklist + insurance' },
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'rule_4', priority: 4,
|
||||
when: { platform: 'google', serviceType: 'replacement' },
|
||||
then: { playbook: 'Retail Replacement Nurture', rep: 'Inside sales', speedToLead: 'Call < 5 min', education: 'Good/Better/Best + financing' },
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'rule_5', priority: 99,
|
||||
when: { platform: 'any', serviceType: 'any' },
|
||||
then: { playbook: 'Default Inbound Lead Nurture', rep: 'Round-robin', speedToLead: 'Text < 15 min', education: 'Company intro' },
|
||||
enabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
// ── audience_segments ──────────────────────────────────────────────────────────
|
||||
export const audiences = [
|
||||
{
|
||||
id: 'aud_1', name: 'Plano hail-hit neighborhoods', segmentType: 'storm_geo',
|
||||
totalCount: 4820, consentCount: 4120, suppressionCount: 700, lastCount: '2026-06-10',
|
||||
filters: 'ZIP 75023/75025 • storm event < 30 days • residential',
|
||||
},
|
||||
{
|
||||
id: 'aud_2', name: 'Unsold estimates < 90 days', segmentType: 'crm_stage',
|
||||
totalCount: 142, consentCount: 138, suppressionCount: 4, lastCount: '2026-06-10',
|
||||
filters: 'Estimate sent • not won • age < 90d • has consent',
|
||||
},
|
||||
{
|
||||
id: 'aud_3', name: 'Past customers — maintenance due', segmentType: 'reactivation',
|
||||
totalCount: 980, consentCount: 910, suppressionCount: 70, lastCount: '2026-06-09',
|
||||
filters: 'Job completed 10-14 mo ago • warranty active • opted in',
|
||||
},
|
||||
{
|
||||
id: 'aud_4', name: 'Commercial property managers (DFW)', segmentType: 'b2b',
|
||||
totalCount: 310, consentCount: 295, suppressionCount: 15, lastCount: '2026-06-08',
|
||||
filters: 'Role = property/facilities manager • DFW metro • consented',
|
||||
},
|
||||
{
|
||||
id: 'aud_5', name: 'Review promoters (NPS 9-10)', segmentType: 'referral',
|
||||
totalCount: 268, consentCount: 268, suppressionCount: 0, lastCount: '2026-06-10',
|
||||
filters: 'Completed job • NPS ≥ 9 • review-permission on file',
|
||||
},
|
||||
];
|
||||
|
||||
// ── media_assets (spec §7 media_asset_id) ──────────────────────────────────────
|
||||
// Front-end demo: no real binaries — each asset renders as a labeled gradient tile.
|
||||
// Uploaded assets add an in-memory object-URL preview at runtime.
|
||||
export const mediaAssets = [
|
||||
{ id: 'med_1', name: 'Hail-damaged shingles (closeup)', type: 'image', kind: 'storm', w: 1200, h: 1200, gradient: ['#f59e0b', '#ea580c'], photo: 'https://loremflickr.com/600/400/roof,shingles?lock=21' },
|
||||
{ id: 'med_2', name: 'Aerial roof inspection', type: 'image', kind: 'storm', w: 1200, h: 628, gradient: ['#3b82f6', '#1d4ed8'], photo: 'https://loremflickr.com/600/400/roof,aerial?lock=22' },
|
||||
{ id: 'med_3', name: 'New architectural shingle roof', type: 'image', kind: 'replacement', w: 1080, h: 1080, gradient: ['#10b981', '#047857'], photo: 'https://loremflickr.com/600/400/roof,house?lock=23' },
|
||||
{ id: 'med_4', name: 'Crew tarping an active leak', type: 'image', kind: 'emergency', w: 1200, h: 628, gradient: ['#ef4444', '#b91c1c'], photo: 'https://loremflickr.com/600/400/roof,rain?lock=24' },
|
||||
{ id: 'med_5', name: 'Commercial flat-roof survey', type: 'image', kind: 'commercial', w: 1200, h: 800, gradient: ['#8b5cf6', '#6d28d9'], photo: 'https://loremflickr.com/600/400/commercial,building?lock=25' },
|
||||
{ id: 'med_6', name: 'Branded crew + truck', type: 'image', kind: 'referral', w: 1080, h: 1080, gradient: ['#06b6d4', '#0e7490'], photo: 'https://loremflickr.com/600/400/construction,truck?lock=26' },
|
||||
];
|
||||
|
||||
// ── consent text versions (spec §8 "consent text versions"; §9 consent text) ────
|
||||
// Tenant-approved SMS/email consent language with opt-out instruction + privacy URL,
|
||||
// versioned and approval-gated. One approved version is live per form at a time.
|
||||
export const consentVersions = [
|
||||
{ id: 'cv1_1', formId: 'form_1', version: 1, channel: 'SMS + Email', status: 'superseded', reviewer: 'fe@lynkeduppro.com', createdAt: '2026-05-20T10:00:00Z', privacyPolicyUrl: 'https://lynkeduppro.com/privacy', optOut: 'Reply STOP to opt out.', text: 'By submitting, I agree LynkedUp Roofing may contact me about my inspection.' },
|
||||
{ id: 'cv1_2', formId: 'form_1', version: 2, channel: 'SMS + Email', status: 'approved', reviewer: 'fe@lynkeduppro.com', createdAt: '2026-06-05T10:00:00Z', privacyPolicyUrl: 'https://lynkeduppro.com/privacy', optOut: 'Reply STOP to opt out.', text: 'By submitting, I agree LynkedUp Roofing may contact me by call, text, and email about my inspection. Msg/data rates may apply.' },
|
||||
{ id: 'cv2_1', formId: 'form_2', version: 1, channel: 'Email', status: 'approved', reviewer: 'fe@lynkeduppro.com', createdAt: '2026-05-25T09:00:00Z', privacyPolicyUrl: 'https://lynkeduppro.com/privacy', optOut: 'Unsubscribe link in every email.', text: 'I agree to be contacted regarding a commercial roofing assessment. LynkedUp will store my details per its privacy policy.' },
|
||||
{ id: 'cv3_1', formId: 'form_3', version: 1, channel: 'SMS + Voice', status: 'superseded', reviewer: 'fe@lynkeduppro.com', createdAt: '2026-05-10T08:00:00Z', privacyPolicyUrl: 'https://lynkeduppro.com/privacy', optOut: 'Reply STOP to opt out.', text: 'I consent to urgent contact by phone and text about my roof emergency.' },
|
||||
{ id: 'cv3_2', formId: 'form_3', version: 2, channel: 'SMS + Voice', status: 'pending', reviewer: null, createdAt: '2026-06-10T07:00:00Z', privacyPolicyUrl: 'https://lynkeduppro.com/privacy', optOut: 'Reply STOP to opt out anytime.', text: 'I consent to urgent contact by phone, text, and email about my roof emergency. Standard message and data rates may apply.' },
|
||||
];
|
||||
|
||||
// ── anomaly alerts (spec §11: CPL spike, lead-quality drop, duplicate rate, ──────
|
||||
// source-to-appointment decline → AI recommends pause/review; tenant executes).
|
||||
export const anomalies = [
|
||||
{ id: 'an_1', campaignId: 'camp_4', type: 'cpl_spike', severity: 'high', metric: 'CPL $26 vs $11 baseline (+136%)', detail: 'Cost per lead more than doubled over the last 2 days while volume held flat.', recommendation: 'review', detectedAt: '2026-06-10T09:10:00Z', status: 'open' },
|
||||
{ id: 'an_2', campaignId: 'camp_2', type: 'appt_decline', severity: 'medium', metric: 'Appt rate 31% vs 48% (−35% WoW)', detail: 'Source-to-appointment conversion dropped sharply — check speed-to-lead and form quality.', recommendation: 'review', detectedAt: '2026-06-10T08:40:00Z', status: 'open' },
|
||||
{ id: 'an_3', campaignId: 'camp_1', type: 'duplicate_rate', severity: 'medium', metric: 'Duplicate rate 24% (threshold 15%)', detail: 'Abnormal duplicate submissions — likely repeat clicks or bot/form traffic.', recommendation: 'review', detectedAt: '2026-06-10T07:55:00Z', status: 'open' },
|
||||
{ id: 'an_4', campaignId: 'camp_3', type: 'quality_drop', severity: 'high', metric: 'Avg lead quality 44 (down from 71)', detail: 'Lead quality fell well below the qualified threshold — likely low-intent traffic.', recommendation: 'pause', detectedAt: '2026-06-10T07:30:00Z', status: 'open' },
|
||||
];
|
||||
|
||||
// ── multi-touch journeys (spec §11: first/last/multi-touch attribution models) ──
|
||||
// Each closed-won customer's ordered touch path across sources. Revenue is credited
|
||||
// differently per model: first-touch → touches[0], last-touch → final touch, multi
|
||||
// (linear) → split evenly across all touches. Touches carry platform + campaignId so
|
||||
// the same keyOf() mapping resolves creative/playbook dimensions too.
|
||||
export const journeys = [
|
||||
{ id: 'jny_1', customer: 'Helen Ortiz', revenue: 28100, touches: [{ platform: 'meta', campaignId: 'camp_1' }, { platform: 'google', campaignId: 'camp_2' }] },
|
||||
{ id: 'jny_2', customer: 'Greg Tanaka', revenue: 24850, touches: [{ platform: 'meta', campaignId: 'camp_1' }] },
|
||||
{ id: 'jny_3', customer: 'Summit Properties', revenue: 86000, touches: [{ platform: 'linkedin', campaignId: 'camp_3' }, { platform: 'google', campaignId: 'camp_2' }, { platform: 'linkedin', campaignId: 'camp_3' }] },
|
||||
{ id: 'jny_4', customer: 'Lena Brooks', revenue: 31200, touches: [{ platform: 'google', campaignId: 'camp_2' }] },
|
||||
{ id: 'jny_5', customer: 'Marco Rivera', revenue: 22400, touches: [{ platform: 'nextdoor', campaignId: 'camp_5' }, { platform: 'meta', campaignId: 'camp_1' }] },
|
||||
{ id: 'jny_6', customer: 'Tina Nguyen', revenue: 46200, touches: [{ platform: 'google', campaignId: 'camp_4' }, { platform: 'meta', campaignId: 'camp_1' }] },
|
||||
{ id: 'jny_7', customer: 'Paul Adler', revenue: 19800, touches: [{ platform: 'meta', campaignId: 'camp_1' }] },
|
||||
{ id: 'jny_8', customer: 'Carla Boone', revenue: 25400, touches: [{ platform: 'google', campaignId: 'camp_2' }, { platform: 'linkedin', campaignId: 'camp_3' }] },
|
||||
];
|
||||
|
||||
// ── reviews + referral lift (spec §8 "reviews"; §10 review/referral lift) ───────
|
||||
// Post-job reviews tied back to the acquisition source. referralLeads = new leads the
|
||||
// review/promoter generated; reactivationRevenue = repeat/reactivation $ by source.
|
||||
export const reviews = [
|
||||
{ id: 'rev_1', platform: 'meta', campaignId: 'camp_1', leadId: 'lead_7', rating: 5, referralLeads: 2, reactivationRevenue: 0, text: 'Fast storm inspection, handled the insurance paperwork for us.', submittedAt: '2026-06-09T18:00:00Z', usagePermission: 'granted', disclosureStatus: 'compliant', usedIn: 'camp_1' },
|
||||
{ id: 'rev_2', platform: 'google', campaignId: 'camp_2', leadId: 'lead_6', rating: 5, referralLeads: 1, reactivationRevenue: 6500, text: 'Second roof they have done for us — came back for the rental property.', submittedAt: '2026-06-09T20:10:00Z' },
|
||||
{ id: 'rev_3', platform: 'google', campaignId: 'camp_2', leadId: 'lead_8', rating: 4, referralLeads: 0, reactivationRevenue: 0, text: 'Good work, scheduling took a couple tries.', submittedAt: '2026-06-08T15:30:00Z' },
|
||||
{ id: 'rev_4', platform: 'nextdoor', campaignId: 'camp_5', leadId: null, rating: 5, referralLeads: 3, reactivationRevenue: 0, text: 'Recommended to three neighbors after the hail storm.', submittedAt: '2026-06-08T12:00:00Z', usagePermission: 'pending', disclosureStatus: 'missing', usedIn: 'camp_5' },
|
||||
{ id: 'rev_5', platform: 'linkedin', campaignId: 'camp_3', leadId: 'lead_3', rating: 5, referralLeads: 1, reactivationRevenue: 0, text: 'Professional crew for our commercial portfolio.', submittedAt: '2026-06-07T09:45:00Z', usagePermission: 'granted', disclosureStatus: 'compliant', usedIn: 'camp_3' },
|
||||
{ id: 'rev_6', platform: 'meta', campaignId: 'camp_1', leadId: null, rating: 4, referralLeads: 0, reactivationRevenue: 0, text: 'Solid job on the ridge cap.', submittedAt: '2026-06-07T17:20:00Z' },
|
||||
{ id: 'rev_7', platform: 'google', campaignId: 'camp_2', leadId: null, rating: 5, referralLeads: 1, reactivationRevenue: 8200, text: 'Called us back for annual maintenance — great value.', submittedAt: '2026-06-06T14:05:00Z' },
|
||||
{ id: 'rev_8', platform: 'nextdoor', campaignId: 'camp_5', leadId: null, rating: 5, referralLeads: 2, reactivationRevenue: 0, text: 'Whole street is using them now.', submittedAt: '2026-06-05T11:30:00Z', usagePermission: 'granted', disclosureStatus: 'compliant', usedIn: 'camp_5' },
|
||||
];
|
||||
|
||||
// ── inbound conversations (spec §4 "leads AND conversations"; §6 Comment/DM triage) ──
|
||||
// Comments + DMs on ad posts, pre-classified by AI as lead/complaint/spam/referral/
|
||||
// support. status: new → routed | converted | dismissed.
|
||||
export const conversations = [
|
||||
{
|
||||
id: 'cv_1', platform: 'meta', channel: 'comment', author: 'Brian Keller', handle: '@bkeller',
|
||||
postRef: 'Plano Hail Response — June (ad)', receivedAt: '2026-06-10T14:10:00Z', status: 'new',
|
||||
text: 'How much for an inspection? My roof took a beating in the last hail storm and I think I have a leak.',
|
||||
classification: { type: 'lead', confidence: 0.93 },
|
||||
},
|
||||
{
|
||||
id: 'cv_2', platform: 'meta', channel: 'comment', author: 'Donna Pratt', handle: '@dpratt75',
|
||||
postRef: 'Plano Hail Response — June (ad)', receivedAt: '2026-06-10T13:30:00Z', status: 'new',
|
||||
text: 'Worst experience ever. Crew showed up late and damaged my gutters. Still waiting on a callback. Avoid!',
|
||||
classification: { type: 'complaint', confidence: 0.95 },
|
||||
},
|
||||
{
|
||||
id: 'cv_3', platform: 'meta', channel: 'dm', author: 'Maria Lopez', handle: '@maria.l',
|
||||
postRef: 'Storm inspection reel', receivedAt: '2026-06-10T12:05:00Z', status: 'new',
|
||||
text: 'I highly recommend you all — my neighbor asked who did our roof so I sent her your page!',
|
||||
classification: { type: 'referral', confidence: 0.9 },
|
||||
},
|
||||
{
|
||||
id: 'cv_4', platform: 'google', channel: 'comment', author: 'Anon', handle: '@deal_hunter',
|
||||
postRef: 'Roof Replacement — North Dallas Search', receivedAt: '2026-06-10T11:40:00Z', status: 'new',
|
||||
text: 'Make $5000/week from home 🔥 click here → http://spam.example/earn',
|
||||
classification: { type: 'spam', confidence: 0.97 },
|
||||
},
|
||||
{
|
||||
id: 'cv_5', platform: 'nextdoor', channel: 'comment', author: 'Greg Tanaka', handle: '@gtanaka',
|
||||
postRef: 'Neighborhood Review Flywheel', receivedAt: '2026-06-10T10:20:00Z', status: 'new',
|
||||
text: 'Already a customer here — when will the crew come back to finish the ridge cap? Reschedule please.',
|
||||
classification: { type: 'support', confidence: 0.88 },
|
||||
},
|
||||
{
|
||||
id: 'cv_6', platform: 'linkedin', channel: 'dm', author: 'Dana Cole', handle: 'Dana Cole',
|
||||
postRef: 'Commercial Bid — Property Managers', receivedAt: '2026-06-09T17:50:00Z', status: 'routed',
|
||||
text: 'We manage 6 properties in Plano and need bids on two flat roofs. Can you send a capability statement?',
|
||||
classification: { type: 'lead', confidence: 0.91 }, routedTo: 'Commercial estimator',
|
||||
},
|
||||
{
|
||||
id: 'cv_7', platform: 'meta', channel: 'dm', author: 'Unknown', handle: '@user_8841',
|
||||
postRef: 'Unsold Estimate Retarget — Q2', receivedAt: '2026-06-09T16:00:00Z', status: 'dismissed',
|
||||
text: 'buy cheap followers + crypto giveaway, dm me to earn',
|
||||
classification: { type: 'spam', confidence: 0.96 },
|
||||
},
|
||||
];
|
||||
|
||||
// ── conversion_events (spec §10.1) ─────────────────────────────────────────────
|
||||
export const conversionEvents = [
|
||||
{ id: 'ce_1', platform: 'meta', eventName: 'LeadCreated', entityType: 'lead', entityId: 'lead_1', value: 0, currency: 'USD', dedupKey: 'meta:fbl_99120', syncStatus: 'synced', syncedAt: '2026-06-10T13:43:00Z' },
|
||||
{ id: 'ce_2', platform: 'google', eventName: 'AppointmentScheduled', entityType: 'opportunity', entityId: 'opp_2210', value: 0, currency: 'USD', dedupKey: 'opp_2210+appt_55', syncStatus: 'synced', syncedAt: '2026-06-10T09:15:00Z' },
|
||||
{ id: 'ce_3', platform: 'meta', eventName: 'EstimateSent', entityType: 'opportunity', entityId: 'opp_2188', value: 0, currency: 'USD', dedupKey: 'appt_51+2026-06-08', syncStatus: 'synced', syncedAt: '2026-06-09T17:02:00Z' },
|
||||
{ id: 'ce_4', platform: 'google', eventName: 'JobWon', entityType: 'estimate', entityId: 'est_990', value: 24850, currency: 'USD', dedupKey: 'est_990', syncStatus: 'synced', syncedAt: '2026-06-08T20:30:00Z' },
|
||||
{ id: 'ce_5', platform: 'linkedin', eventName: 'AppointmentScheduled', entityType: 'opportunity', entityId: 'opp_2241', value: 0, currency: 'USD', dedupKey: 'opp_2241+appt_60', syncStatus: 'queued', syncedAt: null },
|
||||
{ id: 'ce_6', platform: 'reddit', eventName: 'LeadCreated', entityType: 'lead', entityId: 'lead_5', value: 0, currency: 'USD', dedupKey: 'reddit:rdl_1102', syncStatus: 'failed', syncedAt: null },
|
||||
];
|
||||
|
||||
// ── compliance queue / creative_reviews (spec §8 Compliance Center) ─────────────
|
||||
export const approvals = [
|
||||
{ id: 'apr_1', type: 'creative', refId: 'cre_2', title: 'Storm creative — "no pressure"', status: 'pending', flags: ['Verify "no pressure" against approved messaging'], submittedAt: '2026-06-10T08:30:00Z', reviewer: null },
|
||||
{ id: 'apr_2', type: 'creative', refId: 'cre_5', title: 'Unsold retarget — price-jump + scarcity', status: 'blocked', flags: ['Unverified discount claim', 'Deceptive scarcity'], submittedAt: '2026-06-09T15:40:00Z', reviewer: null },
|
||||
{ id: 'apr_3', type: 'consent_text', refId: 'form_1', title: 'Storm form consent language v2', status: 'approved', flags: [], submittedAt: '2026-06-05T10:00:00Z', reviewer: 'fe@lynkeduppro.com' },
|
||||
{ id: 'apr_4', type: 'review_disclosure', refId: 'camp_5', title: 'Neighbor review creative — disclosure check', status: 'pending', flags: ['Confirm reviewer permission + FTC disclosure on file'], submittedAt: '2026-06-10T07:00:00Z', reviewer: null },
|
||||
{ id: 'apr_5', type: 'campaign', refId: 'camp_6', title: 'Unsold Estimate Retarget — publish approval', status: 'pending', flags: ['Blocked creative cre_5 must be resolved before publish'], submittedAt: '2026-06-10T09:00:00Z', reviewer: null },
|
||||
];
|
||||
|
||||
// ── campaign_analytics_daily (materialized reporting) ───────────────────────────
|
||||
// Compact 7-day series per active campaign for the Attribution Dashboard.
|
||||
export const analyticsDaily = [
|
||||
// camp_1 (Meta storm)
|
||||
{ date: '2026-06-04', campaignId: 'camp_1', platform: 'meta', spend: 118, impressions: 9200, clicks: 240, leads: 9, appointments: 4, shows: 3, wins: 1, revenue: 0 },
|
||||
{ date: '2026-06-05', campaignId: 'camp_1', platform: 'meta', spend: 121, impressions: 9800, clicks: 261, leads: 11, appointments: 5, shows: 4, wins: 1, revenue: 22400 },
|
||||
{ date: '2026-06-06', campaignId: 'camp_1', platform: 'meta', spend: 120, impressions: 8900, clicks: 233, leads: 8, appointments: 3, shows: 3, wins: 0, revenue: 0 },
|
||||
{ date: '2026-06-07', campaignId: 'camp_1', platform: 'meta', spend: 119, impressions: 9100, clicks: 248, leads: 10, appointments: 5, shows: 4, wins: 2, revenue: 46200 },
|
||||
{ date: '2026-06-08', campaignId: 'camp_1', platform: 'meta', spend: 122, impressions: 10100, clicks: 272, leads: 12, appointments: 6, shows: 5, wins: 1, revenue: 24850 },
|
||||
{ date: '2026-06-09', campaignId: 'camp_1', platform: 'meta', spend: 120, impressions: 9600, clicks: 255, leads: 10, appointments: 4, shows: 3, wins: 1, revenue: 21900 },
|
||||
{ date: '2026-06-10', campaignId: 'camp_1', platform: 'meta', spend: 60, impressions: 4700, clicks: 130, leads: 6, appointments: 2, shows: 1, wins: 0, revenue: 0 },
|
||||
// camp_2 (Google replacement)
|
||||
{ date: '2026-06-04', campaignId: 'camp_2', platform: 'google', spend: 94, impressions: 3100, clicks: 180, leads: 6, appointments: 3, shows: 2, wins: 1, revenue: 26700 },
|
||||
{ date: '2026-06-05', campaignId: 'camp_2', platform: 'google', spend: 96, impressions: 3250, clicks: 192, leads: 7, appointments: 4, shows: 3, wins: 1, revenue: 28100 },
|
||||
{ date: '2026-06-06', campaignId: 'camp_2', platform: 'google', spend: 95, impressions: 3000, clicks: 175, leads: 5, appointments: 2, shows: 2, wins: 0, revenue: 0 },
|
||||
{ date: '2026-06-07', campaignId: 'camp_2', platform: 'google', spend: 95, impressions: 3300, clicks: 200, leads: 8, appointments: 4, shows: 3, wins: 2, revenue: 51200 },
|
||||
{ date: '2026-06-08', campaignId: 'camp_2', platform: 'google', spend: 97, impressions: 3400, clicks: 210, leads: 7, appointments: 3, shows: 3, wins: 1, revenue: 25400 },
|
||||
{ date: '2026-06-09', campaignId: 'camp_2', platform: 'google', spend: 95, impressions: 3150, clicks: 188, leads: 6, appointments: 3, shows: 2, wins: 1, revenue: 23900 },
|
||||
{ date: '2026-06-10', campaignId: 'camp_2', platform: 'google', spend: 48, impressions: 1600, clicks: 95, leads: 4, appointments: 2, shows: 1, wins: 0, revenue: 0 },
|
||||
// camp_3 (LinkedIn commercial)
|
||||
{ date: '2026-06-04', campaignId: 'camp_3', platform: 'linkedin', spend: 70, impressions: 1400, clicks: 42, leads: 2, appointments: 1, shows: 1, wins: 0, revenue: 0 },
|
||||
{ date: '2026-06-05', campaignId: 'camp_3', platform: 'linkedin', spend: 68, impressions: 1350, clicks: 38, leads: 1, appointments: 1, shows: 0, wins: 0, revenue: 0 },
|
||||
{ date: '2026-06-06', campaignId: 'camp_3', platform: 'linkedin', spend: 71, impressions: 1500, clicks: 45, leads: 2, appointments: 1, shows: 1, wins: 1, revenue: 84000 },
|
||||
{ date: '2026-06-07', campaignId: 'camp_3', platform: 'linkedin', spend: 70, impressions: 1420, clicks: 40, leads: 1, appointments: 0, shows: 0, wins: 0, revenue: 0 },
|
||||
{ date: '2026-06-08', campaignId: 'camp_3', platform: 'linkedin', spend: 72, impressions: 1480, clicks: 44, leads: 3, appointments: 2, shows: 1, wins: 0, revenue: 0 },
|
||||
{ date: '2026-06-09', campaignId: 'camp_3', platform: 'linkedin', spend: 69, impressions: 1390, clicks: 39, leads: 2, appointments: 1, shows: 1, wins: 0, revenue: 0 },
|
||||
{ date: '2026-06-10', campaignId: 'camp_3', platform: 'linkedin', spend: 35, impressions: 700, clicks: 20, leads: 1, appointments: 1, shows: 0, wins: 0, revenue: 0 },
|
||||
// camp_4 (Google emergency)
|
||||
{ date: '2026-06-04', campaignId: 'camp_4', platform: 'google', spend: 44, impressions: 1100, clicks: 88, leads: 4, appointments: 3, shows: 3, wins: 2, revenue: 18400 },
|
||||
{ date: '2026-06-05', campaignId: 'camp_4', platform: 'google', spend: 45, impressions: 1180, clicks: 95, leads: 5, appointments: 4, shows: 3, wins: 2, revenue: 21200 },
|
||||
{ date: '2026-06-06', campaignId: 'camp_4', platform: 'google', spend: 46, impressions: 1050, clicks: 82, leads: 3, appointments: 2, shows: 2, wins: 1, revenue: 9800 },
|
||||
{ date: '2026-06-07', campaignId: 'camp_4', platform: 'google', spend: 45, impressions: 1200, clicks: 99, leads: 5, appointments: 4, shows: 4, wins: 3, revenue: 27600 },
|
||||
{ date: '2026-06-08', campaignId: 'camp_4', platform: 'google', spend: 45, impressions: 1150, clicks: 90, leads: 4, appointments: 3, shows: 2, wins: 1, revenue: 10500 },
|
||||
{ date: '2026-06-09', campaignId: 'camp_4', platform: 'google', spend: 44, impressions: 1090, clicks: 85, leads: 4, appointments: 3, shows: 3, wins: 2, revenue: 19100 },
|
||||
{ date: '2026-06-10', campaignId: 'camp_4', platform: 'google', spend: 22, impressions: 540, clicks: 44, leads: 2, appointments: 2, shows: 1, wins: 0, revenue: 0 },
|
||||
];
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* sessionStore.js — a tiny module-level singleton that lets the PUBLIC ad
|
||||
* lead-capture page (a separate, chrome-less route with its own React tree) hand a
|
||||
* lead to the admin Social Ads Engine inbox.
|
||||
*
|
||||
* Why a singleton and not localStorage: the CRM demo deliberately avoids localStorage
|
||||
* (state is in-memory and resets on reload). This singleton lives for the SPA session,
|
||||
* so a lead submitted on /ads/lp and then viewed via /marketing#inbox (same tab) shows
|
||||
* up live — closing the spec's loop (§5) — without persisting anything to disk.
|
||||
*
|
||||
* The admin SocialAdsProvider reads getPublicLeads() when it mounts AND subscribes to
|
||||
* live updates, so a lead submitted on /ads/lp surfaces in the inbox immediately —
|
||||
* no CRM reload. Updates also bridge across browser tabs via BroadcastChannel (still
|
||||
* no localStorage), so submitting in one tab updates an Ad Engine open in another.
|
||||
*/
|
||||
|
||||
const publicLeads = [];
|
||||
|
||||
let seq = 0;
|
||||
const newId = () => `lead_pub_${Date.now().toString(36)}_${seq++}`;
|
||||
|
||||
// ── Live update bus ──────────────────────────────────────────────────────────
|
||||
const listeners = new Set();
|
||||
|
||||
/** Subscribe to new public leads; returns an unsubscribe fn. */
|
||||
export function subscribeToPublicLeads(fn) {
|
||||
listeners.add(fn);
|
||||
return () => listeners.delete(fn);
|
||||
}
|
||||
|
||||
function emit(lead) {
|
||||
listeners.forEach(fn => { try { fn(lead); } catch { /* listener errors must not break ingest */ } });
|
||||
}
|
||||
|
||||
// Cross-tab bridge: structured-clones the lead to other tabs. No disk persistence.
|
||||
const channel = typeof BroadcastChannel !== 'undefined' ? new BroadcastChannel('lynkedup_ads_leads') : null;
|
||||
if (channel) {
|
||||
channel.onmessage = (e) => {
|
||||
const lead = e.data?.type === 'new_lead' ? e.data.lead : null;
|
||||
if (!lead || publicLeads.some(l => l.id === lead.id)) return;
|
||||
publicLeads.unshift(lead); // keep this tab's store in sync for later remounts
|
||||
emit(lead);
|
||||
};
|
||||
}
|
||||
|
||||
// Map a service type to a seed campaign/form so the lead has plausible attribution.
|
||||
const SERVICE_CAMPAIGN = {
|
||||
storm: { campaignId: 'camp_1', formId: 'form_1' },
|
||||
emergency: { campaignId: 'camp_4', formId: 'form_3' },
|
||||
commercial: { campaignId: 'camp_3', formId: 'form_2' },
|
||||
replacement: { campaignId: 'camp_2', formId: null },
|
||||
maintenance: { campaignId: null, formId: null },
|
||||
referral: { campaignId: 'camp_5', formId: null },
|
||||
};
|
||||
|
||||
/** Lightweight AI triage stand-in from the captured form answers (spec §6 triage). */
|
||||
function triageFromCapture({ serviceType, activeLeak, role }) {
|
||||
const urgency = activeLeak || serviceType === 'emergency' ? 'high' : 'medium';
|
||||
const intent = { storm: 'inspection', emergency: 'emergency', commercial: 'bid', replacement: 'quote' }[serviceType] || 'inquiry';
|
||||
let quality = 70;
|
||||
if (activeLeak) quality += 15;
|
||||
if (role === 'property_manager') quality += 10;
|
||||
if (serviceType === 'commercial') quality += 5;
|
||||
return { serviceType, urgency, role, intent, quality: Math.min(98, quality), confidence: 0.86 };
|
||||
}
|
||||
|
||||
/** Build a fully-shaped ad_lead from the public capture form. */
|
||||
export function buildLeadFromCapture(form) {
|
||||
const { campaignId, formId } = SERVICE_CAMPAIGN[form.serviceType] || {};
|
||||
return {
|
||||
id: newId(),
|
||||
platform: form.platform || 'meta',
|
||||
campaignId: campaignId ?? null,
|
||||
formId: formId ?? null,
|
||||
externalLeadId: `pub_${Date.now().toString(36)}`,
|
||||
receivedAt: new Date().toISOString(),
|
||||
slaStartTs: Date.now(), // SLA clock starts now (live countdown)
|
||||
status: 'new',
|
||||
stage: 'lead',
|
||||
name: form.name,
|
||||
phone: form.phone,
|
||||
email: form.email,
|
||||
address: form.address,
|
||||
consent: !!form.consent,
|
||||
role: form.role,
|
||||
activeLeak: !!form.activeLeak,
|
||||
triage: triageFromCapture(form),
|
||||
duplicateOf: null,
|
||||
assignedTo: null,
|
||||
dealValue: 0,
|
||||
premium: false,
|
||||
firstContactSec: null,
|
||||
source: 'public_landing',
|
||||
};
|
||||
}
|
||||
|
||||
export function addPublicLead(form) {
|
||||
const lead = buildLeadFromCapture(form);
|
||||
publicLeads.unshift(lead);
|
||||
emit(lead); // live update to a mounted provider
|
||||
channel?.postMessage({ type: 'new_lead', lead }); // and to the Ad Engine in other tabs
|
||||
return lead;
|
||||
}
|
||||
|
||||
/** Newest-first copy for the provider to merge on mount. */
|
||||
export function getPublicLeads() {
|
||||
return [...publicLeads];
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* sla.js — speed-to-lead SLA targets and live state (spec §10 "Speed-to-lead",
|
||||
* §9 recipe response times, §11 "Lost leads" mitigation).
|
||||
*
|
||||
* Speed-to-lead = seconds from a lead being received to the first permitted outbound
|
||||
* contact. A new, consented, non-duplicate lead is "on the clock" until it is
|
||||
* assigned (first contact). Breaching the target should create a task/alert.
|
||||
*
|
||||
* Targets come from the campaign recipes (§9):
|
||||
* emergency → human escalation within 10 min
|
||||
* high-urgency / storm / replacement → text/call within 5 min
|
||||
* commercial → email within 1 hr
|
||||
* everything else → default 15 min
|
||||
*/
|
||||
|
||||
export const SLA_TARGETS = {
|
||||
emergency: 600,
|
||||
commercial: 3600,
|
||||
storm: 300,
|
||||
replacement: 300,
|
||||
maintenance: 900,
|
||||
referral: 900,
|
||||
default: 900,
|
||||
};
|
||||
|
||||
/** Target seconds for a given lead (urgency can tighten the window). */
|
||||
export function slaTargetSec(lead) {
|
||||
const s = lead?.triage?.serviceType;
|
||||
if (s === 'emergency') return SLA_TARGETS.emergency;
|
||||
if (s === 'commercial') return SLA_TARGETS.commercial;
|
||||
if (lead?.triage?.urgency === 'high') return SLA_TARGETS.storm;
|
||||
return SLA_TARGETS[s] ?? SLA_TARGETS.default;
|
||||
}
|
||||
|
||||
/** Resolve the clock start (ms). Public leads carry slaStartTs; seed leads derive it. */
|
||||
function startMs(lead) {
|
||||
if (lead?.slaStartTs) return lead.slaStartTs;
|
||||
if (lead?.receivedAt) return Date.parse(lead.receivedAt);
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Live SLA state for a lead at a given clock time.
|
||||
* eligible=false → not on the clock (no consent, duplicate, or already contacted)
|
||||
* eligible=true → { elapsed, target, remaining, breached }
|
||||
*/
|
||||
export function slaState(lead, nowMs = Date.now()) {
|
||||
if (!lead) return { eligible: false, reason: 'unknown' };
|
||||
if (lead.status !== 'new' || lead.stage !== 'lead') return { eligible: false, reason: 'contacted' };
|
||||
if (lead.duplicateOf) return { eligible: false, reason: 'duplicate' };
|
||||
if (!lead.consent) return { eligible: false, reason: 'no_consent' };
|
||||
|
||||
const target = slaTargetSec(lead);
|
||||
const elapsed = Math.max(0, Math.round((nowMs - startMs(lead)) / 1000));
|
||||
const remaining = target - elapsed;
|
||||
return { eligible: true, elapsed, target, remaining, breached: remaining < 0 };
|
||||
}
|
||||
|
||||
/** Count leads currently breaching SLA (for banners / recommended actions). */
|
||||
export function countBreaches(leads, nowMs = Date.now()) {
|
||||
return leads.filter(l => slaState(l, nowMs).breached).length;
|
||||
}
|
||||
|
||||
/** mm:ss formatter that prefixes a sign for breach overshoot. */
|
||||
export function fmtClock(sec) {
|
||||
const sign = sec < 0 ? '+' : '';
|
||||
const a = Math.abs(sec);
|
||||
return `${sign}${Math.floor(a / 60)}:${String(a % 60).padStart(2, '0')}`;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* testLead.js — simulate a platform lead payload flowing through a form's field
|
||||
* mapping into CRM fields (spec phased C: "Test lead maps correctly into CRM fields").
|
||||
*
|
||||
* For each mapped field it generates a plausible raw value, applies the transform, runs
|
||||
* the validation rule, and reports source → transformed value → CRM target with a
|
||||
* pass/fail. Pure + deterministic — no network, no real webhook.
|
||||
*/
|
||||
|
||||
// Pick a believable raw value from the field's source/target keywords.
|
||||
function sampleRaw(field) {
|
||||
const s = `${field.source} ${field.target}`.toLowerCase();
|
||||
if (/email/.test(s)) return 'Jane.Homeowner@Example.com';
|
||||
if (/phone|callback/.test(s)) return '(972) 555-0142';
|
||||
if (/name/.test(s)) return 'jane homeowner';
|
||||
if (/address/.test(s)) return '123 Main St, Plano, TX 75023';
|
||||
if (/leak|active/.test(s)) return 'yes';
|
||||
if (/owner|tenant|role/.test(s)) return 'homeowner';
|
||||
if (/company/.test(s)) return 'Pinnacle Property Group';
|
||||
if (/title/.test(s)) return 'Facilities Manager';
|
||||
if (/property.?type|\btype\b/.test(s)) return 'Office building';
|
||||
if (/square|footage/.test(s)) return '12,000';
|
||||
if (/count|age/.test(s)) return '18';
|
||||
if (/timeline|window|when/.test(s)) return '30-60 days';
|
||||
if (/date/.test(s)) return '2026-06-08';
|
||||
if (/urgency/.test(s)) return 'high';
|
||||
if (/insurance/.test(s)) return 'Filed';
|
||||
if (/financing/.test(s)) return 'yes';
|
||||
if (/warranty/.test(s)) return 'active';
|
||||
if (/past.?customer/.test(s)) return 'yes';
|
||||
return 'Sample value';
|
||||
}
|
||||
|
||||
function applyTransform(value, t) {
|
||||
switch (t) {
|
||||
case 'titlecase': return value.replace(/\w\S*/g, w => w[0].toUpperCase() + w.slice(1).toLowerCase());
|
||||
case 'lowercase': return value.toLowerCase();
|
||||
case 'e164': { const d = value.replace(/\D/g, ''); return d ? `+1${d.slice(-10)}` : ''; }
|
||||
case 'bool': return /^(yes|true|1|y)$/i.test(value.trim()) ? 'true' : 'false';
|
||||
case 'int': { const n = parseInt(value.replace(/[^\d]/g, ''), 10); return Number.isNaN(n) ? '' : String(n); }
|
||||
case 'date': return value;
|
||||
default: return value;
|
||||
}
|
||||
}
|
||||
|
||||
function validate(field, value) {
|
||||
const rule = (field.validation || '').toLowerCase();
|
||||
if ((field.required || /required/.test(rule)) && !String(value).trim()) return 'Required value missing';
|
||||
if (/email/.test(rule) && value && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(value)) return 'Invalid email format';
|
||||
if (/phone/.test(rule) && value && value.replace(/\D/g, '').length < 10) return 'Invalid phone number';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Run a sample lead through the form mapping. Returns { rows, ok, issues }. */
|
||||
export function runTestLead(form) {
|
||||
const rows = (form.fields || []).map(f => {
|
||||
const raw = sampleRaw(f);
|
||||
const value = applyTransform(raw, f.transform || 'none');
|
||||
const issue = validate(f, value);
|
||||
return {
|
||||
source: f.source, raw, transform: f.transform || 'none',
|
||||
value, target: f.target || '(unmapped)', required: !!f.required, valid: !issue, issue,
|
||||
};
|
||||
});
|
||||
// A row with no target is a mapping gap.
|
||||
rows.forEach(r => { if (!r.target || r.target === '(unmapped)') { r.valid = false; r.issue = r.issue || 'No CRM target set'; } });
|
||||
return { rows, ok: rows.length > 0 && rows.every(r => r.valid), issues: rows.filter(r => !r.valid).length };
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* utm.js — trackable campaign links (spec §7 `utm_links` entity).
|
||||
*
|
||||
* Mints a UTM-tagged URL to the public landing page so every ad click is attributable
|
||||
* end-to-end (source / medium / campaign / content / term → destination). Front-end
|
||||
* demo: pure string building, no shortener or backend.
|
||||
*/
|
||||
|
||||
// Use the running app's origin so the copied link opens the demo landing page; in a
|
||||
// real deployment this resolves to your public site (swap to your domain when hosting).
|
||||
const ORIGIN = (typeof window !== 'undefined' && window.location?.origin) || 'https://lynkeduppro.com';
|
||||
const BASE = `${ORIGIN}/ads/lp`;
|
||||
|
||||
// Ad medium by platform (search vs paid social vs messaging).
|
||||
const MEDIUM = {
|
||||
google: 'paid_search',
|
||||
meta: 'paid_social',
|
||||
linkedin: 'paid_social',
|
||||
reddit: 'paid_social',
|
||||
nextdoor: 'paid_social',
|
||||
whatsapp: 'messaging',
|
||||
};
|
||||
|
||||
const slug = (s) => (s || '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 60);
|
||||
|
||||
/**
|
||||
* Build the tracked link + its utm_links field breakdown for a campaign.
|
||||
* Returns { url, fields }.
|
||||
*/
|
||||
export function buildUtm(campaign, { creative } = {}) {
|
||||
const source = campaign.platform;
|
||||
const medium = MEDIUM[source] || 'paid';
|
||||
const name = slug(campaign.name) || campaign.blueprintId || 'campaign';
|
||||
const content = creative ? (slug(creative.headline) || creative.id) : (campaign.creativeId || 'default');
|
||||
const term = campaign.serviceType || '';
|
||||
|
||||
const params = {
|
||||
c: campaign.blueprintId,
|
||||
src: source,
|
||||
utm_source: source,
|
||||
utm_medium: medium,
|
||||
utm_campaign: name,
|
||||
utm_content: content,
|
||||
utm_term: term,
|
||||
};
|
||||
const qs = Object.entries(params)
|
||||
.filter(([, v]) => v)
|
||||
.map(([k, v]) => `${k}=${encodeURIComponent(v)}`)
|
||||
.join('&');
|
||||
|
||||
return {
|
||||
url: `${BASE}?${qs}`,
|
||||
fields: {
|
||||
campaignId: campaign.id || null,
|
||||
source,
|
||||
medium,
|
||||
campaign: name,
|
||||
content,
|
||||
term,
|
||||
destinationUrl: BASE,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* attribution.js — pure derivations for the Attribution Dashboard (spec §10).
|
||||
*
|
||||
* No state, no side effects: feed it the analyticsDaily rows and it returns the
|
||||
* source-to-close metrics that matter to a roofing tenant. Keeping this pure means
|
||||
* the dashboard and the hub home can both reuse it without duplicating math.
|
||||
*/
|
||||
|
||||
const safeDiv = (a, b) => (b > 0 ? a / b : 0);
|
||||
|
||||
/** Sum the raw daily counters across a set of rows. */
|
||||
export function totals(rows) {
|
||||
return rows.reduce(
|
||||
(acc, r) => ({
|
||||
spend: acc.spend + r.spend,
|
||||
impressions: acc.impressions + r.impressions,
|
||||
clicks: acc.clicks + r.clicks,
|
||||
leads: acc.leads + r.leads,
|
||||
appointments: acc.appointments + r.appointments,
|
||||
shows: acc.shows + r.shows,
|
||||
wins: acc.wins + r.wins,
|
||||
revenue: acc.revenue + r.revenue,
|
||||
}),
|
||||
{ spend: 0, impressions: 0, clicks: 0, leads: 0, appointments: 0, shows: 0, wins: 0, revenue: 0 },
|
||||
);
|
||||
}
|
||||
|
||||
/** Derived ratios from a totals() object (spec §10 metric definitions). */
|
||||
export function metrics(t) {
|
||||
return {
|
||||
...t,
|
||||
cpl: safeDiv(t.spend, t.leads), // cost per lead
|
||||
costPerAppt: safeDiv(t.spend, t.appointments),
|
||||
apptRate: safeDiv(t.appointments, t.leads), // appt scheduled / valid leads
|
||||
showRate: safeDiv(t.shows, t.appointments),
|
||||
closeRate: safeDiv(t.wins, t.appointments),
|
||||
roas: safeDiv(t.revenue, t.spend), // return on ad spend
|
||||
ctr: safeDiv(t.clicks, t.impressions),
|
||||
};
|
||||
}
|
||||
|
||||
/** Group rows by a key field and return metrics per group. */
|
||||
export function metricsBy(rows, key) {
|
||||
return metricsByFn(rows, (r) => r[key]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Group rows by a derived key (function) and return metrics per group. Lets the
|
||||
* dashboard break down by creative or playbook (spec §10 "by source/campaign/
|
||||
* creative/playbook") even though the daily rows are keyed by campaign/platform.
|
||||
* Rows whose keyFn returns null/undefined are skipped.
|
||||
*/
|
||||
export function metricsByFn(rows, keyFn) {
|
||||
const buckets = {};
|
||||
for (const r of rows) {
|
||||
const k = keyFn(r);
|
||||
if (k == null) continue;
|
||||
(buckets[k] ||= []).push(r);
|
||||
}
|
||||
return Object.entries(buckets).map(([k, rs]) => ({ key: k, ...metrics(totals(rs)) }));
|
||||
}
|
||||
|
||||
// ── Budget pacing (spec §4 Phase 4, §11 anomaly detection) ─────────────────────
|
||||
/**
|
||||
* Per active campaign: spend-to-date, average daily spend, a 30-day projection, and
|
||||
* how that projection paces against the monthly cap. status: over | on | under.
|
||||
*/
|
||||
export function budgetPacing(campaigns, analyticsDaily) {
|
||||
const spendBy = {};
|
||||
const daysBy = {};
|
||||
for (const r of analyticsDaily) {
|
||||
spendBy[r.campaignId] = (spendBy[r.campaignId] || 0) + r.spend;
|
||||
(daysBy[r.campaignId] ||= new Set()).add(r.date);
|
||||
}
|
||||
return campaigns.filter(c => c.status === 'active').map(c => {
|
||||
const spent = spendBy[c.id] || 0;
|
||||
const days = daysBy[c.id]?.size || 1;
|
||||
const avgDaily = spent / days;
|
||||
const monthlyCap = c.guardrails?.monthlyCap || c.budgetDaily * 30;
|
||||
const projected = avgDaily * 30;
|
||||
const pacePct = monthlyCap ? projected / monthlyCap : 0;
|
||||
const status = pacePct > 1.05 ? 'over' : pacePct < 0.6 ? 'under' : 'on';
|
||||
return { id: c.id, name: c.name, platform: c.platform, spent, avgDaily, monthlyCap, projected, pacePct, status };
|
||||
});
|
||||
}
|
||||
|
||||
// ── Multi-touch attribution models (spec §11: first/last/multi-touch) ──────────
|
||||
/**
|
||||
* Credit each journey's revenue to its touches under three models at once:
|
||||
* first → 100% to the first touch's source
|
||||
* last → 100% to the final (converting) touch
|
||||
* multi → split evenly (linear) across every touch
|
||||
* keyFn maps a touch { platform, campaignId } to the active dimension key, so the
|
||||
* same function powers platform/campaign/creative/playbook attribution.
|
||||
*/
|
||||
export function attributionByModel(journeys, keyFn) {
|
||||
const acc = {};
|
||||
const add = (k, model, v) => {
|
||||
if (k == null) return;
|
||||
(acc[k] ||= { first: 0, last: 0, multi: 0 });
|
||||
acc[k][model] += v;
|
||||
};
|
||||
for (const j of journeys) {
|
||||
const t = j.touches || [];
|
||||
if (!t.length) continue;
|
||||
add(keyFn(t[0]), 'first', j.revenue);
|
||||
add(keyFn(t[t.length - 1]), 'last', j.revenue);
|
||||
const share = j.revenue / t.length;
|
||||
for (const touch of t) add(keyFn(touch), 'multi', share);
|
||||
}
|
||||
return Object.entries(acc).map(([key, v]) => ({ key, ...v }));
|
||||
}
|
||||
|
||||
// ── Review & referral lift (spec §8 "reviews"; §10 review/referral lift) ───────
|
||||
/** Aggregate a set of reviews into count, avg rating, referral leads, reactivation $. */
|
||||
export function reviewTotals(reviews) {
|
||||
const count = reviews.length;
|
||||
return {
|
||||
count,
|
||||
avgRating: count ? reviews.reduce((s, r) => s + r.rating, 0) / count : 0,
|
||||
referralLeads: reviews.reduce((s, r) => s + (r.referralLeads || 0), 0),
|
||||
reactivationRevenue: reviews.reduce((s, r) => s + (r.reactivationRevenue || 0), 0),
|
||||
};
|
||||
}
|
||||
|
||||
/** Group reviews by a derived key and return the lift per group. */
|
||||
export function reviewsByFn(reviews, keyFn) {
|
||||
const buckets = {};
|
||||
for (const r of reviews) {
|
||||
const k = keyFn(r);
|
||||
if (k == null) continue;
|
||||
(buckets[k] ||= []).push(r);
|
||||
}
|
||||
return Object.entries(buckets).map(([k, rs]) => ({ key: k, ...reviewTotals(rs) }));
|
||||
}
|
||||
|
||||
/** Time series of one numeric field, sorted by date (for line/area charts). */
|
||||
export function timeSeries(rows, field) {
|
||||
const byDate = {};
|
||||
for (const r of rows) byDate[r.date] = (byDate[r.date] || 0) + r[field];
|
||||
return Object.entries(byDate)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([date, value]) => ({ date: date.slice(5), value }));
|
||||
}
|
||||
|
||||
/** Source-to-close funnel stages (spec §10 / §8 hub funnel). */
|
||||
export function funnel(rows) {
|
||||
const t = totals(rows);
|
||||
return [
|
||||
{ stage: 'Leads', value: t.leads },
|
||||
{ stage: 'Appointments', value: t.appointments },
|
||||
{ stage: 'Showed', value: t.shows },
|
||||
{ stage: 'Won', value: t.wins },
|
||||
];
|
||||
}
|
||||
|
||||
// ── Live, CRM-event-driven derivations (spec §5 closed loop, §10) ──────────────
|
||||
// These read the live `leads` array (not the historical analyticsDaily table) so the
|
||||
// dashboard reflects actions taken in the Lead Inbox in real time. Analytics are
|
||||
// based on CRM events, not impression-level assumptions (spec §6).
|
||||
|
||||
// Funnel order; index lets us ask "has the lead reached at least this stage?".
|
||||
export const LIFECYCLE = ['lead', 'appointment', 'showed', 'estimate', 'won'];
|
||||
const stageRank = (s) => LIFECYCLE.indexOf(s);
|
||||
|
||||
/** A lead "counts" for the funnel unless it was merged away or had no consent path. */
|
||||
const isActive = (l) => l.status !== 'merged';
|
||||
/** Reached at least `stage` (won counts toward estimate, showed, appointment, etc.). */
|
||||
const reached = (l, stage) => l.stage !== 'lost' && stageRank(l.stage) >= stageRank(stage);
|
||||
|
||||
/** Live source-to-close counters from the leads array. */
|
||||
export function liveCounts(leads) {
|
||||
const active = leads.filter(isActive);
|
||||
const contacted = active.filter(l => l.firstContactSec != null);
|
||||
return {
|
||||
leads: active.length,
|
||||
qualified: active.filter(l => l.triage.quality >= 50).length,
|
||||
spam: active.filter(l => l.triage.quality < 40).length,
|
||||
duplicates: leads.filter(l => l.duplicateOf).length,
|
||||
appointments: active.filter(l => reached(l, 'appointment')).length,
|
||||
shows: active.filter(l => reached(l, 'showed')).length,
|
||||
estimates: active.filter(l => reached(l, 'estimate')).length,
|
||||
wins: active.filter(l => l.stage === 'won').length,
|
||||
revenue: active.reduce((s, l) => s + (l.stage === 'won' ? l.dealValue : 0), 0),
|
||||
premiumWins: active.filter(l => l.stage === 'won' && l.premium).length,
|
||||
avgSpeedToLead: contacted.length
|
||||
? Math.round(contacted.reduce((s, l) => s + l.firstContactSec, 0) / contacted.length)
|
||||
: null,
|
||||
contacted: contacted.length,
|
||||
};
|
||||
}
|
||||
|
||||
/** Live ratios (spec §10 metric definitions) derived from liveCounts. */
|
||||
export function liveMetrics(leads) {
|
||||
const c = liveCounts(leads);
|
||||
return {
|
||||
...c,
|
||||
apptRate: safeDiv(c.appointments, c.leads),
|
||||
showRate: safeDiv(c.shows, c.appointments),
|
||||
estimateRate: safeDiv(c.estimates, c.shows),
|
||||
closeRate: safeDiv(c.wins, c.estimates),
|
||||
premiumMix: safeDiv(c.premiumWins, c.wins),
|
||||
spamRate: safeDiv(c.spam, c.leads),
|
||||
};
|
||||
}
|
||||
|
||||
/** Live funnel array for the bar chart. */
|
||||
export function liveFunnel(leads) {
|
||||
const c = liveCounts(leads);
|
||||
return [
|
||||
{ stage: 'Leads', value: c.leads },
|
||||
{ stage: 'Appointments', value: c.appointments },
|
||||
{ stage: 'Showed', value: c.shows },
|
||||
{ stage: 'Estimate', value: c.estimates },
|
||||
{ stage: 'Won', value: c.wins },
|
||||
];
|
||||
}
|
||||
|
||||
/** Live metrics grouped by platform (for source-to-close by channel). */
|
||||
export function liveMetricsByPlatform(leads) {
|
||||
const buckets = {};
|
||||
for (const l of leads.filter(isActive)) (buckets[l.platform] ||= []).push(l);
|
||||
return Object.entries(buckets).map(([platform, ls]) => ({ key: platform, ...liveMetrics(ls) }));
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* AdToNurtureRules — editable "if platform/service then playbook/rep/speed-to-lead/
|
||||
* education" routing rules (spec §5, §8). Rules evaluate by priority (reorderable); a
|
||||
* catch-all default guarantees no lead is left unmapped. Deterministic rules override
|
||||
* AI triage.
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
GitBranch, ArrowRight, Workflow, UserCheck, Zap, BookOpen, Power,
|
||||
Plus, Trash2, ChevronUp, ChevronDown, CalendarClock, Sparkles, CheckCircle2,
|
||||
} from 'lucide-react';
|
||||
import { useSocialAds } from '../SocialAdsContext.jsx';
|
||||
import { PLATFORMS } from '../data/platforms.js';
|
||||
import { matchRule, recommendPath } from '../ai/nurtureAdvisor.js';
|
||||
import { Card, SectionHeader, Btn } from '../components/ui.jsx';
|
||||
|
||||
const SERVICE_TYPES = ['storm', 'replacement', 'emergency', 'commercial', 'referral', 'maintenance'];
|
||||
const SCHEDULING = ['Send scheduling link', 'Auto-offer next available slot', 'Manual booking by rep', 'Human callback within SLA', 'No scheduling (info only)'];
|
||||
const selCls = 'w-full rounded-lg border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 px-2 py-1.5 text-base sm:text-xs text-zinc-800 dark:text-zinc-100 focus:outline-none focus:ring-2 focus:ring-amber-500';
|
||||
const inCls = 'w-full rounded-lg border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 px-2 py-1.5 text-base sm:text-xs text-zinc-800 dark:text-zinc-100 focus:outline-none focus:ring-2 focus:ring-amber-500';
|
||||
|
||||
export default function AdToNurtureRules() {
|
||||
const { nurtureRules, campaigns, leadForms, toggleRule, updateRule, addRule, removeRule, moveRule, addRuleFromSuggestion } = useSocialAds();
|
||||
const sorted = [...nurtureRules].sort((a, b) => a.priority - b.priority);
|
||||
const movable = sorted.filter(r => r.priority !== 99);
|
||||
|
||||
// AI "Next best nurture selection" (spec §6).
|
||||
const [advPlatform, setAdvPlatform] = useState('google');
|
||||
const [advService, setAdvService] = useState('emergency');
|
||||
const [advice, setAdvice] = useState(null);
|
||||
|
||||
const suggest = () => {
|
||||
const m = matchRule(nurtureRules, { platform: advPlatform, serviceType: advService });
|
||||
if (m.rule && !m.isDefault) setAdvice({ covered: true, rule: m.rule });
|
||||
else setAdvice({ covered: false, ...recommendPath(advService) });
|
||||
};
|
||||
const acceptAdvice = () => {
|
||||
addRuleFromSuggestion({ platform: advPlatform, serviceType: advService }, advice.then);
|
||||
setAdvice(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SectionHeader icon={GitBranch} title="Ad-to-Nurture Rules" description="Route each inbound lead to the right playbook, rep, speed-to-lead channel, and education track. Deterministic rules override AI triage.">
|
||||
<Btn icon={Plus} onClick={addRule}>Add rule</Btn>
|
||||
</SectionHeader>
|
||||
|
||||
{/* AI next-best-nurture (spec §6) */}
|
||||
<div className="rounded-xl border border-purple-200 dark:border-purple-500/20 bg-purple-50/60 dark:bg-purple-500/5 p-3 mb-4">
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<div className="flex items-center gap-2 mr-auto">
|
||||
<Sparkles size={15} className="text-purple-500 shrink-0" />
|
||||
<p className="text-xs text-zinc-600 dark:text-zinc-300">Next best nurture — pick a sample lead; AI recommends a route. Deterministic rules override AI.</p>
|
||||
</div>
|
||||
<select value={advPlatform} onChange={e => setAdvPlatform(e.target.value)} className={selCls + ' !w-auto'}>
|
||||
{PLATFORMS.map(p => <option key={p.id} value={p.id}>{p.short}</option>)}
|
||||
</select>
|
||||
<select value={advService} onChange={e => setAdvService(e.target.value)} className={selCls + ' !w-auto'}>
|
||||
{SERVICE_TYPES.map(s => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
<Btn size="sm" variant="outline" icon={Sparkles} onClick={suggest}>Suggest route</Btn>
|
||||
</div>
|
||||
|
||||
{advice && (advice.covered ? (
|
||||
<div className="mt-3 flex items-start gap-2 text-xs text-emerald-700 dark:text-emerald-400">
|
||||
<CheckCircle2 size={14} className="mt-0.5 shrink-0" />
|
||||
<span>Rule #{advice.rule.priority} (<b>{advice.rule.then.playbook}</b>) already routes this lead — AI deferred to your deterministic rule.</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-3 rounded-lg bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-white/10 p-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 mb-2">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-purple-500">AI recommended path</span>
|
||||
<Btn size="sm" variant="primary" icon={Plus} onClick={acceptAdvice}>Add as rule</Btn>
|
||||
</div>
|
||||
<p className="text-[11px] text-zinc-500 mb-2">{advice.rationale}</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-x-4 gap-y-1.5 text-[11px]">
|
||||
<PathBit label="Playbook" value={advice.then.playbook} />
|
||||
<PathBit label="Rep" value={advice.then.rep} />
|
||||
<PathBit label="Speed-to-lead" value={advice.then.speedToLead} />
|
||||
<PathBit label="Education" value={advice.then.education} />
|
||||
<PathBit label="Scheduling" value={advice.then.scheduling} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{sorted.map(rule => {
|
||||
const isDefault = rule.priority === 99;
|
||||
const idx = movable.findIndex(r => r.id === rule.id);
|
||||
return (
|
||||
<Card key={rule.id} className={`!h-auto ${!rule.enabled ? 'opacity-60' : ''}`} pad="p-4">
|
||||
<div className="flex flex-col lg:flex-row lg:items-start gap-3">
|
||||
{/* Priority + reorder */}
|
||||
<div className="flex lg:flex-col items-center gap-1.5 shrink-0">
|
||||
<span className={`flex items-center justify-center w-7 h-7 rounded-lg text-xs font-bold ${isDefault ? 'bg-zinc-200 dark:bg-zinc-700 text-zinc-500' : 'bg-amber-500/15 text-amber-600 dark:text-amber-400'}`}>
|
||||
{isDefault ? '∗' : rule.priority}
|
||||
</span>
|
||||
{!isDefault && (
|
||||
<div className="flex lg:flex-col">
|
||||
<button onClick={() => moveRule(rule.id, -1)} disabled={idx === 0} className="p-0.5 text-zinc-400 hover:text-amber-500 disabled:opacity-30" title="Higher priority"><ChevronUp size={15} /></button>
|
||||
<button onClick={() => moveRule(rule.id, 1)} disabled={idx === movable.length - 1} className="p-0.5 text-zinc-400 hover:text-amber-500 disabled:opacity-30" title="Lower priority"><ChevronDown size={15} /></button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* WHEN */}
|
||||
<div className="lg:w-52 shrink-0">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-zinc-400 block mb-1">When</span>
|
||||
{isDefault ? (
|
||||
<p className="text-xs text-zinc-500">Any platform · any service <span className="block text-[10px] text-zinc-400">(catch-all default)</span></p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
<select value={rule.when.platform} onChange={e => updateRule(rule.id, { when: { platform: e.target.value } })} className={selCls}>
|
||||
<option value="any">Any platform</option>
|
||||
{PLATFORMS.map(p => <option key={p.id} value={p.id}>{p.short}</option>)}
|
||||
</select>
|
||||
<select value={rule.when.serviceType} onChange={e => updateRule(rule.id, { when: { serviceType: e.target.value } })} className={selCls}>
|
||||
<option value="any">Any service</option>
|
||||
{SERVICE_TYPES.map(s => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
<select value={rule.when.campaignId || 'any'} onChange={e => updateRule(rule.id, { when: { campaignId: e.target.value } })} className={selCls}>
|
||||
<option value="any">Any campaign</option>
|
||||
{campaigns.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
<select value={rule.when.formId || 'any'} onChange={e => updateRule(rule.id, { when: { formId: e.target.value } })} className={selCls}>
|
||||
<option value="any">Any form</option>
|
||||
{leadForms.map(f => <option key={f.id} value={f.id}>{f.title}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ArrowRight size={16} className="text-amber-500 shrink-0 hidden lg:block mt-6" />
|
||||
|
||||
{/* THEN */}
|
||||
<div className="flex-1 grid grid-cols-1 sm:grid-cols-2 gap-2 min-w-0">
|
||||
<ThenField icon={Workflow} label="Playbook" value={rule.then.playbook} onChange={v => updateRule(rule.id, { then: { playbook: v } })} />
|
||||
<ThenField icon={UserCheck} label="Rep" value={rule.then.rep} onChange={v => updateRule(rule.id, { then: { rep: v } })} />
|
||||
<ThenField icon={Zap} label="Speed-to-lead" value={rule.then.speedToLead} onChange={v => updateRule(rule.id, { then: { speedToLead: v } })} />
|
||||
<ThenField icon={BookOpen} label="Education" value={rule.then.education} onChange={v => updateRule(rule.id, { then: { education: v } })} />
|
||||
<ThenField icon={CalendarClock} label="Scheduling" value={rule.then.scheduling || ''} options={SCHEDULING} onChange={v => updateRule(rule.id, { then: { scheduling: v } })} />
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex lg:flex-col items-center gap-2 shrink-0">
|
||||
<button onClick={() => toggleRule(rule.id)} disabled={isDefault}
|
||||
className={`flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs font-semibold transition-colors ${rule.enabled ? 'text-emerald-600 dark:text-emerald-400 bg-emerald-500/10' : 'text-zinc-400 bg-zinc-100 dark:bg-white/5'} ${isDefault ? 'cursor-not-allowed opacity-60' : 'hover:opacity-80'}`}>
|
||||
<Power size={13} /> {rule.enabled ? 'On' : 'Off'}
|
||||
</button>
|
||||
{!isDefault && (
|
||||
<button onClick={() => removeRule(rule.id)} title="Delete rule" className="p-1.5 text-zinc-400 hover:text-red-500"><Trash2 size={15} /></button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-zinc-400 mt-4 flex items-center gap-1">
|
||||
<GitBranch size={12} /> Rules evaluate top-down by priority. If no specific rule matches, the default inbound playbook runs and an admin is alerted.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PathBit({ label, value }) {
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<span className="text-[9px] font-bold uppercase tracking-wider text-zinc-400 block">{label}</span>
|
||||
<span className="text-zinc-700 dark:text-zinc-200">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ThenField({ icon: Icon, label, value, onChange, options }) {
|
||||
return (
|
||||
<label className="min-w-0 block">
|
||||
<span className="text-[9px] font-bold uppercase tracking-wider text-zinc-400 flex items-center gap-1 mb-1">{Icon && <Icon size={10} />}{label}</span>
|
||||
{options
|
||||
? (
|
||||
<select value={value} onChange={e => onChange(e.target.value)} className={inCls}>
|
||||
<option value="">—</option>
|
||||
{options.map(o => <option key={o} value={o}>{o}</option>)}
|
||||
</select>
|
||||
)
|
||||
: <input value={value} onChange={e => onChange(e.target.value)} className={inCls} />}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
/**
|
||||
* AttributionDashboard — spend, CPL, cost-per-appointment, show rate, close rate,
|
||||
* revenue, and ROAS by source/campaign (spec §8, §10). Analytics are built from CRM
|
||||
* events (the analyticsDaily series), not impression-level assumptions.
|
||||
*/
|
||||
import React, { useMemo, useState, useCallback } from 'react';
|
||||
import {
|
||||
BarChart3, DollarSign, Users, CalendarCheck, TrendingUp, Percent, Eye, Trophy,
|
||||
Zap, FileText, Crown, Activity, Filter, X, Star, Gift, Repeat, Route,
|
||||
ThumbsDown, Copy, CalendarX, Pause, Check, ShieldAlert,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell,
|
||||
LineChart, Line, CartesianGrid, Legend,
|
||||
} from 'recharts';
|
||||
import { useSocialAds } from '../SocialAdsContext.jsx';
|
||||
import { totals, metrics, metricsBy, metricsByFn, timeSeries, liveMetrics, reviewTotals, reviewsByFn, attributionByModel, budgetPacing } from '../engine/attribution.js';
|
||||
import { getPlatform } from '../data/platforms.js';
|
||||
import { Card, KpiTile, SectionHeader, PlatformBadge } from '../components/ui.jsx';
|
||||
|
||||
const money = (n) => `$${Math.round(n).toLocaleString('en-US')}`;
|
||||
const pct = (n) => `${Math.round(n * 100)}%`;
|
||||
const mmss = (s) => s == null ? '—' : `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, '0')}s`;
|
||||
|
||||
const MODEL_NOTE = {
|
||||
first: 'First-touch credits 100% to the source that first introduced the customer.',
|
||||
last: 'Last-touch credits 100% to the final source before the sale (the default single-touch view).',
|
||||
multi: 'Multi-touch splits credit evenly (linear) across every source in the journey.',
|
||||
};
|
||||
|
||||
const ANOMALY_META = {
|
||||
cpl_spike: { label: 'CPL spike', icon: TrendingUp, tone: 'red' },
|
||||
quality_drop: { label: 'Lead quality drop', icon: ThumbsDown, tone: 'amber' },
|
||||
duplicate_rate: { label: 'Abnormal duplicate rate', icon: Copy, tone: 'amber' },
|
||||
appt_decline: { label: 'Source-to-appointment decline', icon: CalendarX, tone: 'red' },
|
||||
};
|
||||
const PACE_CHIP = {
|
||||
over: 'text-red-600 dark:text-red-400 bg-red-500/10',
|
||||
on: 'text-emerald-600 dark:text-emerald-400 bg-emerald-500/10',
|
||||
under: 'text-amber-600 dark:text-amber-400 bg-amber-500/10',
|
||||
};
|
||||
const PACE_LABEL = { over: 'Pacing hot', on: 'On track', under: 'Under-pacing' };
|
||||
|
||||
export default function AttributionDashboard() {
|
||||
const { analyticsDaily, campaigns, creatives, leads, reviews, journeys, anomalies, resolveAnomaly } = useSocialAds();
|
||||
const [view, setView] = useState('platform'); // platform | campaign | creative | playbook
|
||||
const [selected, setSelected] = useState(null); // drill-down: a key within the current view
|
||||
const [model, setModel] = useState('last'); // attribution model: first | last | multi
|
||||
|
||||
// Lookups for the creative + playbook breakdowns (spec §10 dimensions).
|
||||
const campById = useMemo(() => Object.fromEntries(campaigns.map(c => [c.id, c])), [campaigns]);
|
||||
const creativeById = useMemo(() => Object.fromEntries(creatives.map(c => [c.id, c])), [creatives]);
|
||||
const primaryCreativeByCampaign = useMemo(() => {
|
||||
const m = {};
|
||||
for (const cr of creatives) if (!m[cr.campaignId]) m[cr.campaignId] = cr;
|
||||
return m;
|
||||
}, [creatives]);
|
||||
|
||||
// The dimension key for any object carrying { platform, campaignId } — works for
|
||||
// both analyticsDaily rows and leads, so a drill-down filters both consistently.
|
||||
const keyOf = useCallback((v, o) => {
|
||||
if (v === 'platform') return o.platform;
|
||||
if (v === 'campaign') return o.campaignId;
|
||||
if (v === 'creative') return primaryCreativeByCampaign[o.campaignId]?.id || `nocre:${o.campaignId}`;
|
||||
return campById[o.campaignId]?.playbook || 'Unmapped';
|
||||
}, [primaryCreativeByCampaign, campById]);
|
||||
|
||||
// Scope spend + leads to the drilled-down entity (top KPIs + trend), if any.
|
||||
const switchView = (v) => { setView(v); setSelected(null); };
|
||||
const filteredDaily = useMemo(() => selected ? analyticsDaily.filter(r => keyOf(view, r) === selected) : analyticsDaily, [analyticsDaily, selected, view, keyOf]);
|
||||
const filteredLeads = useMemo(() => selected ? leads.filter(l => keyOf(view, l) === selected) : leads, [leads, selected, view, keyOf]);
|
||||
const filteredReviews = useMemo(() => selected ? reviews.filter(r => keyOf(view, r) === selected) : reviews, [reviews, selected, view, keyOf]);
|
||||
|
||||
// Review & referral lift (spec §10) — totals scope to the drill-down; the breakdown
|
||||
// groups all reviews by the current dimension.
|
||||
const reviewAgg = useMemo(() => reviewTotals(filteredReviews), [filteredReviews]);
|
||||
const reviewsBreakdown = useMemo(() => reviewsByFn(reviews, r => keyOf(view, r)).sort((a, b) => b.count - a.count), [reviews, view, keyOf]);
|
||||
|
||||
// Multi-touch attribution by model (spec §11), grouped by the current dimension.
|
||||
const modelRows = useMemo(() => attributionByModel(journeys, t => keyOf(view, t)).sort((a, b) => b[model] - a[model]), [journeys, view, keyOf, model]);
|
||||
const modelTotal = useMemo(() => modelRows.reduce((s, r) => s + r[model], 0), [modelRows, model]);
|
||||
|
||||
// Budget pacing + open anomaly alerts (spec §11).
|
||||
const pacing = useMemo(() => budgetPacing(campaigns, analyticsDaily), [campaigns, analyticsDaily]);
|
||||
const openAlerts = useMemo(() => anomalies.filter(a => a.status === 'open'), [anomalies]);
|
||||
|
||||
// Spend/ROAS = platform-reported; lead→won + rates = live CRM events (spec §6, §10).
|
||||
// Both honor the active drill-down filter so the KPI cards reconcile with it.
|
||||
const reported = useMemo(() => metrics(totals(filteredDaily)), [filteredDaily]);
|
||||
const live = useMemo(() => liveMetrics(filteredLeads), [filteredLeads]);
|
||||
const m = useMemo(() => ({
|
||||
...live,
|
||||
spend: reported.spend,
|
||||
cpl: live.leads ? reported.spend / live.leads : 0,
|
||||
costPerAppt: live.appointments ? reported.spend / live.appointments : 0,
|
||||
roas: reported.spend ? live.revenue / reported.spend : 0,
|
||||
}), [reported, live]);
|
||||
const bySource = useMemo(() => {
|
||||
if (view === 'platform') return metricsBy(analyticsDaily, 'platform');
|
||||
if (view === 'campaign') return metricsBy(analyticsDaily, 'campaignId');
|
||||
if (view === 'creative') return metricsByFn(analyticsDaily, r => primaryCreativeByCampaign[r.campaignId]?.id || `nocre:${r.campaignId}`);
|
||||
if (view === 'playbook') return metricsByFn(analyticsDaily, r => campById[r.campaignId]?.playbook || 'Unmapped');
|
||||
return [];
|
||||
}, [analyticsDaily, view, primaryCreativeByCampaign, campById]);
|
||||
|
||||
const revenueSeries = useMemo(() => {
|
||||
// Merge revenue + spend per date for a dual-line chart (respects drill-down).
|
||||
const rev = timeSeries(filteredDaily, 'revenue');
|
||||
const spend = timeSeries(filteredDaily, 'spend');
|
||||
return rev.map((r, i) => ({ date: r.date, revenue: r.value, spend: spend[i]?.value || 0 }));
|
||||
}, [filteredDaily]);
|
||||
|
||||
const campName = (id) => campById[id]?.name || id;
|
||||
|
||||
// Full + short labels for whichever dimension is selected.
|
||||
const labelFor = (k) => {
|
||||
if (view === 'platform') return getPlatform(k)?.name || k;
|
||||
if (view === 'campaign') return campName(k);
|
||||
if (view === 'creative') return k.startsWith('nocre:') ? `${campName(k.slice(6))} — no creative on file` : (creativeById[k]?.headline || k);
|
||||
return k; // playbook key is the name itself
|
||||
};
|
||||
const shortLabel = (k) => {
|
||||
if (view === 'platform') return getPlatform(k)?.short || k;
|
||||
if (view === 'campaign') return campName(k).slice(0, 10);
|
||||
if (view === 'creative') return (k.startsWith('nocre:') ? campName(k.slice(6)) : (creativeById[k]?.headline || k)).slice(0, 12);
|
||||
return k.split(' ').slice(0, 2).join(' ');
|
||||
};
|
||||
const barColor = (k) => {
|
||||
if (view === 'platform') return getPlatform(k)?.color || '#3b82f6';
|
||||
if (view === 'creative' && !k.startsWith('nocre:')) return getPlatform(creativeById[k]?.platform)?.color || '#3b82f6';
|
||||
return '#3b82f6';
|
||||
};
|
||||
|
||||
// ROAS leaderboard
|
||||
const ranked = [...bySource].sort((a, b) => b.roas - a.roas);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SectionHeader icon={BarChart3} title="Attribution Dashboard" description="Source-to-close economics built from CRM events — leads alone are not enough.">
|
||||
<div className="flex flex-wrap rounded-lg overflow-hidden border border-zinc-200 dark:border-white/10">
|
||||
{['platform', 'campaign', 'creative', 'playbook'].map(v => (
|
||||
<button key={v} onClick={() => switchView(v)}
|
||||
className={`px-3 py-1.5 text-xs font-semibold capitalize ${view === v ? 'bg-amber-500 text-white' : 'bg-white dark:bg-zinc-900 text-zinc-500'}`}>
|
||||
By {v}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</SectionHeader>
|
||||
|
||||
{/* Live banner + drill-down filter chip */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 -mb-2">
|
||||
<div className="flex items-center gap-2 text-[11px] text-emerald-600 dark:text-emerald-400">
|
||||
<Activity size={13} className="animate-pulse" />
|
||||
<span className="font-semibold">Live</span>
|
||||
<span className="text-zinc-400">— lead-to-won metrics update from CRM events as you act in the Lead Inbox. Spend is platform-reported.</span>
|
||||
</div>
|
||||
{selected && (
|
||||
<button onClick={() => setSelected(null)}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-semibold bg-amber-500/15 text-amber-700 dark:text-amber-400 hover:bg-amber-500/25 transition-colors">
|
||||
<Filter size={12} /> {labelFor(selected)} <span className="text-amber-500/60">· by {view}</span> <X size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Budget pacing & anomaly alerts (spec §11) */}
|
||||
<Card title="Budget pacing & anomaly alerts" subtitle="AI flags spend/quality anomalies; you pause or review" icon={ShieldAlert}>
|
||||
{/* Alerts */}
|
||||
{openAlerts.length === 0 ? (
|
||||
<div className="flex items-center gap-2 text-sm text-emerald-600 dark:text-emerald-400 mb-4">
|
||||
<Check size={16} /> No open anomalies — all campaigns within guardrails.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 mb-5">
|
||||
{openAlerts.map(a => {
|
||||
const meta = ANOMALY_META[a.type] || ANOMALY_META.cpl_spike;
|
||||
const AIcon = meta.icon;
|
||||
const toneCls = meta.tone === 'red' ? 'text-red-500 bg-red-500/10' : 'text-amber-500 bg-amber-500/10';
|
||||
return (
|
||||
<div key={a.id} className="flex flex-col sm:flex-row sm:items-center gap-3 p-3 rounded-xl border border-zinc-200 dark:border-white/10 bg-zinc-50 dark:bg-white/[0.02]">
|
||||
<span className={`p-2 rounded-lg shrink-0 self-start ${toneCls}`}>{AIcon && <AIcon size={16} />}</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-bold text-sm text-zinc-900 dark:text-white">{campName(a.campaignId)}</span>
|
||||
<span className={`text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded ${a.severity === 'high' ? 'text-red-600 dark:text-red-400 bg-red-500/10' : 'text-amber-600 dark:text-amber-400 bg-amber-500/10'}`}>{a.severity}</span>
|
||||
<span className="text-[11px] text-zinc-400">{meta.label}</span>
|
||||
</div>
|
||||
<p className="text-xs font-mono text-zinc-700 dark:text-zinc-200 mt-0.5">{a.metric}</p>
|
||||
<p className="text-[11px] text-zinc-500 dark:text-zinc-400">{a.detail}</p>
|
||||
<p className="text-[11px] text-purple-500 mt-0.5 flex items-center gap-1"><Activity size={11} /> AI recommends: <b>{a.recommendation === 'pause' ? 'pause the campaign' : 'review & optimize'}</b></p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0 self-start sm:self-center">
|
||||
<button onClick={() => resolveAnomaly(a.id, 'paused')} title="Pause campaign"
|
||||
className={`inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs font-semibold ${a.recommendation === 'pause' ? 'bg-red-500/10 text-red-600 dark:text-red-400 hover:bg-red-500/20' : 'bg-zinc-100 dark:bg-white/5 text-zinc-600 dark:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-white/10'}`}>
|
||||
<Pause size={13} /> Pause
|
||||
</button>
|
||||
<button onClick={() => resolveAnomaly(a.id, 'reviewed')} title="Mark reviewed"
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs font-semibold bg-zinc-100 dark:bg-white/5 text-zinc-600 dark:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-white/10">
|
||||
<Check size={13} /> Review
|
||||
</button>
|
||||
<button onClick={() => resolveAnomaly(a.id, 'dismissed')} title="Dismiss" className="p-1.5 text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200"><X size={15} /></button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Budget pacing per active campaign */}
|
||||
<p className="text-[10px] font-bold uppercase tracking-wider text-zinc-400 mb-2">Budget pacing — active campaigns</p>
|
||||
<div className="space-y-2">
|
||||
{pacing.map(p => (
|
||||
<div key={p.id} className="flex items-center gap-3">
|
||||
<span className="text-xs font-semibold text-zinc-700 dark:text-zinc-200 w-40 sm:w-52 truncate shrink-0" title={p.name}>{p.name}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="h-2 rounded-full bg-zinc-200 dark:bg-zinc-700 overflow-hidden">
|
||||
<div className={`h-full ${p.status === 'over' ? 'bg-red-500' : p.status === 'under' ? 'bg-amber-500' : 'bg-emerald-500'}`} style={{ width: `${Math.min(100, p.pacePct * 100)}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[11px] font-mono text-zinc-400 w-28 text-right shrink-0 hidden sm:block">{money(p.projected)} / {money(p.monthlyCap)}</span>
|
||||
<span className={`text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded-full shrink-0 ${PACE_CHIP[p.status]}`}>{PACE_LABEL[p.status]}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* KPI row */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<KpiTile label="Spend (reported)" value={money(m.spend)} icon={DollarSign} color="amber" />
|
||||
<KpiTile label="CPL" value={money(m.cpl)} icon={Users} color="blue" sub={`${m.leads} leads`} />
|
||||
<KpiTile label="Cost / appt" value={money(m.costPerAppt)} icon={CalendarCheck} color="purple" sub={`${m.appointments} appts`} />
|
||||
<KpiTile label="ROAS" value={`${m.roas.toFixed(1)}×`} icon={TrendingUp} color="emerald" sub={money(m.revenue)} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<KpiTile label="Appt rate" value={pct(m.apptRate)} icon={CalendarCheck} color="blue" />
|
||||
<KpiTile label="Show rate" value={pct(m.showRate)} icon={Eye} color="cyan" />
|
||||
<KpiTile label="Estimate rate" value={pct(m.estimateRate)} icon={FileText} color="blue" sub={`${m.estimates} sent`} />
|
||||
<KpiTile label="Close rate" value={pct(m.closeRate)} icon={Percent} color="emerald" sub={`${m.wins} won`} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<KpiTile label="Speed-to-lead" value={mmss(m.avgSpeedToLead)} icon={Zap} color="amber" sub="avg to first contact" />
|
||||
<KpiTile label="Premium mix" value={pct(m.premiumMix)} icon={Crown} color="purple" sub={`${m.premiumWins} premium`} />
|
||||
<KpiTile label="Qualified leads" value={m.qualified} icon={Users} color="emerald" sub={`spam ${pct(m.spamRate)}`} />
|
||||
<KpiTile label="Revenue (won)" value={money(m.revenue)} icon={Trophy} color="amber" />
|
||||
</div>
|
||||
|
||||
{/* Revenue vs spend */}
|
||||
<Card title="Revenue vs. spend" subtitle="Daily, all sources" icon={TrendingUp}>
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<LineChart data={revenueSeries} margin={{ top: 5, right: 10, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#71717a22" vertical={false} />
|
||||
<XAxis dataKey="date" tick={{ fill: '#71717a', fontSize: 11 }} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fill: '#71717a', fontSize: 11 }} axisLine={false} tickLine={false} />
|
||||
<Tooltip contentStyle={{ background: 'rgba(24,24,27,0.95)', border: '1px solid #3f3f46', borderRadius: 12, fontSize: 12 }} labelStyle={{ color: '#fff' }} formatter={(v, n) => [money(v), n === 'revenue' ? 'Revenue' : 'Spend']} />
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
<Line type="monotone" dataKey="revenue" stroke="#10b981" strokeWidth={2.5} dot={false} name="Revenue" />
|
||||
<Line type="monotone" dataKey="spend" stroke="#f59e0b" strokeWidth={2} dot={false} name="Spend" />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* CPL by source */}
|
||||
<Card title={`Cost per lead by ${view}`} icon={Users}>
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<BarChart data={bySource} margin={{ top: 5, right: 20, left: -10, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#71717a22" vertical={false} />
|
||||
<XAxis dataKey="key" tick={{ fill: '#71717a', fontSize: 10 }} axisLine={false} tickLine={false}
|
||||
interval={0} tickFormatter={shortLabel} />
|
||||
<YAxis tick={{ fill: '#71717a', fontSize: 11 }} axisLine={false} tickLine={false} />
|
||||
<Tooltip cursor={{ fill: 'transparent' }} contentStyle={{ background: 'rgba(24,24,27,0.95)', border: '1px solid #3f3f46', borderRadius: 12, fontSize: 12 }}
|
||||
formatter={(v) => [money(v), 'CPL']} labelFormatter={labelFor} />
|
||||
<Bar dataKey="cpl" radius={[6, 6, 0, 0]} barSize={36}>
|
||||
{bySource.map((s, i) => <Cell key={i} fill={barColor(s.key)} />)}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
|
||||
{/* ROAS leaderboard */}
|
||||
<Card title="ROAS leaderboard" subtitle="Ranked by return — click a row to filter" icon={Trophy}>
|
||||
<div className="space-y-2">
|
||||
{ranked.map((s, i) => (
|
||||
<div key={s.key} onClick={() => setSelected(sel => sel === s.key ? null : s.key)}
|
||||
title="Click to filter the dashboard to this row"
|
||||
className={`flex items-center gap-3 px-3 py-2 rounded-lg cursor-pointer transition-colors ${selected === s.key ? 'bg-amber-50 dark:bg-amber-500/10 ring-1 ring-amber-400/60' : 'bg-zinc-50 dark:bg-white/5 hover:bg-zinc-100 dark:hover:bg-white/10'}`}>
|
||||
<span className="text-xs font-bold text-zinc-400 w-4">{i + 1}</span>
|
||||
{view === 'platform'
|
||||
? <PlatformBadge platformId={s.key} />
|
||||
: <span className="text-sm font-semibold text-zinc-700 dark:text-zinc-200 truncate flex-1" title={labelFor(s.key)}>{labelFor(s.key)}</span>}
|
||||
<div className="flex items-center gap-3 ml-auto text-xs">
|
||||
<span className="text-zinc-400">{money(s.revenue)}</span>
|
||||
<span className={`font-mono font-bold w-12 text-right ${s.roas >= 1 ? 'text-emerald-500' : 'text-red-500'}`}>{s.roas.toFixed(1)}×</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Review & referral lift (spec §8 "reviews"; §10 review/referral lift) */}
|
||||
<Card title="Review & referral lift" subtitle={`Post-job reviews attributed back to source${selected ? ` · filtered to ${labelFor(selected)}` : ''}`} icon={Star}>
|
||||
<div className="grid grid-cols-3 gap-2 mb-4">
|
||||
<LiftStat icon={Star} color="text-amber-500" value={reviewAgg.count} label="Reviews" sub={reviewAgg.count ? `${reviewAgg.avgRating.toFixed(1)}★ avg` : 'no reviews'} />
|
||||
<LiftStat icon={Gift} color="text-emerald-500" value={reviewAgg.referralLeads} label="Referral leads" sub="generated" />
|
||||
<LiftStat icon={Repeat} color="text-purple-500" value={money(reviewAgg.reactivationRevenue)} label="Reactivation $" sub="repeat revenue" />
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-left text-[10px] uppercase tracking-wider text-zinc-400 border-b border-zinc-100 dark:border-white/5">
|
||||
<th className="py-2 pr-3 font-bold">{view}</th>
|
||||
<th className="py-2 px-2 font-bold text-right">Reviews</th>
|
||||
<th className="py-2 px-2 font-bold text-right">Avg rating</th>
|
||||
<th className="py-2 px-2 font-bold text-right">Referral leads</th>
|
||||
<th className="py-2 pl-2 font-bold text-right">Reactivation $</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{reviewsBreakdown.length === 0 && <tr><td colSpan={5} className="py-4 text-center text-zinc-400">No reviews for this dimension yet.</td></tr>}
|
||||
{reviewsBreakdown.map(b => (
|
||||
<tr key={b.key} onClick={() => setSelected(sel => sel === b.key ? null : b.key)}
|
||||
className={`border-b border-zinc-50 dark:border-white/5 cursor-pointer transition-colors ${selected === b.key ? 'bg-amber-50 dark:bg-amber-500/10' : 'hover:bg-zinc-50 dark:hover:bg-white/5'}`}>
|
||||
<td className="py-2 pr-3">
|
||||
{view === 'platform' ? <PlatformBadge platformId={b.key} size="sm" /> : <span className="font-semibold text-zinc-700 dark:text-zinc-200">{labelFor(b.key)}</span>}
|
||||
</td>
|
||||
<td className="py-2 px-2 text-right font-mono">{b.count}</td>
|
||||
<td className="py-2 px-2 text-right font-mono text-amber-600 dark:text-amber-400">{b.avgRating.toFixed(1)}★</td>
|
||||
<td className="py-2 px-2 text-right font-mono text-emerald-600 dark:text-emerald-400">{b.referralLeads}</td>
|
||||
<td className="py-2 pl-2 text-right font-mono">{money(b.reactivationRevenue)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Multi-touch attribution models (spec §11) */}
|
||||
<Card title="Multi-touch attribution" subtitle={`How closed-won revenue is credited across sources, by ${view}`} icon={Route}
|
||||
action={
|
||||
<div className="flex rounded-lg overflow-hidden border border-zinc-200 dark:border-white/10">
|
||||
{[['first', 'First-touch'], ['last', 'Last-touch'], ['multi', 'Multi-touch']].map(([m, label]) => (
|
||||
<button key={m} onClick={() => setModel(m)}
|
||||
className={`px-2.5 py-1.5 text-[11px] font-semibold ${model === m ? 'bg-amber-500 text-white' : 'bg-white dark:bg-zinc-900 text-zinc-500'}`}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
}>
|
||||
<p className="text-[11px] text-zinc-500 dark:text-zinc-400 mb-3 flex items-start gap-1.5">
|
||||
<Route size={13} className="text-amber-500 mt-0.5 shrink-0" />
|
||||
{MODEL_NOTE[model]} Highlighted column is the active model; compare to see how credit shifts.
|
||||
</p>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-left text-[10px] uppercase tracking-wider text-zinc-400 border-b border-zinc-100 dark:border-white/5">
|
||||
<th className="py-2 pr-3 font-bold">{view}</th>
|
||||
<th className={`py-2 px-2 font-bold text-right ${model === 'first' ? 'text-amber-600 dark:text-amber-400' : ''}`}>First-touch</th>
|
||||
<th className={`py-2 px-2 font-bold text-right ${model === 'last' ? 'text-amber-600 dark:text-amber-400' : ''}`}>Last-touch</th>
|
||||
<th className={`py-2 pl-2 font-bold text-right ${model === 'multi' ? 'text-amber-600 dark:text-amber-400' : ''}`}>Multi-touch</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{modelRows.length === 0 && <tr><td colSpan={4} className="py-4 text-center text-zinc-400">No closed-won journeys for this dimension yet.</td></tr>}
|
||||
{modelRows.map(r => (
|
||||
<tr key={r.key} onClick={() => setSelected(sel => sel === r.key ? null : r.key)}
|
||||
className={`border-b border-zinc-50 dark:border-white/5 cursor-pointer transition-colors ${selected === r.key ? 'bg-amber-50 dark:bg-amber-500/10' : 'hover:bg-zinc-50 dark:hover:bg-white/5'}`}>
|
||||
<td className="py-2 pr-3">
|
||||
{view === 'platform' ? <PlatformBadge platformId={r.key} size="sm" /> : <span className="font-semibold text-zinc-700 dark:text-zinc-200">{labelFor(r.key)}</span>}
|
||||
</td>
|
||||
<td className={`py-2 px-2 text-right font-mono ${model === 'first' ? 'font-bold text-amber-600 dark:text-amber-400' : 'text-zinc-500'}`}>{money(r.first)}</td>
|
||||
<td className={`py-2 px-2 text-right font-mono ${model === 'last' ? 'font-bold text-amber-600 dark:text-amber-400' : 'text-zinc-500'}`}>{money(r.last)}</td>
|
||||
<td className={`py-2 pl-2 text-right font-mono ${model === 'multi' ? 'font-bold text-amber-600 dark:text-amber-400' : 'text-zinc-500'}`}>{money(r.multi)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
{modelRows.length > 0 && (
|
||||
<tfoot>
|
||||
<tr className="text-[11px] font-bold text-zinc-600 dark:text-zinc-300">
|
||||
<td className="py-2 pr-3">Total ({model}-touch)</td>
|
||||
<td colSpan={3} className="py-2 pl-2 text-right font-mono text-emerald-600 dark:text-emerald-400">{money(modelTotal)}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
)}
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Detail table */}
|
||||
<Card title={`Performance by ${view}`} subtitle="Click a row to filter the KPIs above" icon={BarChart3}>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-left text-[10px] uppercase tracking-wider text-zinc-400 border-b border-zinc-100 dark:border-white/5">
|
||||
<th className="py-2 pr-3 font-bold">{view}</th>
|
||||
<th className="py-2 px-2 font-bold text-right">Spend</th>
|
||||
<th className="py-2 px-2 font-bold text-right">Leads</th>
|
||||
<th className="py-2 px-2 font-bold text-right">CPL</th>
|
||||
<th className="py-2 px-2 font-bold text-right">Appts</th>
|
||||
<th className="py-2 px-2 font-bold text-right">Show</th>
|
||||
<th className="py-2 px-2 font-bold text-right">Close</th>
|
||||
<th className="py-2 px-2 font-bold text-right">Revenue</th>
|
||||
<th className="py-2 pl-2 font-bold text-right">ROAS</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{bySource.map(s => (
|
||||
<tr key={s.key} onClick={() => setSelected(sel => sel === s.key ? null : s.key)}
|
||||
className={`border-b border-zinc-50 dark:border-white/5 cursor-pointer transition-colors ${selected === s.key ? 'bg-amber-50 dark:bg-amber-500/10' : 'hover:bg-zinc-50 dark:hover:bg-white/5'}`}>
|
||||
<td className="py-2 pr-3">
|
||||
{view === 'platform' ? <PlatformBadge platformId={s.key} size="sm" /> : <span className="font-semibold text-zinc-700 dark:text-zinc-200">{labelFor(s.key)}</span>}
|
||||
</td>
|
||||
<td className="py-2 px-2 text-right font-mono">{money(s.spend)}</td>
|
||||
<td className="py-2 px-2 text-right font-mono">{s.leads}</td>
|
||||
<td className="py-2 px-2 text-right font-mono">{money(s.cpl)}</td>
|
||||
<td className="py-2 px-2 text-right font-mono">{s.appointments}</td>
|
||||
<td className="py-2 px-2 text-right font-mono">{pct(s.showRate)}</td>
|
||||
<td className="py-2 px-2 text-right font-mono">{pct(s.closeRate)}</td>
|
||||
<td className="py-2 px-2 text-right font-mono text-emerald-600 dark:text-emerald-400">{money(s.revenue)}</td>
|
||||
<td className={`py-2 pl-2 text-right font-mono font-bold ${s.roas >= 1 ? 'text-emerald-500' : 'text-red-500'}`}>{s.roas.toFixed(1)}×</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LiftStat({ icon: Icon, color, value, label, sub }) {
|
||||
return (
|
||||
<div className="rounded-xl bg-zinc-50 dark:bg-white/5 p-3 text-center">
|
||||
{Icon && <Icon size={16} className={`${color} mx-auto mb-1`} />}
|
||||
<p className="text-lg font-mono font-bold text-zinc-800 dark:text-zinc-100">{value}</p>
|
||||
<p className="text-[10px] uppercase tracking-wider text-zinc-400">{label}</p>
|
||||
{sub && <p className="text-[10px] text-zinc-400 mt-0.5">{sub}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
/**
|
||||
* AudienceSegments — reusable audiences with consent count, suppression count,
|
||||
* export/sync status, refresh, AND an editable segment builder with a live preview
|
||||
* count that always applies opt-out + suppression filtering (spec §4, §8, §12 E1/E2).
|
||||
* Exports/syncs only permitted, consented, suppression-filtered contacts.
|
||||
*/
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import {
|
||||
Users, RefreshCw, Download, Upload, ShieldCheck, ShieldX, UserCheck,
|
||||
Plus, Pencil, X, Target, MapPin, Lock, ShieldQuestion, Sparkles, SlidersHorizontal,
|
||||
CheckCircle2, ChevronDown, CircleDashed,
|
||||
} from 'lucide-react';
|
||||
import { useSocialAds } from '../SocialAdsContext.jsx';
|
||||
import { FILTER_OPTIONS, previewSegment, DEFAULT_FILTER_DEF } from '../data/contactPool.js';
|
||||
import { AUDIENCE_SUGGESTIONS } from '../ai/audienceAdvisor.js';
|
||||
import { Card, SectionHeader, Btn } from '../components/ui.jsx';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const SEGMENT_LABEL = {
|
||||
storm_geo: 'Storm geo', crm_stage: 'CRM stage', reactivation: 'Reactivation',
|
||||
b2b: 'B2B', referral: 'Referral',
|
||||
};
|
||||
const LAST_JOB_OPTIONS = [['', 'Any time'], ['90', '< 90 days'], ['180', '< 180 days'], ['365', '< 1 year'], ['540', '< 18 months']];
|
||||
|
||||
export default function AudienceSegments() {
|
||||
const { audiences, createAudience, updateAudience, syncAudience, refreshAudience } = useSocialAds();
|
||||
const [editing, setEditing] = useState(null); // { audience } edit · { preset } new-from-AI · {} blank new · null closed
|
||||
const [dismissed, setDismissed] = useState([]);
|
||||
|
||||
// Hide a suggestion once a segment with that name exists, or the user dismisses it.
|
||||
const existingNames = useMemo(() => new Set(audiences.map(a => a.name)), [audiences]);
|
||||
const suggestions = AUDIENCE_SUGGESTIONS.filter(s => !existingNames.has(s.name) && !dismissed.includes(s.key));
|
||||
|
||||
const exportCsv = (a) => toast.success(`Exported ${(a.eligibleCount ?? a.consentCount).toLocaleString()} eligible contacts from "${a.name}" to CSV`);
|
||||
const handleSync = (a, dest) => {
|
||||
const tid = toast.loading(`Syncing "${a.name}" to ${dest}…`);
|
||||
setTimeout(() => { toast.dismiss(tid); syncAudience(a.id, dest); }, 700);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SectionHeader icon={Users} title="Audience Segments" description="Reusable CRM audiences. Only consented contacts sync, and suppression lists are always applied — including for retargeting.">
|
||||
<Btn icon={Plus} onClick={() => setEditing({})}>New segment</Btn>
|
||||
</SectionHeader>
|
||||
|
||||
{/* AI audience suggestions (spec §6) */}
|
||||
{suggestions.length > 0 && (
|
||||
<div className="rounded-xl border border-purple-200 dark:border-purple-500/20 bg-purple-50/60 dark:bg-purple-500/5 p-3 mb-5">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Sparkles size={15} className="text-purple-500 shrink-0" />
|
||||
<p className="text-xs text-zinc-600 dark:text-zinc-300">AI suggested segments — consent + suppression always applied; no sensitive-attribute targeting.</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
{suggestions.map(s => (
|
||||
<SuggestionCard key={s.key} s={s}
|
||||
onCreate={() => createAudience({ name: s.name, segmentType: s.segmentType, filterDef: s.filterDef })}
|
||||
onCustomize={() => setEditing({ preset: s })}
|
||||
onDismiss={() => setDismissed(d => [...d, s.key])} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{audiences.map(a => {
|
||||
const eligible = a.eligibleCount ?? a.consentCount;
|
||||
const eligiblePct = a.totalCount ? Math.round((eligible / a.totalCount) * 100) : 0;
|
||||
return (
|
||||
<Card key={a.id} className="!h-auto" pad="p-4">
|
||||
<div className="flex items-start justify-between mb-2 gap-2">
|
||||
<div className="min-w-0">
|
||||
<h4 className="font-bold text-sm text-zinc-900 dark:text-white">{a.name}</h4>
|
||||
<p className="text-[11px] text-zinc-400">{a.filters}</p>
|
||||
</div>
|
||||
<span className="text-[10px] font-bold uppercase tracking-wide px-2 py-0.5 rounded-full bg-zinc-100 dark:bg-white/10 text-zinc-500 shrink-0">
|
||||
{SEGMENT_LABEL[a.segmentType] || a.segmentType}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Count breakdown */}
|
||||
<div className="grid grid-cols-3 gap-2 my-3">
|
||||
<Stat icon={Users} label="Total" value={a.totalCount} color="text-zinc-500" />
|
||||
<Stat icon={ShieldCheck} label="Consented" value={a.consentCount} color="text-emerald-500" />
|
||||
<Stat icon={ShieldX} label="Suppressed" value={a.suppressionCount} color="text-red-500" />
|
||||
</div>
|
||||
|
||||
{/* Eligible bar (consent ∩ not-suppressed) */}
|
||||
<div className="mb-3">
|
||||
<div className="flex items-center justify-between text-[10px] text-zinc-400 mb-1">
|
||||
<span className="flex items-center gap-1"><UserCheck size={11} /> Eligible to sync (consent + suppression)</span>
|
||||
<span className="font-mono font-semibold text-emerald-600 dark:text-emerald-400">{eligible.toLocaleString()} · {eligiblePct}%</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-zinc-200 dark:bg-zinc-700 overflow-hidden">
|
||||
<div className="h-full bg-gradient-to-r from-emerald-400 to-emerald-500" style={{ width: `${eligiblePct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2 mb-2">
|
||||
<span className="text-[10px] text-zinc-400">Updated {a.lastCount}{a.frequencyCap ? ` · freq cap ${a.frequencyCap}/wk` : ''}</span>
|
||||
<SyncStatusChip audience={a} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1.5 pt-3 border-t border-zinc-100 dark:border-white/5">
|
||||
<Btn size="sm" variant="ghost" icon={Pencil} onClick={() => setEditing({ audience: a })}>Edit</Btn>
|
||||
<Btn size="sm" variant="ghost" icon={RefreshCw} onClick={() => refreshAudience(a.id)}>Refresh</Btn>
|
||||
<div className="flex items-center gap-1.5 ml-auto">
|
||||
<Btn size="sm" variant="outline" icon={Download} onClick={() => exportCsv(a)}>CSV</Btn>
|
||||
<SyncMenu onSync={(dest) => handleSync(a, dest)} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{editing && (
|
||||
<SegmentBuilder
|
||||
audience={editing.audience}
|
||||
preset={editing.preset}
|
||||
onClose={() => setEditing(null)}
|
||||
onSave={(def) => {
|
||||
if (editing.audience) updateAudience(editing.audience.id, def);
|
||||
else createAudience(def);
|
||||
setEditing(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ icon: Icon, label, value, color }) {
|
||||
return (
|
||||
<div className="rounded-lg bg-zinc-50 dark:bg-white/5 p-2 text-center">
|
||||
{Icon && <Icon size={14} className={`${color} mx-auto mb-0.5`} />}
|
||||
<p className="text-sm font-mono font-bold text-zinc-800 dark:text-zinc-100">{value.toLocaleString()}</p>
|
||||
<p className="text-[9px] uppercase tracking-wider text-zinc-400">{label}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Sync status + destination menu (spec §8 "export/sync status"; §12 E2) ──────
|
||||
const SYNC_DESTINATIONS = ['Meta Custom Audience', 'Google Customer Match', 'LinkedIn Matched Audiences'];
|
||||
|
||||
function SyncStatusChip({ audience }) {
|
||||
if (audience.syncStatus === 'synced') {
|
||||
const when = audience.lastSyncedAt
|
||||
? new Date(audience.lastSyncedAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
||||
: '';
|
||||
const short = (audience.syncDestination || '').split(' ')[0];
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-emerald-600 dark:text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded-full" title={`Synced to ${audience.syncDestination}`}>
|
||||
<CheckCircle2 size={11} /> Synced · {short}{when ? ` · ${when}` : ''}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-zinc-400 bg-zinc-100 dark:bg-white/5 px-2 py-0.5 rounded-full">
|
||||
<CircleDashed size={11} /> Not synced
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SyncMenu({ onSync }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<div className="relative">
|
||||
<button onClick={() => setOpen(o => !o)}
|
||||
className="inline-flex items-center justify-center gap-1 font-semibold rounded-xl transition-all focus:outline-none focus:ring-2 focus:ring-amber-500 px-2.5 py-1.5 text-xs bg-gradient-to-r from-amber-400 to-orange-500 text-white shadow-lg shadow-amber-500/25 hover:shadow-amber-500/40">
|
||||
<Upload size={14} /> Sync <ChevronDown size={13} />
|
||||
</button>
|
||||
{open && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
|
||||
<div className="absolute right-0 mt-1 w-52 rounded-xl border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 shadow-xl z-20 p-1">
|
||||
<p className="px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider text-zinc-400">Sync to platform</p>
|
||||
{SYNC_DESTINATIONS.map(d => (
|
||||
<button key={d} onClick={() => { onSync(d); setOpen(false); }}
|
||||
className="w-full text-left px-3 py-2 rounded-lg text-xs text-zinc-700 dark:text-zinc-200 hover:bg-amber-500/10 hover:text-amber-600 dark:hover:text-amber-400 transition-colors">
|
||||
{d}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── AI suggestion card (spec §6): reasons visible, one-click create, opt-out aware ──
|
||||
function SuggestionCard({ s, onCreate, onCustomize, onDismiss }) {
|
||||
const SIcon = s.icon;
|
||||
const reach = useMemo(() => previewSegment(s.filterDef), [s.filterDef]);
|
||||
return (
|
||||
<div className="rounded-xl border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 p-3 flex flex-col">
|
||||
<div className="flex items-start justify-between gap-2 mb-1.5">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="p-1.5 rounded-lg bg-purple-500/10 text-purple-500 shrink-0">{SIcon && <SIcon size={15} />}</span>
|
||||
<div className="min-w-0">
|
||||
<h4 className="font-bold text-sm text-zinc-900 dark:text-white truncate">{s.name}</h4>
|
||||
<span className="text-[10px] font-bold uppercase tracking-wide text-purple-500">AI · {Math.round(s.confidence * 100)}% match</span>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onDismiss} title="Dismiss" className="p-1 text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200 shrink-0"><X size={14} /></button>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-zinc-500 dark:text-zinc-400 mb-2">{s.rationale}</p>
|
||||
|
||||
{/* AI reasons visible (spec §12 E1) */}
|
||||
<ul className="space-y-1 mb-2">
|
||||
{s.reasons.map((r, i) => (
|
||||
<li key={i} className="text-[11px] text-zinc-600 dark:text-zinc-300 flex items-start gap-1.5">
|
||||
<span className="text-purple-400 mt-1.5 w-1 h-1 rounded-full bg-purple-400 shrink-0" /> {r}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-zinc-400 mb-3 mt-auto pt-2 border-t border-zinc-100 dark:border-white/5">
|
||||
<UserCheck size={12} className="text-emerald-500" />
|
||||
<span className="text-emerald-600 dark:text-emerald-400 font-mono font-semibold">{reach.eligible.toLocaleString()}</span> eligible after consent + suppression
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Btn size="sm" variant="primary" icon={Plus} onClick={onCreate}>Create</Btn>
|
||||
<Btn size="sm" variant="outline" icon={SlidersHorizontal} onClick={onCustomize}>Customize</Btn>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Segment builder modal: filter CRM contacts with a live, opt-out-aware count ──
|
||||
function SegmentBuilder({ audience, preset, onClose, onSave }) {
|
||||
const init = audience || preset; // edit an existing audience, or seed a new one from an AI suggestion
|
||||
const [name, setName] = useState(init?.name || '');
|
||||
const [segmentType, setSegmentType] = useState(init?.segmentType || 'crm_stage');
|
||||
const [f, setF] = useState(() => ({ ...DEFAULT_FILTER_DEF, ...(init?.filterDef || {}) }));
|
||||
|
||||
const set = (patch) => setF(prev => ({ ...prev, ...patch }));
|
||||
const toggleZip = (z) => set({ geo: f.geo.includes(z) ? f.geo.filter(x => x !== z) : [...f.geo, z] });
|
||||
|
||||
const preview = useMemo(() => previewSegment(f), [f]);
|
||||
const optOuts = preview.matched - preview.consented;
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[1000] flex items-center justify-center p-4" onClick={onClose}>
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-[1px]" />
|
||||
<div onClick={e => e.stopPropagation()} className="relative w-full max-w-2xl max-h-[90vh] overflow-y-auto rounded-2xl bg-white dark:bg-zinc-900 shadow-2xl border border-zinc-200 dark:border-white/10 animate-in zoom-in-95">
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 z-10 bg-white/95 dark:bg-zinc-900/95 backdrop-blur border-b border-zinc-200 dark:border-white/10 px-5 py-3 flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Target size={18} className="text-amber-500" />
|
||||
<h3 className="font-bold text-sm text-zinc-900 dark:text-white">{audience ? 'Edit segment' : 'New segment'}</h3>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1.5 rounded-lg text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200 hover:bg-zinc-100 dark:hover:bg-white/10"><X size={18} /></button>
|
||||
</div>
|
||||
|
||||
<div className="p-5 space-y-4">
|
||||
{/* Name + type */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<Field label="Segment name">
|
||||
<input value={name} onChange={e => setName(e.target.value)} placeholder="e.g. Plano storm — past 30 days"
|
||||
className={inCls} />
|
||||
</Field>
|
||||
<Field label="Segment type">
|
||||
<select value={segmentType} onChange={e => setSegmentType(e.target.value)} className={inCls}>
|
||||
{Object.entries(SEGMENT_LABEL).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{/* CRM filters (spec §12 E1) */}
|
||||
<div>
|
||||
<p className="text-[10px] font-bold uppercase tracking-wider text-zinc-400 mb-2">Filter CRM contacts</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<Field label="Lifecycle stage">
|
||||
<select value={f.stage} onChange={e => set({ stage: e.target.value })} className={inCls}>
|
||||
<option value="any">Any stage</option>
|
||||
{FILTER_OPTIONS.stages.map(s => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Source">
|
||||
<select value={f.source} onChange={e => set({ source: e.target.value })} className={inCls}>
|
||||
<option value="any">Any source</option>
|
||||
{FILTER_OPTIONS.sources.map(s => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Service">
|
||||
<select value={f.service} onChange={e => set({ service: e.target.value })} className={inCls}>
|
||||
<option value="any">Any service</option>
|
||||
{FILTER_OPTIONS.services.map(s => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Last job within">
|
||||
<select value={f.lastJobWithinDays || ''} onChange={e => set({ lastJobWithinDays: e.target.value ? Number(e.target.value) : null })} className={inCls}>
|
||||
{LAST_JOB_OPTIONS.map(([v, l]) => <option key={v} value={v}>{l}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Frequency cap (per week)">
|
||||
<input type="number" min={0} max={14} value={f.frequencyCap ?? ''} onChange={e => set({ frequencyCap: e.target.value ? Number(e.target.value) : null })} className={inCls} />
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Geography */}
|
||||
<Field label={<span className="flex items-center gap-1"><MapPin size={11} /> Geography (service-area ZIPs)</span>}>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{FILTER_OPTIONS.zips.map(z => (
|
||||
<button key={z} onClick={() => toggleZip(z)}
|
||||
className={`px-2.5 py-1 rounded-lg text-xs font-mono font-semibold transition-colors ${f.geo.includes(z) ? 'bg-amber-500 text-white' : 'bg-zinc-100 dark:bg-white/5 text-zinc-600 dark:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-white/10'}`}>
|
||||
{z}
|
||||
</button>
|
||||
))}
|
||||
{f.geo.length > 0 && <button onClick={() => set({ geo: [] })} className="px-2.5 py-1 rounded-lg text-xs text-zinc-400 hover:text-red-500">clear</button>}
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
{/* Mandatory consent + suppression (spec §6) */}
|
||||
<div className="rounded-xl border border-emerald-200 dark:border-emerald-500/20 bg-emerald-50/60 dark:bg-emerald-500/5 p-3 space-y-1.5">
|
||||
<p className="text-[11px] font-semibold text-emerald-700 dark:text-emerald-400 flex items-center gap-1.5"><Lock size={12} /> Always applied (mandatory)</p>
|
||||
<p className="text-[11px] text-zinc-600 dark:text-zinc-300 flex items-center gap-1.5"><ShieldCheck size={12} className="text-emerald-500" /> Consent filter — only opted-in contacts are reachable.</p>
|
||||
<p className="text-[11px] text-zinc-600 dark:text-zinc-300 flex items-center gap-1.5"><ShieldX size={12} className="text-red-500" /> Suppression list — opt-outs/DNC removed, including for retargeting.</p>
|
||||
<p className="text-[11px] text-zinc-500 flex items-start gap-1.5 pt-1 border-t border-emerald-200/50 dark:border-emerald-500/10"><ShieldQuestion size={12} className="text-zinc-400 mt-0.5 shrink-0" /> No sensitive-attribute targeting — only the CRM fields above can be used.</p>
|
||||
</div>
|
||||
|
||||
{/* Live preview (spec §8 / §12 E2: count respects opt-outs + suppressions) */}
|
||||
<div className="rounded-xl border border-zinc-200 dark:border-white/10 bg-zinc-50 dark:bg-white/[0.02] p-4">
|
||||
<p className="text-[10px] font-bold uppercase tracking-wider text-zinc-400 mb-3">Estimated reach (live)</p>
|
||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||
<FunnelStep label="Matched" value={preview.matched} />
|
||||
<FunnelArrow note={`− ${optOuts.toLocaleString()} no consent`} />
|
||||
<FunnelStep label="Consented" value={preview.consented} />
|
||||
<FunnelArrow note={`− ${preview.suppressed.toLocaleString()} suppressed`} />
|
||||
<FunnelStep label="Eligible" value={preview.eligible} highlight />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="sticky bottom-0 bg-white/95 dark:bg-zinc-900/95 backdrop-blur border-t border-zinc-200 dark:border-white/10 px-5 py-3 flex justify-end gap-2">
|
||||
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
|
||||
<Btn variant="primary" icon={Target} onClick={() => onSave({ name, segmentType, filterDef: f })}>
|
||||
{audience ? 'Save segment' : 'Create segment'} · {preview.eligible.toLocaleString()} eligible
|
||||
</Btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
const inCls = 'w-full rounded-lg border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 px-2.5 py-2 text-sm text-zinc-800 dark:text-zinc-100 focus:outline-none focus:ring-2 focus:ring-amber-500';
|
||||
|
||||
function Field({ label, children }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="text-[11px] font-semibold text-zinc-500 dark:text-zinc-400 mb-1 block">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function FunnelStep({ label, value, highlight }) {
|
||||
return (
|
||||
<div className="text-center">
|
||||
<p className={`text-xl font-mono font-bold ${highlight ? 'text-emerald-600 dark:text-emerald-400' : 'text-zinc-800 dark:text-zinc-100'}`}>{value.toLocaleString()}</p>
|
||||
<p className="text-[10px] uppercase tracking-wider text-zinc-400">{label}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FunnelArrow({ note }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center text-zinc-400 min-w-0">
|
||||
<span className="text-lg leading-none">→</span>
|
||||
<span className="text-[9px] text-red-400 whitespace-nowrap">{note}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,810 @@
|
||||
/**
|
||||
* CampaignBuilder — blueprint library + a guided builder that clones a recipe into a
|
||||
* campaign draft: objective, platform, service type, geography, budget, schedule,
|
||||
* offer, creative, lead form, playbook mapping, and approval workflow (spec §8).
|
||||
* Honors platform capability flags: where full-publish is unavailable, it offers a
|
||||
* manual checklist. An AI Campaign Brief Generator (spec §6) drafts a whole campaign
|
||||
* from a natural-language prompt and hands it to the builder for human approval.
|
||||
*/
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Megaphone, Plus, X, Check, ChevronRight, MapPin, DollarSign,
|
||||
Calendar, Target, Workflow, FileCheck2, ListChecks, ExternalLink,
|
||||
Sparkles, Wand2, ClipboardList, ShieldCheck, AlertTriangle, ArrowRight, Image,
|
||||
Gauge, Bell, ShieldAlert, Power, ArrowLeft, Copy, CheckCircle2, Circle, Rocket, Link2,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { useSocialAds } from '../SocialAdsContext.jsx';
|
||||
import { BLUEPRINTS, BLUEPRINT_BY_ID } from '../data/blueprints.js';
|
||||
import { PLATFORMS, getPlatform, hasCapability, CAPABILITY } from '../data/platforms.js';
|
||||
import { generateVariants } from '../ai/creativeGenerator.js';
|
||||
import { generateBrief, BRIEF_EXAMPLES } from '../ai/briefGenerator.js';
|
||||
import { buildChecklist, checklistToText } from '../data/publishChecklist.js';
|
||||
import { buildUtm } from '../data/utm.js';
|
||||
import { Card, StatusChip, SectionHeader, Btn, PlatformBadge } from '../components/ui.jsx';
|
||||
|
||||
const STEPS = ['Blueprint', 'Targeting', 'Budget', 'Creative', 'Lead form', 'Mapping', 'Review'];
|
||||
|
||||
// Build the exact public landing-page URL a click on this campaign's ad would open:
|
||||
// blueprint drives the headline/offer (?c), platform sets the "Sponsored" source (?src).
|
||||
const publicFormUrl = (blueprintId, platform) =>
|
||||
`/ads/lp?c=${blueprintId}&src=${platform}`;
|
||||
|
||||
const POLICY_META = {
|
||||
clear: { color: 'text-emerald-500', label: 'Policy clear' },
|
||||
review: { color: 'text-amber-500', label: 'Needs review' },
|
||||
blocked: { color: 'text-red-500', label: 'Blocked' },
|
||||
};
|
||||
|
||||
export default function CampaignBuilder() {
|
||||
const { campaigns, createCampaign, publishCampaign, setCampaignStatus, addCreative, completeManualSetup } = useSocialAds();
|
||||
const [mode, setMode] = useState('list'); // 'list' | 'building' | 'briefing'
|
||||
const [seedDraft, setSeedDraft] = useState(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const close = () => { setMode('list'); setSeedDraft(null); };
|
||||
|
||||
// Brief → builder: persist the AI-drafted creative, then seed the wizard with the brief.
|
||||
const openFromBrief = (brief) => {
|
||||
const creative = addCreative({
|
||||
platform: brief.platform,
|
||||
serviceType: brief.serviceType,
|
||||
headline: brief.adCopy.headline,
|
||||
body: brief.adCopy.body,
|
||||
description: brief.adCopy.description,
|
||||
aiGenerated: true,
|
||||
policyStatus: brief.adCopy.policy.status,
|
||||
policyFlags: brief.adCopy.policy.flags.map(f => f.message),
|
||||
});
|
||||
setSeedDraft({
|
||||
blueprintId: brief.blueprintId,
|
||||
name: brief.prompt.slice(0, 60) || `${brief.blueprintName} — New`,
|
||||
platform: brief.platform,
|
||||
objective: brief.objective,
|
||||
serviceType: brief.serviceType,
|
||||
serviceArea: brief.geo || '',
|
||||
offer: brief.offer,
|
||||
playbook: brief.nurturePath,
|
||||
creativeId: creative.id,
|
||||
});
|
||||
setMode('building');
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SectionHeader icon={Megaphone} title="Campaign Builder" description="Clone a roofing blueprint into a campaign draft, attach creative and a lead form, map it to a nurture playbook, and route it through approval.">
|
||||
{mode === 'list' && (
|
||||
<>
|
||||
<Btn variant="outline" icon={Wand2} onClick={() => setMode('briefing')}>AI brief</Btn>
|
||||
<Btn icon={Plus} onClick={() => { setSeedDraft(null); setMode('building'); }}>New campaign</Btn>
|
||||
</>
|
||||
)}
|
||||
</SectionHeader>
|
||||
|
||||
{mode === 'briefing' && <BriefPanel onClose={close} onUse={openFromBrief} />}
|
||||
{mode === 'building' && <BuilderWizard onClose={close} onCreate={createCampaign} initialDraft={seedDraft} />}
|
||||
{mode === 'list' && <CampaignList campaigns={campaigns} onPublish={publishCampaign} onStatus={setCampaignStatus} onManualComplete={completeManualSetup} />}
|
||||
|
||||
{mode === 'list' && (
|
||||
<>
|
||||
<h3 className="text-sm font-bold text-zinc-700 dark:text-zinc-300 mt-8 mb-3 flex items-center gap-2">
|
||||
<Target size={16} className="text-amber-500" /> Blueprint library
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
|
||||
{BLUEPRINTS.map(bp => (
|
||||
<Card key={bp.id} className="!h-auto" pad="p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="font-bold text-sm text-zinc-900 dark:text-white">{bp.name}</h4>
|
||||
<span className={`text-[10px] font-bold uppercase tracking-wide text-${bp.accent}-500`}>{bp.objective.replace(/_/g, ' ')}</span>
|
||||
</div>
|
||||
<p className="text-xs text-zinc-500 dark:text-zinc-400 mb-3 line-clamp-2">{bp.adCopy}</p>
|
||||
<div className="flex flex-wrap items-center gap-1.5 mb-2">
|
||||
{bp.bestChannels.map(c => <PlatformBadge key={c} platformId={c} size="sm" showName={false} />)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-[11px] text-zinc-400 flex items-center gap-1 min-w-0 truncate"><Workflow size={11} className="shrink-0" /> {bp.nurture}</p>
|
||||
<button
|
||||
onClick={() => navigate(publicFormUrl(bp.id, bp.bestChannels[0]))}
|
||||
title="Preview the public lead-capture page for this blueprint"
|
||||
className="shrink-0 inline-flex items-center gap-1 text-[11px] font-semibold text-amber-600 dark:text-amber-400 hover:underline">
|
||||
<ExternalLink size={11} /> Preview form
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── AI Campaign Brief Generator (spec §6) ──────────────────────────────────────
|
||||
function BriefPanel({ onClose, onUse }) {
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [brief, setBrief] = useState(null);
|
||||
const [thinking, setThinking] = useState(false);
|
||||
|
||||
const run = (text) => {
|
||||
const p = (text ?? prompt).trim();
|
||||
if (!p) return;
|
||||
if (text != null) setPrompt(text);
|
||||
setThinking(true);
|
||||
setBrief(null);
|
||||
setTimeout(() => { setBrief(generateBrief(p)); setThinking(false); }, 700);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="!h-auto">
|
||||
<div className="flex items-start justify-between gap-3 mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-2 rounded-xl bg-purple-500/10 text-purple-500"><Wand2 size={18} /></div>
|
||||
<div>
|
||||
<h3 className="font-bold text-zinc-900 dark:text-white">AI Campaign Brief</h3>
|
||||
<p className="text-[11px] text-zinc-500">Describe the campaign in plain language — AI drafts it from approved blueprints. You approve before anything is created.</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1 rounded-lg text-zinc-400 hover:bg-zinc-100 dark:hover:bg-white/10"><X size={18} /></button>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
value={prompt}
|
||||
onChange={e => setPrompt(e.target.value)}
|
||||
rows={2}
|
||||
placeholder='e.g. "storm inspection campaign for hail-hit ZIPs in Plano"'
|
||||
className="w-full rounded-xl border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 px-3.5 py-2.5 text-base sm:text-sm text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-purple-500 resize-none"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1.5 mt-2">
|
||||
{BRIEF_EXAMPLES.map(ex => (
|
||||
<button key={ex} onClick={() => run(ex)}
|
||||
className="text-[11px] px-2 py-1 rounded-lg bg-zinc-100 dark:bg-white/5 text-zinc-600 dark:text-zinc-300 hover:bg-purple-500/10 hover:text-purple-600 dark:hover:text-purple-400 transition-colors">
|
||||
{ex}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end mt-3">
|
||||
<Btn icon={Sparkles} onClick={() => run()} disabled={thinking || !prompt.trim()}>
|
||||
{thinking ? 'Drafting…' : 'Generate brief'}
|
||||
</Btn>
|
||||
</div>
|
||||
|
||||
{brief && <BriefSummary brief={brief} onUse={onUse} onRegenerate={() => run()} />}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function BriefSummary({ brief, onUse, onRegenerate }) {
|
||||
const policy = POLICY_META[brief.adCopy.policy.status] || POLICY_META.review;
|
||||
return (
|
||||
<div className="mt-4 pt-4 border-t border-zinc-100 dark:border-white/5 space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wide text-zinc-400">Drafted brief</span>
|
||||
<StatusChip status="draft" label={brief.blueprintName} />
|
||||
<PlatformBadge platformId={brief.platform} size="sm" />
|
||||
<span className="text-[11px] font-semibold text-zinc-500">{brief.objectiveLabel}</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<BriefBlock icon={Target} title="Audience notes">{brief.audienceNotes}</BriefBlock>
|
||||
<BriefBlock icon={Megaphone} title="Call to action">{brief.cta} · <span className="text-zinc-400">{brief.offer}</span></BriefBlock>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-zinc-200 dark:border-white/10 p-3">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wide text-zinc-400 flex items-center gap-1"><Sparkles size={11} className="text-purple-500" /> Ad copy draft</span>
|
||||
<span className={`text-[10px] font-bold flex items-center gap-1 ${policy.color}`}><ShieldCheck size={11} /> {policy.label}</span>
|
||||
</div>
|
||||
<p className="font-bold text-sm text-zinc-900 dark:text-white">{brief.adCopy.headline}</p>
|
||||
<p className="text-xs text-zinc-600 dark:text-zinc-300 mt-0.5">{brief.adCopy.body}</p>
|
||||
{brief.adCopy.policy.flags.length > 0 && (
|
||||
<ul className="mt-1.5 space-y-0.5">
|
||||
{brief.adCopy.policy.flags.map(f => (
|
||||
<li key={f.id} className="text-[10px] text-amber-600 dark:text-amber-400/80 flex items-start gap-1"><AlertTriangle size={10} className="mt-0.5 shrink-0" /> {f.message}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<BriefBlock icon={ClipboardList} title="Suggested form fields">
|
||||
<div className="flex flex-wrap gap-1.5 mt-1">
|
||||
{brief.formFields.map(q => (
|
||||
<span key={q} className="text-[11px] px-2 py-0.5 rounded-full bg-zinc-100 dark:bg-white/10 text-zinc-600 dark:text-zinc-300">{q}</span>
|
||||
))}
|
||||
</div>
|
||||
</BriefBlock>
|
||||
<BriefBlock icon={Workflow} title="Nurture path">{brief.nurturePath}</BriefBlock>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-end gap-2 pt-1">
|
||||
<Btn variant="ghost" icon={Sparkles} onClick={onRegenerate}>Regenerate</Btn>
|
||||
<Btn icon={ArrowRight} onClick={() => onUse(brief)}>Open in builder</Btn>
|
||||
</div>
|
||||
<p className="text-[11px] text-zinc-400 flex items-center gap-1">
|
||||
<ShieldCheck size={12} /> AI only uses tenant-approved blueprints and offers. The draft requires your approval before it can publish.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BriefBlock({ icon: Icon, title, children }) {
|
||||
return (
|
||||
<div className="rounded-xl bg-zinc-50 dark:bg-white/5 p-3">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wide text-zinc-400 flex items-center gap-1 mb-1">{Icon && <Icon size={11} className="text-amber-500" />} {title}</span>
|
||||
<div className="text-xs text-zinc-600 dark:text-zinc-300">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const STATUS_FILTERS = [
|
||||
['all', 'All'],
|
||||
['active', 'Active'],
|
||||
['pending', 'Pending approval'],
|
||||
['draft', 'Drafts'],
|
||||
['paused', 'Paused'],
|
||||
];
|
||||
|
||||
function CampaignList({ campaigns, onPublish, onStatus, onManualComplete }) {
|
||||
const navigate = useNavigate();
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
const [checklistId, setChecklistId] = useState(null);
|
||||
|
||||
const checklistCampaign = campaigns.find(c => c.id === checklistId);
|
||||
if (checklistCampaign) {
|
||||
return (
|
||||
<ManualPublishChecklist
|
||||
campaign={checklistCampaign}
|
||||
onClose={() => setChecklistId(null)}
|
||||
onComplete={() => { onManualComplete(checklistCampaign.id); setChecklistId(null); }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const matches = (c) => {
|
||||
if (statusFilter === 'all') return true;
|
||||
if (statusFilter === 'pending') return c.status === 'draft' && c.approvalStatus === 'pending';
|
||||
if (statusFilter === 'draft') return c.status === 'draft' && c.approvalStatus !== 'pending';
|
||||
return c.status === statusFilter;
|
||||
};
|
||||
const counts = Object.fromEntries(STATUS_FILTERS.map(([id]) => [
|
||||
id,
|
||||
campaigns.filter(c => {
|
||||
if (id === 'all') return true;
|
||||
if (id === 'pending') return c.status === 'draft' && c.approvalStatus === 'pending';
|
||||
if (id === 'draft') return c.status === 'draft' && c.approvalStatus !== 'pending';
|
||||
return c.status === id;
|
||||
}).length,
|
||||
]));
|
||||
const visible = campaigns.filter(matches);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* Status filter */}
|
||||
<div className="flex flex-wrap items-center gap-2 mb-3">
|
||||
{STATUS_FILTERS.map(([id, label]) => (
|
||||
<button key={id} onClick={() => setStatusFilter(id)}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-semibold transition-colors ${statusFilter === id ? 'bg-amber-500 text-white' : 'bg-zinc-100 dark:bg-white/5 text-zinc-600 dark:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-white/10'}`}>
|
||||
{label} <span className="opacity-60">{counts[id]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{visible.length === 0 && <p className="text-sm text-zinc-500 text-center py-8">No campaigns match this filter.</p>}
|
||||
{visible.map(c => {
|
||||
const canPublish = hasCapability(c.platform, CAPABILITY.FULL_PUBLISH);
|
||||
return (
|
||||
<Card key={c.id} className="!h-auto" pad="p-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<PlatformBadge platformId={c.platform} showName={false} />
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h4 className="font-bold text-sm text-zinc-900 dark:text-white truncate">{c.name}</h4>
|
||||
<StatusChip status={c.status} />
|
||||
<StatusChip status={c.approvalStatus} label={`${c.approvalStatus} approval`} />
|
||||
</div>
|
||||
<p className="text-[11px] text-zinc-500 dark:text-zinc-400 mt-0.5">
|
||||
{c.serviceArea} · ${c.budgetDaily}/day · {c.startAt} → {c.endAt} · {c.playbook}
|
||||
</p>
|
||||
{c.guardrails && (
|
||||
<p className="text-[11px] text-zinc-400 mt-0.5 flex items-center gap-1">
|
||||
<Gauge size={11} className="text-amber-500 shrink-0" />
|
||||
Caps ${c.guardrails.weeklyCap.toLocaleString()}/wk · ${c.guardrails.monthlyCap.toLocaleString()}/mo · CPL alert > ${c.guardrails.cplThreshold}
|
||||
{(c.guardrails.pauseOnDailyCap || c.guardrails.pauseOnCplBreach || c.guardrails.anomalyDetection) && ' · auto-pause on'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 shrink-0">
|
||||
<Btn size="sm" variant="ghost" icon={ExternalLink}
|
||||
title="Open the public lead-capture page this campaign's ad links to"
|
||||
onClick={() => navigate(publicFormUrl(c.blueprintId, c.platform))}>
|
||||
Preview form
|
||||
</Btn>
|
||||
<Btn size="sm" variant="ghost" icon={Link2}
|
||||
title="Copy this campaign's tracked UTM link"
|
||||
onClick={() => copyToClipboard(buildUtm(c).url, 'Tracked link copied')}>
|
||||
Copy link
|
||||
</Btn>
|
||||
{c.status === 'active'
|
||||
? <Btn size="sm" variant="outline" onClick={() => onStatus(c.id, 'paused')}>Pause</Btn>
|
||||
: c.status === 'paused'
|
||||
? <Btn size="sm" variant="success" onClick={() => onStatus(c.id, 'active')}>Resume</Btn>
|
||||
: canPublish
|
||||
? <Btn size="sm" variant="primary" icon={FileCheck2} onClick={() => onPublish(c.id)}>Publish</Btn>
|
||||
: <Btn size="sm" variant="outline" icon={ListChecks} onClick={() => setChecklistId(c.id)}>Manual setup</Btn>}
|
||||
</div>
|
||||
</div>
|
||||
{c.status === 'draft' && !canPublish && (
|
||||
<p className="text-[11px] text-amber-600 dark:text-amber-400/80 mt-2 flex items-center gap-1">
|
||||
<ListChecks size={12} /> {getPlatform(c.platform)?.short} has no full-publish API — generate a platform-ready setup checklist instead.
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Manual-publish setup checklist (spec §5/§11) ───────────────────────────────
|
||||
function ManualPublishChecklist({ campaign, onClose, onComplete }) {
|
||||
const { creatives, leadForms } = useSocialAds();
|
||||
const creative = creatives.find(c => c.id === campaign.creativeId);
|
||||
const form = leadForms.find(f => f.id === campaign.formId);
|
||||
const checklist = useMemo(() => buildChecklist(campaign, { creative, form }), [campaign, creative, form]);
|
||||
const [done, setDone] = useState({});
|
||||
|
||||
const completedCount = checklist.steps.filter((_, i) => done[i]).length;
|
||||
const allDone = completedCount === checklist.steps.length;
|
||||
const approved = campaign.approvalStatus === 'approved';
|
||||
|
||||
const copyInstructions = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(checklistToText(campaign, checklist));
|
||||
toast.success('Setup instructions copied to clipboard');
|
||||
} catch {
|
||||
toast.error('Could not copy — select and copy manually');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="!h-auto">
|
||||
<div className="flex items-start justify-between gap-3 mb-4">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Btn size="sm" variant="ghost" icon={ArrowLeft} onClick={onClose}>Back</Btn>
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-bold text-zinc-900 dark:text-white truncate flex items-center gap-2">
|
||||
<ListChecks size={18} className="text-amber-500 shrink-0" /> Manual setup · {campaign.name}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<PlatformBadge platformId={campaign.platform} size="sm" />
|
||||
<span className="text-[11px] text-zinc-400">No full-publish API — set up by hand, then mark live.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Btn size="sm" variant="outline" icon={Copy} onClick={copyInstructions}>Copy</Btn>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/20 p-3 mb-4">
|
||||
<p className="text-xs text-amber-700 dark:text-amber-300/90 flex items-start gap-1.5">
|
||||
<AlertTriangle size={13} className="mt-0.5 shrink-0" /> {checklist.tip}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{checklist.steps.map((s, i) => {
|
||||
const checked = !!done[i];
|
||||
return (
|
||||
<button key={i} type="button" onClick={() => setDone(d => ({ ...d, [i]: !d[i] }))}
|
||||
className={`w-full text-left flex items-start gap-3 p-3 rounded-xl border transition-colors ${checked ? 'border-emerald-300 dark:border-emerald-500/30 bg-emerald-50 dark:bg-emerald-500/10' : 'border-zinc-200 dark:border-white/10 hover:bg-zinc-50 dark:hover:bg-white/5'}`}>
|
||||
{checked
|
||||
? <CheckCircle2 size={18} className="text-emerald-500 shrink-0 mt-0.5" />
|
||||
: <Circle size={18} className="text-zinc-300 dark:text-zinc-600 shrink-0 mt-0.5" />}
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-zinc-800 dark:text-zinc-100">{i + 1}. {s.title}</p>
|
||||
<p className="text-xs text-zinc-500 dark:text-zinc-400 mt-0.5 whitespace-pre-line">{s.detail}</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mt-5 pt-4 border-t border-zinc-100 dark:border-white/5">
|
||||
<span className="text-xs text-zinc-500">{completedCount}/{checklist.steps.length} steps done{!approved && ' · approval required to go live'}</span>
|
||||
<Btn icon={Rocket} disabled={!approved} title={approved ? 'Mark the campaign live' : 'Approve in Compliance first'} onClick={onComplete}>
|
||||
{allDone ? 'Mark setup complete & go live' : 'Mark setup complete'}
|
||||
</Btn>
|
||||
</div>
|
||||
{!approved && (
|
||||
<p className="text-[11px] text-amber-600 dark:text-amber-400/80 mt-2 flex items-center gap-1">
|
||||
<ShieldAlert size={12} /> This campaign is awaiting approval — approve it in the Compliance Center before marking setup complete.
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function BuilderWizard({ onClose, onCreate, initialDraft }) {
|
||||
const { creatives, leadForms, addCreative } = useSocialAds();
|
||||
const [step, setStep] = useState(0);
|
||||
const [draft, setDraft] = useState(() => ({
|
||||
blueprintId: '', name: '', platform: '', objective: '', serviceType: '',
|
||||
serviceArea: '', budgetDaily: 75, budgetTotal: 2250,
|
||||
startAt: '2026-06-15', endAt: '2026-07-15', playbook: '',
|
||||
creativeId: '', formId: '',
|
||||
// Spend guardrails — hard caps + alert thresholds set before activation (spec §3/§4).
|
||||
guardrails: {
|
||||
weeklyCap: 525, monthlyCap: 2250, cplThreshold: 120, alertPct: 80,
|
||||
pauseOnDailyCap: true, pauseOnCplBreach: false, anomalyDetection: true,
|
||||
},
|
||||
...(initialDraft || {}),
|
||||
}));
|
||||
const [variants, setVariants] = useState([]);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
|
||||
const set = (patch) => setDraft(d => ({ ...d, ...patch }));
|
||||
const setG = (patch) => setDraft(d => ({ ...d, guardrails: { ...d.guardrails, ...patch } }));
|
||||
const bp = BLUEPRINT_BY_ID[draft.blueprintId];
|
||||
const g = draft.guardrails;
|
||||
|
||||
const pickBlueprint = (b) => set({
|
||||
blueprintId: b.id, objective: b.objective, serviceType: b.serviceType,
|
||||
platform: b.bestChannels[0], playbook: b.nurture, offer: b.offer,
|
||||
name: draft.name || `${b.name} — New`,
|
||||
creativeId: '', formId: '',
|
||||
});
|
||||
|
||||
const creativesForPlatform = creatives.filter(c => c.platform === draft.platform);
|
||||
const formsForPlatform = leadForms.filter(f => f.platform === draft.platform);
|
||||
const selectedCreative = creatives.find(c => c.id === draft.creativeId);
|
||||
const selectedForm = leadForms.find(f => f.id === draft.formId);
|
||||
|
||||
const genVariants = () => {
|
||||
setGenerating(true);
|
||||
setTimeout(() => {
|
||||
setVariants(generateVariants({ platformId: draft.platform, serviceType: draft.serviceType || 'storm', city: draft.serviceArea || 'your area', count: 3 }));
|
||||
setGenerating(false);
|
||||
}, 600);
|
||||
};
|
||||
|
||||
const applyVariant = (v) => {
|
||||
const c = addCreative({
|
||||
platform: draft.platform, serviceType: draft.serviceType,
|
||||
headline: v.headline, body: v.body, description: v.description,
|
||||
aiGenerated: true, policyStatus: v.policy.status,
|
||||
policyFlags: v.policy.flags.map(f => f.message),
|
||||
});
|
||||
set({ creativeId: c.id });
|
||||
setVariants([]);
|
||||
};
|
||||
|
||||
const canNext = [
|
||||
() => !!draft.blueprintId,
|
||||
() => !!draft.platform && !!draft.serviceArea,
|
||||
() => draft.budgetDaily > 0 && !!draft.startAt && !!draft.endAt,
|
||||
() => !!draft.creativeId,
|
||||
() => true, // lead form is optional (some blueprints reuse a CRM audience)
|
||||
() => !!draft.playbook,
|
||||
() => true,
|
||||
][step]();
|
||||
|
||||
const finish = () => { onCreate(draft); onClose(); };
|
||||
|
||||
return (
|
||||
<Card className="!h-auto">
|
||||
{/* Stepper */}
|
||||
<div className="flex items-center justify-between mb-5 gap-1">
|
||||
{STEPS.map((s, i) => (
|
||||
<React.Fragment key={s}>
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span className={`flex items-center justify-center w-6 h-6 rounded-full text-[11px] font-bold shrink-0 ${i < step ? 'bg-emerald-500 text-white' : i === step ? 'bg-amber-500 text-white' : 'bg-zinc-200 dark:bg-zinc-700 text-zinc-500'}`}>
|
||||
{i < step ? <Check size={13} /> : i + 1}
|
||||
</span>
|
||||
<span className={`text-[11px] font-semibold truncate hidden md:block ${i === step ? 'text-zinc-900 dark:text-white' : 'text-zinc-400'}`}>{s}</span>
|
||||
</div>
|
||||
{i < STEPS.length - 1 && <div className="flex-1 h-px bg-zinc-200 dark:bg-zinc-700" />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
<button onClick={onClose} className="ml-2 p-1 rounded-lg text-zinc-400 hover:bg-zinc-100 dark:hover:bg-white/10"><X size={18} /></button>
|
||||
</div>
|
||||
|
||||
{/* Step body */}
|
||||
<div className="min-h-[220px]">
|
||||
{step === 0 && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{BLUEPRINTS.map(b => (
|
||||
<button key={b.id} onClick={() => pickBlueprint(b)}
|
||||
className={`text-left p-3 rounded-xl border-2 transition-all ${draft.blueprintId === b.id ? 'border-amber-500 bg-amber-500/5' : 'border-zinc-200 dark:border-white/10 hover:border-amber-300'}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-bold text-sm text-zinc-900 dark:text-white">{b.name}</span>
|
||||
{draft.blueprintId === b.id && <Check size={16} className="text-amber-500" />}
|
||||
</div>
|
||||
<p className="text-[11px] text-zinc-500 mt-1 line-clamp-2">{b.adCopy}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 1 && (
|
||||
<div className="space-y-4">
|
||||
<Field label="Campaign name">
|
||||
<input value={draft.name} onChange={e => set({ name: e.target.value })} className={inputCls} placeholder="e.g. Plano Hail Response — July" />
|
||||
</Field>
|
||||
<Field label="Objective">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{['lead_generation', 'retargeting', 'engagement', 'reactivation'].map(o => (
|
||||
<button key={o} onClick={() => set({ objective: o })}
|
||||
className={`px-3 py-1.5 rounded-lg border-2 text-xs font-semibold capitalize transition-all ${draft.objective === o ? 'border-amber-500 bg-amber-500/5' : 'border-zinc-200 dark:border-white/10'}`}>
|
||||
{o.replace(/_/g, ' ')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="Platform" hint={bp ? `Recommended: ${bp.bestChannels.map(c => getPlatform(c).short).join(', ')}` : ''}>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{PLATFORMS.map(p => (
|
||||
<button key={p.id} onClick={() => set({ platform: p.id, creativeId: '', formId: '' })}
|
||||
className={`px-3 py-1.5 rounded-lg border-2 text-xs font-semibold transition-all ${draft.platform === p.id ? 'border-amber-500 bg-amber-500/5' : 'border-zinc-200 dark:border-white/10'}`}>
|
||||
{p.short}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="Service area / geography" icon={MapPin}>
|
||||
<input value={draft.serviceArea} onChange={e => set({ serviceArea: e.target.value })} className={inputCls} placeholder="ZIP codes, city, or radius" />
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Field label="Daily budget" icon={DollarSign}>
|
||||
<input type="number" value={draft.budgetDaily}
|
||||
onChange={e => {
|
||||
const v = +e.target.value;
|
||||
setDraft(d => ({ ...d, budgetDaily: v, budgetTotal: v * 30, guardrails: { ...d.guardrails, weeklyCap: v * 7, monthlyCap: v * 30 } }));
|
||||
}}
|
||||
className={inputCls} />
|
||||
</Field>
|
||||
<Field label="Total budget (≈30d)" icon={DollarSign}>
|
||||
<input type="number" value={draft.budgetTotal} onChange={e => set({ budgetTotal: +e.target.value })} className={inputCls} />
|
||||
</Field>
|
||||
<Field label="Start date" icon={Calendar}>
|
||||
<input type="date" value={draft.startAt} onChange={e => set({ startAt: e.target.value })} className={inputCls} />
|
||||
</Field>
|
||||
<Field label="End date" icon={Calendar}>
|
||||
<input type="date" value={draft.endAt} onChange={e => set({ endAt: e.target.value })} className={inputCls} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{/* Spend guardrails (spec §3/§4) — hard caps + alert thresholds before activation */}
|
||||
<div className="pt-4 border-t border-zinc-100 dark:border-white/5">
|
||||
<div className="flex items-center gap-1.5 mb-3">
|
||||
<ShieldAlert size={14} className="text-amber-500" />
|
||||
<h4 className="text-xs font-bold text-zinc-700 dark:text-zinc-300">Spend guardrails</h4>
|
||||
<span className="text-[11px] text-zinc-400">— hard caps & alerts enforced before activation</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<Field label="Weekly cap" icon={Gauge}>
|
||||
<input type="number" value={g.weeklyCap} onChange={e => setG({ weeklyCap: +e.target.value })} className={inputCls} />
|
||||
</Field>
|
||||
<Field label="Monthly cap" icon={Gauge}>
|
||||
<input type="number" value={g.monthlyCap} onChange={e => setG({ monthlyCap: +e.target.value })} className={inputCls} />
|
||||
</Field>
|
||||
<Field label="CPL threshold" icon={DollarSign}>
|
||||
<input type="number" value={g.cplThreshold} onChange={e => setG({ cplThreshold: +e.target.value })} className={inputCls} />
|
||||
</Field>
|
||||
<Field label="Alert at (% of cap)" icon={Bell}>
|
||||
<input type="number" min="1" max="100" value={g.alertPct} onChange={e => setG({ alertPct: +e.target.value })} className={inputCls} />
|
||||
</Field>
|
||||
</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
<ToggleRow label="Auto-pause when daily cap is reached" on={g.pauseOnDailyCap} onToggle={() => setG({ pauseOnDailyCap: !g.pauseOnDailyCap })} />
|
||||
<ToggleRow label={`Auto-pause when cost-per-lead exceeds $${g.cplThreshold}`} on={g.pauseOnCplBreach} onToggle={() => setG({ pauseOnCplBreach: !g.pauseOnCplBreach })} />
|
||||
<ToggleRow label="Anomaly detection — pause on a sudden spend spike" on={g.anomalyDetection} onToggle={() => setG({ anomalyDetection: !g.anomalyDetection })} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-xs text-zinc-500 flex items-center gap-1">
|
||||
<Image size={13} className="text-amber-500" /> Attach an approved creative for {getPlatform(draft.platform)?.short || 'this platform'} — AI drafts are approval-gated.
|
||||
</p>
|
||||
<Btn size="sm" variant="outline" icon={Sparkles} onClick={genVariants} disabled={generating}>
|
||||
{generating ? 'Drafting…' : 'Generate with AI'}
|
||||
</Btn>
|
||||
</div>
|
||||
|
||||
{variants.length > 0 && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
{variants.map((v, i) => {
|
||||
const pm = POLICY_META[v.policy.status] || POLICY_META.review;
|
||||
return (
|
||||
<div key={i} className="rounded-xl border border-zinc-200 dark:border-white/10 p-3 flex flex-col">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wide text-zinc-400">Variant {i + 1}</span>
|
||||
<span className={`text-[10px] font-bold ${pm.color}`}>{pm.label}</span>
|
||||
</div>
|
||||
<p className="font-bold text-sm text-zinc-900 dark:text-white">{v.headline}</p>
|
||||
<p className="text-xs text-zinc-500 mt-1 flex-1">{v.body}</p>
|
||||
<Btn size="sm" variant="outline" icon={Plus} className="mt-2" disabled={v.policy.status === 'blocked'} onClick={() => applyVariant(v)}>Use this</Btn>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{creativesForPlatform.length === 0 && variants.length === 0 && (
|
||||
<p className="text-xs text-zinc-400 sm:col-span-2 py-4 text-center">No creatives yet for this platform — generate one with AI above.</p>
|
||||
)}
|
||||
{creativesForPlatform.map(c => {
|
||||
const pm = POLICY_META[c.policyStatus] || POLICY_META.review;
|
||||
const selected = draft.creativeId === c.id;
|
||||
return (
|
||||
<button key={c.id} type="button" onClick={() => set({ creativeId: c.id })}
|
||||
className={`text-left p-3 rounded-xl border-2 transition-all ${selected ? 'border-amber-500 bg-amber-500/5' : 'border-zinc-200 dark:border-white/10 hover:border-amber-300'}`}>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-bold">
|
||||
{c.aiGenerated && <Sparkles size={10} className="text-purple-500" />}
|
||||
<span className={pm.color}>{pm.label}</span>
|
||||
</span>
|
||||
{selected && <Check size={15} className="text-amber-500" />}
|
||||
</div>
|
||||
<p className="font-bold text-sm text-zinc-900 dark:text-white">{c.headline}</p>
|
||||
<p className="text-xs text-zinc-500 mt-0.5 line-clamp-2">{c.body}</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 4 && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs text-zinc-500 flex items-center gap-1">
|
||||
<ClipboardList size={13} className="text-amber-500" /> Choose the lead form this campaign sends to. Optional — leads can use the default capture form.
|
||||
</p>
|
||||
|
||||
{bp?.formQuestions && (
|
||||
<div className="rounded-xl bg-zinc-50 dark:bg-white/5 p-3">
|
||||
<p className="text-[10px] font-bold uppercase tracking-wide text-zinc-400 mb-2">Recommended questions · {bp.name}</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{bp.formQuestions.map(q => (
|
||||
<span key={q} className="text-[11px] px-2 py-0.5 rounded-full bg-white dark:bg-white/10 border border-zinc-200 dark:border-white/10 text-zinc-600 dark:text-zinc-300">{q}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<button type="button" onClick={() => set({ formId: '' })}
|
||||
className={`text-left p-3 rounded-xl border-2 transition-all ${!draft.formId ? 'border-amber-500 bg-amber-500/5' : 'border-zinc-200 dark:border-white/10 hover:border-amber-300'}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-bold text-sm text-zinc-900 dark:text-white">Default capture form</span>
|
||||
{!draft.formId && <Check size={15} className="text-amber-500" />}
|
||||
</div>
|
||||
<p className="text-[11px] text-zinc-400 mt-0.5">Public landing-page form with consent capture</p>
|
||||
</button>
|
||||
{formsForPlatform.map(f => {
|
||||
const selected = draft.formId === f.id;
|
||||
return (
|
||||
<button key={f.id} type="button" onClick={() => set({ formId: f.id })}
|
||||
className={`text-left p-3 rounded-xl border-2 transition-all ${selected ? 'border-amber-500 bg-amber-500/5' : 'border-zinc-200 dark:border-white/10 hover:border-amber-300'}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-bold text-sm text-zinc-900 dark:text-white truncate">{f.title}</span>
|
||||
{selected && <Check size={15} className="text-amber-500 shrink-0" />}
|
||||
</div>
|
||||
<p className="text-[11px] text-zinc-400 mt-0.5">{f.fields.length} fields mapped · {f.status}</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 5 && (
|
||||
<div className="space-y-4">
|
||||
<Field label="Offer">
|
||||
<input value={draft.offer || ''} onChange={e => set({ offer: e.target.value })} className={inputCls} />
|
||||
</Field>
|
||||
<Field label="Mapped nurture playbook" icon={Workflow}>
|
||||
<input value={draft.playbook} onChange={e => set({ playbook: e.target.value })} className={inputCls} />
|
||||
</Field>
|
||||
<p className="text-[11px] text-zinc-500 flex items-center gap-1">
|
||||
<Workflow size={12} /> New leads from this campaign will auto-start the mapped playbook within the speed-to-lead SLA.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 6 && (
|
||||
<div className="space-y-2 text-sm">
|
||||
{[
|
||||
['Blueprint', bp?.name], ['Campaign', draft.name],
|
||||
['Platform', getPlatform(draft.platform)?.name], ['Objective', draft.objective?.replace(/_/g, ' ')],
|
||||
['Service area', draft.serviceArea], ['Budget', `$${draft.budgetDaily}/day · $${draft.budgetTotal} total`],
|
||||
['Schedule', `${draft.startAt} → ${draft.endAt}`],
|
||||
['Spend caps', `$${g.weeklyCap.toLocaleString()}/wk · $${g.monthlyCap.toLocaleString()}/mo`],
|
||||
['CPL guardrail', `alert > $${g.cplThreshold} · notify at ${g.alertPct}% of cap`],
|
||||
['Auto-pause rules', [g.pauseOnDailyCap && 'daily cap', g.pauseOnCplBreach && 'CPL breach', g.anomalyDetection && 'anomaly'].filter(Boolean).join(', ') || 'none'],
|
||||
['Creative', selectedCreative?.headline], ['Lead form', selectedForm?.title || 'Default capture form'],
|
||||
['Playbook', draft.playbook],
|
||||
].map(([k, v]) => (
|
||||
<div key={k} className="flex justify-between gap-3 py-1.5 border-b border-zinc-100 dark:border-white/5">
|
||||
<span className="text-zinc-500">{k}</span>
|
||||
<span className="font-semibold text-zinc-800 dark:text-zinc-100 text-right">{v || '—'}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="pt-2">
|
||||
<CopyableLink url={buildUtm(draft, { creative: selectedCreative }).url} />
|
||||
</div>
|
||||
<p className="text-[11px] text-amber-600 dark:text-amber-400/80 pt-2 flex items-center gap-1">
|
||||
<FileCheck2 size={12} /> Saved as a draft with <b>pending</b> approval — it cannot publish until creatives and consent text are approved.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between mt-5 pt-4 border-t border-zinc-100 dark:border-white/5">
|
||||
<Btn variant="ghost" onClick={() => (step === 0 ? onClose() : setStep(step - 1))}>
|
||||
{step === 0 ? 'Cancel' : 'Back'}
|
||||
</Btn>
|
||||
{step < STEPS.length - 1
|
||||
? <Btn icon={ChevronRight} disabled={!canNext} onClick={() => setStep(step + 1)}>Next</Btn>
|
||||
: <Btn icon={Check} onClick={finish}>Create draft</Btn>}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const inputCls = 'w-full rounded-xl border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 px-3 py-2 text-base sm:text-sm text-zinc-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-amber-500';
|
||||
|
||||
function Field({ label, hint, icon: Icon, children }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="text-xs font-semibold text-zinc-600 dark:text-zinc-300 mb-1.5 flex items-center gap-1.5">
|
||||
{Icon && <Icon size={13} className="text-amber-500" />} {label}
|
||||
{hint && <span className="font-normal text-zinc-400">· {hint}</span>}
|
||||
</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
const copyToClipboard = async (text, label = 'Copied') => {
|
||||
try { await navigator.clipboard.writeText(text); toast.success(label); }
|
||||
catch { toast.error('Could not copy — select and copy manually'); }
|
||||
};
|
||||
|
||||
function CopyableLink({ url }) {
|
||||
return (
|
||||
<div className="rounded-xl bg-zinc-50 dark:bg-white/5 p-3">
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wide text-zinc-400 flex items-center gap-1"><Link2 size={11} className="text-amber-500" /> Tracked link (UTM)</span>
|
||||
<Btn size="sm" variant="ghost" icon={Copy} onClick={() => copyToClipboard(url, 'Tracked link copied')}>Copy</Btn>
|
||||
</div>
|
||||
<code className="block text-[11px] font-mono text-blue-600 dark:text-blue-400 break-all">{url}</code>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleRow({ label, on, onToggle }) {
|
||||
return (
|
||||
<button type="button" onClick={onToggle}
|
||||
className="w-full flex items-center justify-between gap-3 px-3 py-2 rounded-xl bg-zinc-50 dark:bg-white/5 hover:bg-zinc-100 dark:hover:bg-white/10 transition-colors text-left">
|
||||
<span className="text-xs text-zinc-700 dark:text-zinc-200">{label}</span>
|
||||
<span className={`shrink-0 inline-flex items-center gap-1 px-2 py-1 rounded-lg text-[11px] font-semibold ${on ? 'text-emerald-600 dark:text-emerald-400 bg-emerald-500/10' : 'text-zinc-400 bg-zinc-200/60 dark:bg-white/5'}`}>
|
||||
<Power size={12} /> {on ? 'On' : 'Off'}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
/**
|
||||
* ComplianceCenter — pending creative/consent/campaign approvals, blocked claims,
|
||||
* review-disclosure checks, and an AI audit + conversion-sync log (spec §8, §10.1).
|
||||
* An unapproved campaign cannot publish and a risky message cannot send.
|
||||
*/
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import {
|
||||
ShieldCheck, AlertTriangle, Ban, CheckCircle2, Clock, RefreshCw,
|
||||
FileWarning, Wand2, ArrowUpRight, FileText, History, ChevronDown, Lock,
|
||||
Megaphone, Image, MessageSquare, Download, Search, ScrollText,
|
||||
Quote, ShieldAlert, Bot,
|
||||
} from 'lucide-react';
|
||||
import { useSocialAds } from '../SocialAdsContext.jsx';
|
||||
import { PLATFORMS } from '../data/platforms.js';
|
||||
import { PlatformBadge, Card, SectionHeader, StatusChip, Btn } from '../components/ui.jsx';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const fmtDate = (iso) => iso ? new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : '';
|
||||
const fmtDateTime = (iso) => iso ? new Date(iso).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : '';
|
||||
|
||||
const AUDIT_CAT = {
|
||||
campaign: { label: 'Campaign', color: 'text-blue-500', icon: Megaphone },
|
||||
creative: { label: 'Creative', color: 'text-purple-500', icon: Image },
|
||||
consent: { label: 'Consent', color: 'text-emerald-500', icon: FileText },
|
||||
ai: { label: 'AI', color: 'text-amber-500', icon: Wand2 },
|
||||
conversion: { label: 'Conversion', color: 'text-cyan-500', icon: ArrowUpRight },
|
||||
message: { label: 'Message', color: 'text-pink-500', icon: MessageSquare },
|
||||
};
|
||||
|
||||
const csvCell = (v) => { const s = String(v ?? '').replace(/"/g, '""'); return /[",\n]/.test(s) ? `"${s}"` : s; };
|
||||
|
||||
const TYPE_LABEL = {
|
||||
creative: 'Creative', consent_text: 'Consent text',
|
||||
review_disclosure: 'Review disclosure', campaign: 'Campaign publish',
|
||||
};
|
||||
|
||||
const SYNC_ICON = {
|
||||
synced: { icon: CheckCircle2, color: 'text-emerald-500' },
|
||||
queued: { icon: Clock, color: 'text-amber-500' },
|
||||
failed: { icon: AlertTriangle, color: 'text-red-500' },
|
||||
};
|
||||
|
||||
export default function ComplianceCenter() {
|
||||
const { approvals, resolveApproval, conversionEvents, retryConversion, leadForms, consentVersions, approveConsentVersion, blockConsentVersion, anomalies, conversations, campaigns, creatives, reviews, setReviewPermission, setReviewDisclosure } = useSocialAds();
|
||||
|
||||
const pending = approvals.filter(a => a.status === 'pending');
|
||||
const blocked = approvals.filter(a => a.status === 'blocked');
|
||||
const resolved = approvals.filter(a => a.status === 'approved');
|
||||
|
||||
// ── Unified audit log (spec §12 H2) — derived live from every compliance-
|
||||
// relevant data source so it never drifts from the underlying records.
|
||||
const auditEntries = useMemo(() => {
|
||||
const campById = Object.fromEntries(campaigns.map(c => [c.id, c]));
|
||||
const creById = Object.fromEntries(creatives.map(c => [c.id, c]));
|
||||
const formById = Object.fromEntries(leadForms.map(f => [f.id, f]));
|
||||
const out = [];
|
||||
|
||||
for (const a of approvals) {
|
||||
const cat = a.type === 'consent_text' ? 'consent' : a.type === 'campaign' ? 'campaign' : 'creative';
|
||||
const cre = a.type === 'creative' ? creById[a.refId] : null;
|
||||
const form = a.type === 'consent_text' ? formById[a.refId] : null;
|
||||
const camp = a.type === 'campaign' ? campById[a.refId] : (cre ? campById[cre.campaignId] : form ? campById[form.campaignId] : null);
|
||||
const action = a.status === 'approved' ? 'Approved' : a.status === 'blocked' ? 'Blocked' : 'Submitted for review';
|
||||
out.push({
|
||||
id: `aud_apr_${a.id}`, ts: a.submittedAt, category: cat, action, detail: a.title + (a.flags?.length ? ` — ${a.flags.join('; ')}` : ''),
|
||||
actor: a.reviewer || (a.flags?.length ? 'AI compliance reviewer' : 'system'),
|
||||
platform: camp?.platform || cre?.platform || form?.platform || '', campaignId: camp?.id || '', serviceType: camp?.serviceType || '',
|
||||
});
|
||||
}
|
||||
for (const v of consentVersions) {
|
||||
const form = formById[v.formId];
|
||||
const camp = form ? campById[form.campaignId] : null;
|
||||
out.push({
|
||||
id: `aud_cv_${v.id}`, ts: v.createdAt, category: 'consent', action: `Consent v${v.version} ${v.status}`,
|
||||
detail: `${form?.title || v.formId}: "${v.text.slice(0, 60)}…"`, actor: v.reviewer || 'system',
|
||||
platform: form?.platform || camp?.platform || '', campaignId: camp?.id || '', serviceType: camp?.serviceType || '',
|
||||
});
|
||||
}
|
||||
for (const e of conversionEvents) {
|
||||
out.push({
|
||||
id: `aud_ce_${e.id}`, ts: e.syncedAt || e.submittedAt, category: 'conversion', action: `${e.eventName} · ${e.syncStatus}`,
|
||||
detail: `dedup ${e.dedupKey}${e.value ? ` · $${e.value.toLocaleString()}` : ''}`, actor: 'system',
|
||||
platform: e.platform, campaignId: '', serviceType: '',
|
||||
});
|
||||
}
|
||||
for (const an of anomalies) {
|
||||
const camp = campById[an.campaignId];
|
||||
out.push({
|
||||
id: `aud_an_${an.id}`, ts: an.detectedAt, category: 'ai', action: `Anomaly: ${an.type}${an.status !== 'open' ? ` · ${an.status}` : ''}`,
|
||||
detail: `${camp?.name || an.campaignId} — ${an.metric}`, actor: 'AI anomaly detector',
|
||||
platform: camp?.platform || '', campaignId: an.campaignId, serviceType: camp?.serviceType || '',
|
||||
});
|
||||
}
|
||||
for (const c of conversations.filter(cv => cv.status !== 'new')) {
|
||||
out.push({
|
||||
id: `aud_cv2_${c.id}`, ts: c.receivedAt, category: 'message', action: `${c.classification.type} · ${c.status}`,
|
||||
detail: `${c.author}: "${c.text.slice(0, 50)}…"`, actor: 'AI triage',
|
||||
platform: c.platform, campaignId: '', serviceType: '',
|
||||
});
|
||||
}
|
||||
return out.map(e => ({ ...e, campaignName: campById[e.campaignId]?.name || '' }))
|
||||
.sort((a, b) => String(b.ts).localeCompare(String(a.ts)));
|
||||
}, [approvals, consentVersions, conversionEvents, anomalies, conversations, campaigns, creatives, leadForms]);
|
||||
|
||||
const [auditCat, setAuditCat] = useState('all');
|
||||
const [auditPlatform, setAuditPlatform] = useState('all');
|
||||
const [auditSearch, setAuditSearch] = useState('');
|
||||
|
||||
const filteredAudit = auditEntries.filter(e =>
|
||||
(auditCat === 'all' || e.category === auditCat) &&
|
||||
(auditPlatform === 'all' || e.platform === auditPlatform) &&
|
||||
(!auditSearch || `${e.action} ${e.detail} ${e.actor}`.toLowerCase().includes(auditSearch.toLowerCase())),
|
||||
);
|
||||
|
||||
const exportAudit = () => {
|
||||
const headers = ['Timestamp', 'Category', 'Action', 'Detail', 'Actor', 'Source', 'Campaign', 'Service type'];
|
||||
const lines = [headers.join(',')];
|
||||
for (const e of filteredAudit) {
|
||||
lines.push([e.ts, AUDIT_CAT[e.category]?.label || e.category, e.action, e.detail, e.actor, e.platform, e.campaignName, e.serviceType].map(csvCell).join(','));
|
||||
}
|
||||
const blob = new Blob([lines.join('\n')], { type: 'text/csv;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = 'ad-engine-audit-log.csv'; a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success(`Exported ${filteredAudit.length} audit entries to CSV`);
|
||||
};
|
||||
|
||||
// Reviews used in ads (spec §11 review/testimonial disclosure).
|
||||
const usedReviews = reviews.filter(r => r.usedIn);
|
||||
const campName = (id) => campaigns.find(c => c.id === id)?.name || id;
|
||||
|
||||
// AI quality metrics (spec §8 "AI audit queue", §10 AI quality).
|
||||
const aiMetrics = useMemo(() => {
|
||||
const aiCreatives = creatives.filter(c => c.aiGenerated);
|
||||
const corrections = creatives.reduce((s, c) => s + (c.versions?.length || 0), 0);
|
||||
const blockedResp = approvals.filter(a => a.status === 'blocked').length;
|
||||
const handoffs = conversations.filter(c => c.classification.type === 'complaint' && c.status !== 'new').length;
|
||||
const totalConv = conversations.length || 1;
|
||||
const contained = conversations.filter(c => c.classification.type !== 'complaint').length;
|
||||
const avgRating = reviews.length ? reviews.reduce((s, r) => s + r.rating, 0) / reviews.length : 0;
|
||||
return {
|
||||
aiPending: aiCreatives.filter(c => c.approvalStatus === 'pending').length,
|
||||
containment: contained / totalConv,
|
||||
handoffs, blockedResp, corrections,
|
||||
sentiment: avgRating,
|
||||
};
|
||||
}, [creatives, approvals, conversations, reviews]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SectionHeader icon={ShieldCheck} title="Compliance Center" description="Approval workflow, blocked claims, consent versions, and the AI / conversion audit log. Nothing risky publishes or sends until resolved.">
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusChip status="pending" label={`${pending.length} pending`} />
|
||||
{blocked.length > 0 && <StatusChip status="blocked" label={`${blocked.length} blocked`} />}
|
||||
</div>
|
||||
</SectionHeader>
|
||||
|
||||
{/* Blocked first — must resolve */}
|
||||
{blocked.length > 0 && (
|
||||
<Card title="Blocked — resolve before publish" icon={Ban} subtitle="Risky claims detected by the compliance reviewer.">
|
||||
<div className="space-y-2">
|
||||
{blocked.map(a => <ApprovalRow key={a.id} item={a} onResolve={resolveApproval} />)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Pending queue */}
|
||||
<Card title="Approval queue" icon={Clock} subtitle="Creative, consent text, review disclosure, and campaign publish approvals.">
|
||||
{pending.length === 0
|
||||
? <p className="text-sm text-zinc-500 py-4 text-center">Nothing pending — all clear.</p>
|
||||
: <div className="space-y-2">{pending.map(a => <ApprovalRow key={a.id} item={a} onResolve={resolveApproval} />)}</div>}
|
||||
</Card>
|
||||
|
||||
{/* Consent text versions (spec §8) */}
|
||||
<Card title="Consent text versions" icon={FileText} subtitle="Tenant-approved SMS/email consent language per form. One version is live at a time; changes need re-approval.">
|
||||
<div className="space-y-3">
|
||||
{leadForms.map(form => {
|
||||
const versions = consentVersions.filter(v => v.formId === form.id);
|
||||
if (versions.length === 0) return null;
|
||||
return <ConsentFormBlock key={form.id} form={form} versions={versions} onApprove={approveConsentVersion} onBlock={blockConsentVersion} />;
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Review/testimonial usage + FTC disclosure (spec §8, §11) */}
|
||||
<Card title="Review usage & disclosure" icon={Quote} subtitle="Reviews used in ads need written usage permission and an FTC disclosure — otherwise the review creative is blocked.">
|
||||
<div className="space-y-2">
|
||||
{usedReviews.length === 0 && <p className="text-sm text-zinc-500 py-3 text-center">No reviews are currently used in ad creative.</p>}
|
||||
{usedReviews.map(r => {
|
||||
const cleared = r.usagePermission === 'granted' && r.disclosureStatus === 'compliant';
|
||||
return (
|
||||
<div key={r.id} className={`p-3 rounded-xl border ${cleared ? 'border-zinc-200 dark:border-white/10 bg-zinc-50 dark:bg-white/[0.02]' : 'border-red-200 dark:border-red-500/20 bg-red-50/60 dark:bg-red-500/5'}`}>
|
||||
<div className="flex flex-col sm:flex-row sm:items-start gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap mb-1">
|
||||
<PlatformBadge platformId={r.platform} size="sm" showName={false} />
|
||||
<span className="text-amber-500 text-xs font-mono">{'★'.repeat(r.rating)}</span>
|
||||
<span className="text-[11px] text-zinc-400">used in {campName(r.usedIn)}</span>
|
||||
{cleared
|
||||
? <span className="inline-flex items-center gap-1 text-[10px] font-bold uppercase tracking-wider text-emerald-600 dark:text-emerald-400 bg-emerald-500/10 px-1.5 py-0.5 rounded"><ShieldCheck size={10} /> Cleared to use</span>
|
||||
: <span className="inline-flex items-center gap-1 text-[10px] font-bold uppercase tracking-wider text-red-600 dark:text-red-400 bg-red-500/10 px-1.5 py-0.5 rounded"><ShieldAlert size={10} /> Creative blocked</span>}
|
||||
</div>
|
||||
<p className="text-[11px] text-zinc-600 dark:text-zinc-300 italic">"{r.text}"</p>
|
||||
<div className="flex items-center gap-3 mt-1.5 text-[11px]">
|
||||
<span className={`inline-flex items-center gap-1 ${r.usagePermission === 'granted' ? 'text-emerald-600 dark:text-emerald-400' : r.usagePermission === 'denied' ? 'text-red-500' : 'text-amber-600 dark:text-amber-400'}`}>
|
||||
<ShieldCheck size={12} /> Permission: {r.usagePermission}
|
||||
</span>
|
||||
<span className={`inline-flex items-center gap-1 ${r.disclosureStatus === 'compliant' ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-500'}`}>
|
||||
<FileText size={12} /> FTC disclosure: {r.disclosureStatus}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{r.usagePermission !== 'granted'
|
||||
? <Btn size="sm" variant="success" icon={CheckCircle2} onClick={() => setReviewPermission(r.id, 'granted')}>Grant permission</Btn>
|
||||
: <Btn size="sm" variant="danger" icon={Ban} onClick={() => setReviewPermission(r.id, 'denied')}>Revoke</Btn>}
|
||||
{r.disclosureStatus !== 'compliant' && <Btn size="sm" variant="outline" icon={FileText} onClick={() => setReviewDisclosure(r.id, 'compliant')}>Add disclosure</Btn>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-[11px] text-zinc-400 mt-3 flex items-start gap-1"><AlertTriangle size={12} className="mt-0.5 shrink-0" /> Per FTC and platform guidance, testimonials in ads require documented permission and clear disclosure. Unapproved review creative cannot publish.</p>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Conversion sync log */}
|
||||
<Card title="Conversion sync log" icon={ArrowUpRight} subtitle="Events sent back to ad platforms (deduped).">
|
||||
<div className="space-y-1.5">
|
||||
{conversionEvents.map(e => {
|
||||
const meta = SYNC_ICON[e.syncStatus];
|
||||
const Icon = meta.icon;
|
||||
return (
|
||||
<div key={e.id} className="flex items-center gap-2 px-2.5 py-2 rounded-lg bg-zinc-50 dark:bg-white/5">
|
||||
<Icon size={14} className={meta.color} />
|
||||
<PlatformBadge platformId={e.platform} size="sm" showName={false} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-semibold text-zinc-700 dark:text-zinc-200 truncate">{e.eventName}</p>
|
||||
<p className="text-[10px] text-zinc-400 font-mono truncate">dedup: {e.dedupKey}</p>
|
||||
</div>
|
||||
{e.value > 0 && <span className="text-[11px] font-mono text-emerald-600 dark:text-emerald-400">${e.value.toLocaleString()}</span>}
|
||||
{e.syncStatus === 'failed'
|
||||
? <Btn size="sm" variant="danger" icon={RefreshCw} onClick={() => retryConversion(e.id)}>Retry</Btn>
|
||||
: <StatusChip status={e.syncStatus} />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* AI audit & quality (spec §8 "AI audit queue", §10 AI quality) */}
|
||||
<Card title="AI audit & quality" icon={Bot} subtitle="AI containment, handoffs, blocked copy, and human corrections.">
|
||||
<div className="grid grid-cols-3 gap-2 mb-3">
|
||||
<AiStat value={`${Math.round(aiMetrics.containment * 100)}%`} label="Containment" color="text-emerald-500" />
|
||||
<AiStat value={aiMetrics.handoffs} label="Human handoffs" color="text-amber-500" />
|
||||
<AiStat value={aiMetrics.blockedResp} label="Blocked copy" color="text-red-500" />
|
||||
<AiStat value={aiMetrics.corrections} label="Human corrections" color="text-blue-500" />
|
||||
<AiStat value={aiMetrics.sentiment.toFixed(1)} label="Sentiment ★" color="text-purple-500" />
|
||||
<AiStat value={aiMetrics.aiPending} label="AI items pending" color="text-amber-500" />
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2 px-2.5 py-2 rounded-lg bg-purple-50 dark:bg-purple-500/10 mb-2">
|
||||
<Wand2 size={14} className="text-purple-500 mt-0.5 shrink-0" />
|
||||
<p className="text-[11px] text-zinc-600 dark:text-zinc-300">
|
||||
All AI copy required human approval — <b>0</b> auto-published. {aiMetrics.aiPending > 0 ? `${aiMetrics.aiPending} AI-generated item(s) still awaiting review in the queue above.` : 'No AI items pending.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] font-bold uppercase tracking-wider text-zinc-400 mb-1.5">Recently approved</p>
|
||||
<div className="space-y-1.5">
|
||||
{resolved.map(a => (
|
||||
<div key={a.id} className="flex items-center gap-2 px-2.5 py-2 rounded-lg bg-zinc-50 dark:bg-white/5">
|
||||
<CheckCircle2 size={14} className="text-emerald-500 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-semibold text-zinc-700 dark:text-zinc-200 truncate">{a.title}</p>
|
||||
<p className="text-[10px] text-zinc-400">{TYPE_LABEL[a.type]} · approved by {a.reviewer}</p>
|
||||
</div>
|
||||
<StatusChip status="approved" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Audit log + export (spec §12 H2) */}
|
||||
<Card title="Audit log" icon={ScrollText} subtitle="Campaign, consent, message, AI, and conversion events — filter and export."
|
||||
action={<Btn size="sm" variant="outline" icon={Download} onClick={exportAudit}>Export CSV</Btn>}>
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap items-center gap-2 mb-3">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{['all', ...Object.keys(AUDIT_CAT)].map(c => (
|
||||
<button key={c} onClick={() => setAuditCat(c)}
|
||||
className={`px-2.5 py-1 rounded-lg text-[11px] font-semibold transition-colors ${auditCat === c ? 'bg-amber-500 text-white' : 'bg-zinc-100 dark:bg-white/5 text-zinc-600 dark:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-white/10'}`}>
|
||||
{c === 'all' ? 'All' : AUDIT_CAT[c].label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="w-px h-5 bg-zinc-200 dark:bg-white/10 mx-1 hidden sm:block" />
|
||||
<select value={auditPlatform} onChange={e => setAuditPlatform(e.target.value)}
|
||||
className="rounded-lg border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 px-2 py-1.5 text-xs text-zinc-700 dark:text-zinc-200 focus:outline-none focus:ring-2 focus:ring-amber-500">
|
||||
<option value="all">All sources</option>
|
||||
{PLATFORMS.map(p => <option key={p.id} value={p.id}>{p.short}</option>)}
|
||||
</select>
|
||||
<div className="relative flex-1 min-w-[140px]">
|
||||
<Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-zinc-400" />
|
||||
<input value={auditSearch} onChange={e => setAuditSearch(e.target.value)} placeholder="Search audit log…"
|
||||
className="w-full rounded-lg border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 pl-8 pr-2 py-1.5 text-xs text-zinc-700 dark:text-zinc-200 focus:outline-none focus:ring-2 focus:ring-amber-500" />
|
||||
</div>
|
||||
<span className="text-[11px] text-zinc-400 ml-auto">{filteredAudit.length} entries</span>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-x-auto max-h-96 overflow-y-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="sticky top-0 bg-white dark:bg-zinc-900">
|
||||
<tr className="text-left text-[10px] uppercase tracking-wider text-zinc-400 border-b border-zinc-100 dark:border-white/5">
|
||||
<th className="py-2 pr-2 font-bold">When</th>
|
||||
<th className="py-2 px-2 font-bold">Category</th>
|
||||
<th className="py-2 px-2 font-bold">Action</th>
|
||||
<th className="py-2 px-2 font-bold">Detail</th>
|
||||
<th className="py-2 px-2 font-bold">Actor</th>
|
||||
<th className="py-2 pl-2 font-bold">Source</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredAudit.length === 0 && <tr><td colSpan={6} className="py-4 text-center text-zinc-400">No audit entries match these filters.</td></tr>}
|
||||
{filteredAudit.map(e => {
|
||||
const cat = AUDIT_CAT[e.category] || AUDIT_CAT.campaign;
|
||||
const CIcon = cat.icon;
|
||||
return (
|
||||
<tr key={e.id} className="border-b border-zinc-50 dark:border-white/5 align-top">
|
||||
<td className="py-2 pr-2 text-zinc-400 whitespace-nowrap">{fmtDateTime(e.ts)}</td>
|
||||
<td className="py-2 px-2">
|
||||
<span className={`inline-flex items-center gap-1 font-semibold ${cat.color}`}>{CIcon && <CIcon size={12} />}{cat.label}</span>
|
||||
</td>
|
||||
<td className="py-2 px-2 font-semibold text-zinc-700 dark:text-zinc-200 whitespace-nowrap">{e.action}</td>
|
||||
<td className="py-2 px-2 text-zinc-500 max-w-[280px] truncate" title={e.detail}>{e.detail}</td>
|
||||
<td className="py-2 px-2 text-zinc-500 whitespace-nowrap">{e.actor}</td>
|
||||
<td className="py-2 pl-2">{e.platform ? <PlatformBadge platformId={e.platform} size="sm" showName={false} /> : <span className="text-zinc-300 dark:text-zinc-600">—</span>}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AiStat({ value, label, color }) {
|
||||
return (
|
||||
<div className="rounded-lg bg-zinc-50 dark:bg-white/5 p-2 text-center">
|
||||
<p className={`text-base font-mono font-bold ${color}`}>{value}</p>
|
||||
<p className="text-[9px] uppercase tracking-wider text-zinc-400 leading-tight">{label}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const CONSENT_CHIP = { approved: 'approved', pending: 'pending', superseded: 'merged', blocked: 'blocked' };
|
||||
|
||||
function ConsentFormBlock({ form, versions, onApprove, onBlock }) {
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
const sorted = [...versions].sort((a, b) => b.version - a.version);
|
||||
const live = sorted.find(v => v.status === 'approved');
|
||||
const pending = sorted.filter(v => v.status === 'pending');
|
||||
const history = sorted.filter(v => v.status === 'superseded' || v.status === 'blocked');
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-zinc-200 dark:border-white/10 p-3">
|
||||
<div className="flex items-center justify-between gap-2 mb-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<PlatformBadge platformId={form.platform} size="sm" showName={false} />
|
||||
<h4 className="font-bold text-sm text-zinc-900 dark:text-white truncate">{form.title}</h4>
|
||||
</div>
|
||||
{live && <span className="inline-flex items-center gap-1 text-[10px] font-bold uppercase tracking-wider text-emerald-600 dark:text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded-full shrink-0"><Lock size={10} /> Live v{live.version}</span>}
|
||||
</div>
|
||||
|
||||
{/* Live version */}
|
||||
{live && <ConsentVersionView v={live} live />}
|
||||
|
||||
{/* Pending versions awaiting approval */}
|
||||
{pending.map(v => (
|
||||
<div key={v.id} className="mt-2 rounded-lg border border-amber-200 dark:border-amber-500/20 bg-amber-50/60 dark:bg-amber-500/5 p-2.5">
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-amber-600 dark:text-amber-400 flex items-center gap-1"><AlertTriangle size={11} /> v{v.version} pending approval</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Btn size="sm" variant="success" icon={CheckCircle2} onClick={() => onApprove(v.id)}>Approve</Btn>
|
||||
<Btn size="sm" variant="danger" icon={Ban} onClick={() => onBlock(v.id)}>Block</Btn>
|
||||
</div>
|
||||
</div>
|
||||
<ConsentVersionView v={v} />
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* History */}
|
||||
{history.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<button onClick={() => setShowHistory(s => !s)} className="text-[11px] text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200 flex items-center gap-1">
|
||||
<History size={12} /> {history.length} previous version{history.length > 1 ? 's' : ''}
|
||||
<ChevronDown size={12} className={`transition-transform ${showHistory ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
{showHistory && (
|
||||
<div className="mt-2 space-y-2 pl-2 border-l-2 border-zinc-100 dark:border-white/10">
|
||||
{history.map(v => <ConsentVersionView key={v.id} v={v} muted />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConsentVersionView({ v, live, muted }) {
|
||||
return (
|
||||
<div className={muted ? 'opacity-70' : ''}>
|
||||
<div className="flex items-center gap-2 flex-wrap mb-1">
|
||||
<span className="text-[10px] font-mono font-bold text-zinc-500">v{v.version}</span>
|
||||
<span className="text-[10px] text-zinc-400">· {v.channel}</span>
|
||||
{!live && <StatusChip status={CONSENT_CHIP[v.status] || 'draft'} label={v.status} />}
|
||||
<span className="text-[10px] text-zinc-400 ml-auto">{v.reviewer ? `${v.reviewer} · ` : ''}{fmtDate(v.createdAt)}</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-zinc-600 dark:text-zinc-300 italic leading-relaxed">"{v.text}"</p>
|
||||
<div className="flex items-center gap-3 mt-1 text-[10px] text-zinc-400 flex-wrap">
|
||||
<span>Opt-out: {v.optOut}</span>
|
||||
{v.privacyPolicyUrl && <span className="font-mono truncate">Privacy: {v.privacyPolicyUrl}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ApprovalRow({ item, onResolve }) {
|
||||
const isBlocked = item.status === 'blocked';
|
||||
return (
|
||||
<div className={`flex flex-col sm:flex-row sm:items-center gap-3 p-3 rounded-xl ${isBlocked ? 'bg-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-500/20' : 'bg-zinc-50 dark:bg-white/5'}`}>
|
||||
<div className="flex items-start gap-2 min-w-0 flex-1">
|
||||
<FileWarning size={16} className={isBlocked ? 'text-red-500 mt-0.5' : 'text-amber-500 mt-0.5'} />
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wide px-1.5 py-0.5 rounded bg-zinc-200 dark:bg-white/10 text-zinc-500">{TYPE_LABEL[item.type]}</span>
|
||||
<h4 className="font-semibold text-sm text-zinc-800 dark:text-zinc-100">{item.title}</h4>
|
||||
</div>
|
||||
{item.flags?.length > 0 && (
|
||||
<ul className="mt-1 space-y-0.5">
|
||||
{item.flags.map((f, i) => <li key={i} className="text-[11px] text-amber-600 dark:text-amber-400/80 flex items-start gap-1"><AlertTriangle size={11} className="mt-0.5 shrink-0" />{f}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Btn size="sm" variant="success" icon={CheckCircle2} disabled={isBlocked} onClick={() => onResolve(item.id, 'approved')}>Approve</Btn>
|
||||
<Btn size="sm" variant="danger" icon={Ban} onClick={() => onResolve(item.id, 'blocked')}>{isBlocked ? 'Keep blocked' : 'Block'}</Btn>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
/**
|
||||
* ConnectedAccounts — platform cards with connect/disconnect, token + webhook health,
|
||||
* permission scopes, last sync, and a webhook test button (spec §4, §8).
|
||||
*
|
||||
* Capability badges make the spec §11 non-negotiable visible: the UI honestly shows
|
||||
* which platforms are ingest-only / report-only / full-publish, so nobody assumes
|
||||
* automation that doesn't exist.
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Plug, RefreshCw, Webhook, KeyRound, Clock, AlertTriangle, CheckCircle2, AlarmClock, ShieldCheck, ShieldAlert, Upload, Download, X, FileUp } from 'lucide-react';
|
||||
import { useSocialAds } from '../SocialAdsContext.jsx';
|
||||
import { PLATFORMS, CAPABILITY, getPlatform } from '../data/platforms.js';
|
||||
import { Card, StatusChip, CapabilityBadge, SectionHeader, Btn } from '../components/ui.jsx';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const ALL_CAPS = [
|
||||
CAPABILITY.INGEST, CAPABILITY.REPORT, CAPABILITY.CONVERSION_SYNC,
|
||||
CAPABILITY.DRAFT_BUILDER, CAPABILITY.FULL_PUBLISH,
|
||||
];
|
||||
|
||||
const TOKEN_META = {
|
||||
healthy: { icon: CheckCircle2, color: 'text-emerald-500', label: 'Healthy' },
|
||||
expiring: { icon: AlertTriangle, color: 'text-amber-500', label: 'Expiring soon' },
|
||||
revoked: { icon: AlertTriangle, color: 'text-red-500', label: 'Revoked' },
|
||||
none: { icon: KeyRound, color: 'text-zinc-400', label: 'No token' },
|
||||
};
|
||||
|
||||
function fmtDate(iso) {
|
||||
if (!iso) return '—';
|
||||
return new Date(iso).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
|
||||
}
|
||||
|
||||
// Permission/scope status from the granted scopes (spec §8 "permission status").
|
||||
function permState(acc) {
|
||||
if (acc?.status === 'connected') return acc.scopes?.length ? 'granted' : 'insufficient';
|
||||
if (acc?.status === 'action_required') return 'insufficient';
|
||||
return 'none';
|
||||
}
|
||||
const PERM_META = {
|
||||
granted: { icon: ShieldCheck, color: 'text-emerald-600 dark:text-emerald-400', label: 'Granted' },
|
||||
insufficient: { icon: ShieldAlert, color: 'text-amber-600 dark:text-amber-400', label: 'Insufficient' },
|
||||
none: { icon: KeyRound, color: 'text-zinc-400', label: 'Not connected' },
|
||||
};
|
||||
|
||||
export default function ConnectedAccounts() {
|
||||
const { accounts, toggleAccount, testWebhook, reconnectAccount, importLeads } = useSocialAds();
|
||||
const accountByPlatform = Object.fromEntries(accounts.map(a => [a.platform, a]));
|
||||
const [importFor, setImportFor] = useState(null); // account being manually imported into
|
||||
|
||||
const connectedCount = accounts.filter(a => a.status === 'connected').length;
|
||||
|
||||
// Token-expiration / failure alerts (spec §8 MVP, §11, §12 A1 "token failure displays alert").
|
||||
const tokenAlerts = accounts.filter(a => a.tokenStatus === 'expiring' || a.tokenStatus === 'revoked' || a.status === 'action_required');
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SectionHeader
|
||||
icon={Plug}
|
||||
title="Connected Accounts"
|
||||
description="Manage platform connections, token health, and webhooks. Secrets are stored outside the app DB."
|
||||
>
|
||||
<StatusChip status="connected" label={`${connectedCount}/${PLATFORMS.length} connected`} />
|
||||
</SectionHeader>
|
||||
|
||||
{/* Token-expiration / failure alerts (spec §8 MVP, §11, §12 A1) */}
|
||||
{tokenAlerts.length > 0 && (
|
||||
<div className="mb-5 rounded-xl border border-red-200 dark:border-red-500/30 bg-red-50 dark:bg-red-500/10 p-4">
|
||||
<div className="flex items-center gap-2 mb-2 text-sm font-semibold text-red-700 dark:text-red-400">
|
||||
<AlarmClock size={18} className="animate-pulse shrink-0" />
|
||||
{tokenAlerts.length} account token{tokenAlerts.length > 1 ? 's' : ''} need attention — leads can be lost while a connection is down.
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{tokenAlerts.map(a => {
|
||||
const p = getPlatform(a.platform);
|
||||
const PIcon = p?.icon;
|
||||
const reason = a.tokenStatus === 'revoked' ? 'token revoked' : a.tokenStatus === 'expiring' ? `token expiring ${a.tokenExpiresAt}` : 'action required';
|
||||
return (
|
||||
<div key={a.id} className="flex items-center gap-2 px-2.5 py-2 rounded-lg bg-white/70 dark:bg-white/5">
|
||||
{PIcon && <span className="shrink-0" style={{ color: p.color }}><PIcon size={15} /></span>}
|
||||
<span className="text-xs font-semibold text-zinc-700 dark:text-zinc-200">{p?.short || a.platform}</span>
|
||||
<span className="text-[11px] text-red-600 dark:text-red-400">— {reason}</span>
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
<Btn size="sm" variant="outline" icon={Upload} onClick={() => setImportFor(a)}>Manual import</Btn>
|
||||
<Btn size="sm" variant="primary" icon={RefreshCw} onClick={() => reconnectAccount(a.id)}>Reconnect</Btn>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{PLATFORMS.map(p => {
|
||||
const acc = accountByPlatform[p.id];
|
||||
const connected = acc?.status === 'connected';
|
||||
const tokenMeta = TOKEN_META[acc?.tokenStatus] || TOKEN_META.none;
|
||||
const TokenIcon = tokenMeta.icon;
|
||||
|
||||
return (
|
||||
<Card key={p.id} className="!h-auto">
|
||||
<div className="flex items-start justify-between gap-2 mb-3">
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<span className="inline-flex items-center justify-center rounded-xl p-2.5 shrink-0" style={{ backgroundColor: `${p.color}1A`, color: p.color }}>
|
||||
<p.icon size={22} />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-bold text-zinc-900 dark:text-white truncate">{p.name}</h3>
|
||||
<p className="text-[11px] text-zinc-500 dark:text-zinc-400 truncate">{acc?.externalAccountId || 'Not connected'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<StatusChip status={acc?.status || 'disconnected'} />
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-zinc-500 dark:text-zinc-400 mb-3 line-clamp-2">{p.role}</p>
|
||||
|
||||
{/* Capability matrix */}
|
||||
<div className="flex flex-wrap gap-1.5 mb-3">
|
||||
{ALL_CAPS.map(cap => (
|
||||
<CapabilityBadge key={cap} capability={cap} active={p.capabilities.includes(cap)} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Health rows */}
|
||||
<div className="space-y-1.5 text-xs border-t border-zinc-100 dark:border-white/5 pt-3 mb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-1.5 text-zinc-500"><TokenIcon size={13} className={tokenMeta.color} /> Token</span>
|
||||
<span className={`font-semibold ${tokenMeta.color}`}>{tokenMeta.label}{acc?.tokenExpiresAt ? ` · ${acc.tokenExpiresAt}` : ''}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-1.5 text-zinc-500"><Webhook size={13} /> Webhook</span>
|
||||
<span className="font-semibold text-zinc-600 dark:text-zinc-300 capitalize">{(acc?.webhookStatus || 'not configured').replace(/_/g, ' ')}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-1.5 text-zinc-500"><Clock size={13} /> Last sync</span>
|
||||
<span className="font-semibold text-zinc-600 dark:text-zinc-300">{fmtDate(acc?.lastSyncAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Permissions / scopes (spec §8 "permission status", §7 scopes_json) */}
|
||||
{(() => {
|
||||
const perm = permState(acc);
|
||||
const pm = PERM_META[perm];
|
||||
const PermIcon = pm.icon;
|
||||
return (
|
||||
<div className="border-t border-zinc-100 dark:border-white/5 pt-3 mb-3">
|
||||
<div className="flex items-center justify-between text-xs mb-1.5">
|
||||
<span className="flex items-center gap-1.5 text-zinc-500"><KeyRound size={13} /> Permissions</span>
|
||||
<span className={`flex items-center gap-1 font-semibold ${pm.color}`}>{PermIcon && <PermIcon size={13} />} {pm.label}</span>
|
||||
</div>
|
||||
{acc?.scopes?.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{acc.scopes.map(s => (
|
||||
<span key={s} className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-zinc-100 dark:bg-white/10 text-zinc-600 dark:text-zinc-300">{s}</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[11px] text-amber-600 dark:text-amber-400/80">
|
||||
{perm === 'none' ? 'No permissions granted — connect to authorize scopes.' : 'Missing required scopes — reconnect to re-grant.'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{acc?.cautions && (
|
||||
<p className="text-[11px] text-amber-600 dark:text-amber-400/80 mb-3 flex items-start gap-1">
|
||||
<AlertTriangle size={12} className="mt-0.5 shrink-0" /> {p.cautions}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Btn
|
||||
size="sm"
|
||||
variant={connected ? 'outline' : 'primary'}
|
||||
onClick={() => toggleAccount(acc?.id)}
|
||||
className="flex-1"
|
||||
>
|
||||
{connected ? 'Disconnect' : 'Connect'}
|
||||
</Btn>
|
||||
<Btn size="sm" variant="ghost" icon={RefreshCw} onClick={() => testWebhook(acc?.id)} disabled={!connected}>
|
||||
Test lead
|
||||
</Btn>
|
||||
<Btn size="sm" variant="ghost" icon={Upload} onClick={() => setImportFor(acc)} disabled={!acc} title="Manually import leads from a CSV">
|
||||
Import
|
||||
</Btn>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{importFor && (
|
||||
<ManualImportModal
|
||||
account={importFor}
|
||||
onClose={() => setImportFor(null)}
|
||||
onImport={(rows) => { importLeads(importFor.platform, rows); setImportFor(null); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Manual lead import (spec §11 fallback) ─────────────────────────────────────
|
||||
function splitCsvLine(line) {
|
||||
const out = []; let cur = ''; let q = false;
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const ch = line[i];
|
||||
if (q) {
|
||||
if (ch === '"') { if (line[i + 1] === '"') { cur += '"'; i++; } else q = false; }
|
||||
else cur += ch;
|
||||
} else if (ch === '"') q = true;
|
||||
else if (ch === ',') { out.push(cur); cur = ''; }
|
||||
else cur += ch;
|
||||
}
|
||||
out.push(cur);
|
||||
return out;
|
||||
}
|
||||
|
||||
function parseLeadsCsv(text) {
|
||||
const lines = text.trim().split(/\r?\n/).filter(l => l.trim());
|
||||
if (!lines.length) return [];
|
||||
const header = splitCsvLine(lines[0]).map(h => h.trim().toLowerCase());
|
||||
const idx = (names) => header.findIndex(h => names.includes(h));
|
||||
const cols = {
|
||||
name: idx(['name', 'full_name', 'full name', 'contact']),
|
||||
phone: idx(['phone', 'phone_number', 'phone number', 'mobile']),
|
||||
email: idx(['email', 'email_address', 'e-mail']),
|
||||
address: idx(['address', 'street_address', 'street', 'property']),
|
||||
service: idx(['service', 'service_type', 'servicetype', 'type']),
|
||||
consent: idx(['consent', 'opt_in', 'optin', 'sms_consent']),
|
||||
};
|
||||
const hasHeader = [cols.name, cols.phone, cols.email, cols.address].some(i => i >= 0);
|
||||
const dataLines = hasHeader ? lines.slice(1) : lines;
|
||||
const pick = (c, i) => (i >= 0 ? (c[i] || '').trim() : '');
|
||||
return dataLines.map(line => {
|
||||
const c = splitCsvLine(line);
|
||||
return hasHeader
|
||||
? { name: pick(c, cols.name), phone: pick(c, cols.phone), email: pick(c, cols.email), address: pick(c, cols.address), service: pick(c, cols.service), consent: pick(c, cols.consent) }
|
||||
: { name: (c[0] || '').trim(), phone: (c[1] || '').trim(), email: (c[2] || '').trim(), address: (c[3] || '').trim(), service: (c[4] || '').trim(), consent: (c[5] || '').trim() };
|
||||
}).filter(r => r.name || r.phone || r.email);
|
||||
}
|
||||
|
||||
const SAMPLE_CSV = `name,phone,email,address,service,consent
|
||||
John Carter,(972) 555-0190,john.carter@example.com,"410 Oak St, Plano, TX 75023",storm,yes
|
||||
Maria Gomez,(469) 555-0177,maria.g@example.com,"88 Elm Dr, Allen, TX",replacement,yes
|
||||
Sam Two Rivers,(214) 555-0162,sam.tr@example.com,"7 Birch Ct, Frisco, TX",emergency,no`;
|
||||
|
||||
function ManualImportModal({ account, onClose, onImport }) {
|
||||
const [text, setText] = useState('');
|
||||
const platform = getPlatform(account.platform);
|
||||
const rows = parseLeadsCsv(text);
|
||||
const withConsent = rows.filter(r => /^(y|yes|true|1)$/i.test(r.consent)).length;
|
||||
|
||||
const onFile = (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => setText(String(reader.result || ''));
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
const downloadTemplate = () => {
|
||||
const blob = new Blob(['name,phone,email,address,service,consent\n'], { type: 'text/csv;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = 'lead-import-template.csv'; a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success('Template downloaded');
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[1000] flex items-center justify-center p-4" onClick={onClose}>
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-[1px]" />
|
||||
<div onClick={e => e.stopPropagation()} className="relative w-full max-w-lg max-h-[90vh] overflow-y-auto rounded-2xl bg-white dark:bg-zinc-900 shadow-2xl border border-zinc-200 dark:border-white/10 animate-in zoom-in-95">
|
||||
<div className="sticky top-0 bg-white/95 dark:bg-zinc-900/95 backdrop-blur border-b border-zinc-200 dark:border-white/10 px-5 py-3 flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{platform && <span className="shrink-0" style={{ color: platform.color }}><platform.icon size={18} /></span>}
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-bold text-sm text-zinc-900 dark:text-white truncate">Manual lead import — {platform?.short || account.platform}</h3>
|
||||
<p className="text-[11px] text-zinc-400">Fallback for when a webhook or token is down</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1.5 rounded-lg text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200 hover:bg-zinc-100 dark:hover:bg-white/10 shrink-0"><X size={18} /></button>
|
||||
</div>
|
||||
|
||||
<div className="p-5 space-y-3">
|
||||
<p className="text-[11px] text-zinc-500 dark:text-zinc-400">Paste CSV or upload a file exported from the platform. Columns: <span className="font-mono">name, phone, email, address, service, consent</span>. Rows without explicit consent import as manual-follow-up only.</p>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<label className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-xl border border-zinc-200 dark:border-white/15 text-xs font-semibold text-zinc-700 dark:text-zinc-200 hover:bg-zinc-100 dark:hover:bg-white/5 cursor-pointer">
|
||||
<FileUp size={14} /> Upload CSV
|
||||
<input type="file" accept=".csv,text/csv" onChange={onFile} className="hidden" />
|
||||
</label>
|
||||
<Btn size="sm" variant="ghost" onClick={() => setText(SAMPLE_CSV)}>Load sample</Btn>
|
||||
<Btn size="sm" variant="ghost" icon={Download} onClick={downloadTemplate}>Template</Btn>
|
||||
</div>
|
||||
|
||||
<textarea value={text} onChange={e => setText(e.target.value)} rows={7} placeholder="name,phone,email,address,service,consent Jane Doe,(972) 555-0101,jane@example.com,..."
|
||||
className="w-full rounded-lg border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 px-2.5 py-2 text-xs font-mono text-zinc-700 dark:text-zinc-200 focus:outline-none focus:ring-2 focus:ring-amber-500" />
|
||||
|
||||
{rows.length > 0 && (
|
||||
<div className="rounded-lg bg-zinc-50 dark:bg-white/[0.02] border border-zinc-200 dark:border-white/10 p-2.5 text-[11px] text-zinc-600 dark:text-zinc-300">
|
||||
<b>{rows.length}</b> lead{rows.length > 1 ? 's' : ''} parsed · <b>{withConsent}</b> with consent · {rows.length - withConsent} manual-follow-up only.
|
||||
<div className="mt-1.5 space-y-0.5 max-h-24 overflow-y-auto">
|
||||
{rows.slice(0, 5).map((r, i) => <div key={i} className="truncate text-zinc-500">• {r.name || '(no name)'} — {r.phone || r.email || '—'} {r.service ? `· ${r.service}` : ''}</div>)}
|
||||
{rows.length > 5 && <div className="text-zinc-400">…and {rows.length - 5} more</div>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="sticky bottom-0 bg-white/95 dark:bg-zinc-900/95 backdrop-blur border-t border-zinc-200 dark:border-white/10 px-5 py-3 flex justify-end gap-2">
|
||||
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
|
||||
<Btn variant="primary" icon={Upload} disabled={rows.length === 0} onClick={() => onImport(rows)}>Import {rows.length || ''} lead{rows.length === 1 ? '' : 's'}</Btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
/**
|
||||
* CreativeStudio — AI draft variants by platform, character-limit + compliance flags,
|
||||
* version history, and approve / block / publish (spec §4, §6, §8).
|
||||
*
|
||||
* Generation is the mock AI assistant (ai/creativeGenerator). Every variant — AI or
|
||||
* hand-typed — is scanned by the bannedClaims guardrail; blocked copy cannot be approved.
|
||||
*/
|
||||
import React, { useState, useMemo, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Wand2, Sparkles, CheckCircle2, AlertTriangle, Ban, ShieldCheck, Plus, ExternalLink, PenLine, X, Image, Upload, ImageOff, History, ChevronDown, Undo2 } from 'lucide-react';
|
||||
import { useSocialAds } from '../SocialAdsContext.jsx';
|
||||
import { PLATFORMS, getPlatform } from '../data/platforms.js';
|
||||
import { BLUEPRINTS } from '../data/blueprints.js';
|
||||
import { generateVariants, CHAR_LIMITS } from '../ai/creativeGenerator.js';
|
||||
import { scanCopy } from '../ai/bannedClaims.js';
|
||||
import { Card, StatusChip, SectionHeader, Btn, PlatformBadge } from '../components/ui.jsx';
|
||||
|
||||
const SERVICE_TYPES = [
|
||||
{ id: 'storm', label: 'Storm inspection' },
|
||||
{ id: 'replacement', label: 'Roof replacement' },
|
||||
{ id: 'emergency', label: 'Emergency leak' },
|
||||
{ id: 'commercial', label: 'Commercial bid' },
|
||||
{ id: 'referral', label: 'Review / referral' },
|
||||
{ id: 'maintenance', label: 'Maintenance' },
|
||||
];
|
||||
|
||||
const POLICY_META = {
|
||||
clear: { icon: CheckCircle2, color: 'text-emerald-500', label: 'Policy clear' },
|
||||
review: { icon: AlertTriangle, color: 'text-amber-500', label: 'Needs review' },
|
||||
blocked: { icon: Ban, color: 'text-red-500', label: 'Blocked' },
|
||||
};
|
||||
|
||||
export default function CreativeStudio() {
|
||||
const { creatives, campaigns, mediaAssets, setCreativeApproval, addCreative, updateCreative, revertCreative, addMediaAsset } = useSocialAds();
|
||||
const navigate = useNavigate();
|
||||
const fileRef = useRef(null);
|
||||
const [platform, setPlatform] = useState('meta');
|
||||
const [serviceType, setServiceType] = useState('storm');
|
||||
const [city, setCity] = useState('Plano');
|
||||
const [variants, setVariants] = useState([]);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [editor, setEditor] = useState(null); // null | { id, platform, headline, body, description }
|
||||
|
||||
const generate = () => {
|
||||
setGenerating(true);
|
||||
setTimeout(() => {
|
||||
setVariants(generateVariants({ platformId: platform, serviceType, city, count: 5 }));
|
||||
setGenerating(false);
|
||||
}, 650);
|
||||
};
|
||||
|
||||
const openNew = () => setEditor({ id: null, platform, headline: '', body: '', description: '', mediaAssetId: '' });
|
||||
const openEdit = (c) => setEditor({ id: c.id, platform: c.platform, headline: c.headline, body: c.body, description: c.description || '', mediaAssetId: c.mediaAssetId || '' });
|
||||
|
||||
const saveCreative = ({ id, platform: p, headline, body, description, mediaAssetId, policy }) => {
|
||||
const payload = { platform: p, headline, body, description, mediaAssetId: mediaAssetId || null, policyStatus: policy.status, policyFlags: policy.flags.map(f => f.message) };
|
||||
if (id) updateCreative(id, payload);
|
||||
else addCreative({ ...payload, aiGenerated: false });
|
||||
setEditor(null);
|
||||
};
|
||||
|
||||
const onUploadMedia = (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
// In-memory preview only — object URLs reset on reload (no localStorage/backend).
|
||||
const url = URL.createObjectURL(file);
|
||||
addMediaAsset({ name: file.name, kind: 'uploaded', url, gradient: ['#71717a', '#3f3f46'] });
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
// Resolve the landing page a creative's ad would link to: prefer its campaign's
|
||||
// blueprint, then the service type it was drafted for, then any blueprint that
|
||||
// runs on the creative's platform.
|
||||
const previewUrl = (c) => {
|
||||
const camp = c.campaignId && campaigns.find(x => x.id === c.campaignId);
|
||||
const bp = (camp && BLUEPRINTS.find(b => b.id === camp.blueprintId))
|
||||
|| (c.serviceType && BLUEPRINTS.find(b => b.serviceType === c.serviceType))
|
||||
|| BLUEPRINTS.find(b => b.bestChannels.includes(c.platform))
|
||||
|| BLUEPRINTS[0];
|
||||
// Pass the attached media + headline so the landing page previews the actual creative.
|
||||
const media = mediaAssets.find(m => m.id === c.mediaAssetId);
|
||||
const img = media?.url || media?.photo;
|
||||
const extra = (img ? `&img=${encodeURIComponent(img)}` : '') + (c.headline ? `&hl=${encodeURIComponent(c.headline)}` : '');
|
||||
return `/ads/lp?c=${bp.id}&src=${c.platform}${extra}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SectionHeader icon={Wand2} title="Creative Studio" description="AI drafts platform-aware ad copy from approved tenant knowledge. All output requires human approval before publish.">
|
||||
<Btn variant="outline" icon={PenLine} onClick={openNew}>Write your own</Btn>
|
||||
<StatusChip status="review" label={`${creatives.filter(c => c.approvalStatus === 'pending').length} pending`} />
|
||||
</SectionHeader>
|
||||
|
||||
{editor && (
|
||||
<CreativeEditor key={editor.id || 'new'} initial={editor} mediaAssets={mediaAssets} onCancel={() => setEditor(null)} onSave={saveCreative} />
|
||||
)}
|
||||
|
||||
{/* Media library (spec §8 / §7 media_asset_id) */}
|
||||
<Card title="Media library" icon={Image} subtitle="Reusable image assets to attach to creatives."
|
||||
action={<><input ref={fileRef} type="file" accept="image/*" className="hidden" onChange={onUploadMedia} /><Btn size="sm" variant="outline" icon={Upload} onClick={() => fileRef.current?.click()}>Upload</Btn></>}>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-3">
|
||||
{mediaAssets.map(a => <MediaThumb key={a.id} asset={a} />)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Generator panel */}
|
||||
<Card title="AI Creative Assistant" icon={Sparkles} subtitle="Generate 5 variants with platform tone + character limits.">
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-4 gap-3 mb-4">
|
||||
<label className="block sm:col-span-1">
|
||||
<span className="text-xs font-semibold text-zinc-600 dark:text-zinc-300 mb-1.5 block">Platform</span>
|
||||
<select value={platform} onChange={e => setPlatform(e.target.value)} className={inputCls}>
|
||||
{PLATFORMS.map(p => <option key={p.id} value={p.id}>{p.short}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block sm:col-span-2">
|
||||
<span className="text-xs font-semibold text-zinc-600 dark:text-zinc-300 mb-1.5 block">Campaign type</span>
|
||||
<select value={serviceType} onChange={e => setServiceType(e.target.value)} className={inputCls}>
|
||||
{SERVICE_TYPES.map(s => <option key={s.id} value={s.id}>{s.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-semibold text-zinc-600 dark:text-zinc-300 mb-1.5 block">City merge</span>
|
||||
<input value={city} onChange={e => setCity(e.target.value)} className={inputCls} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-[11px] text-zinc-400 flex items-center gap-1">
|
||||
<ShieldCheck size={12} /> {getPlatform(platform)?.copyGuidance}
|
||||
</p>
|
||||
<Btn icon={Sparkles} onClick={generate} disabled={generating}>{generating ? 'Drafting…' : 'Generate variants'}</Btn>
|
||||
</div>
|
||||
|
||||
{variants.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 mt-4">
|
||||
{variants.map((v, i) => {
|
||||
const pm = POLICY_META[v.policy.status];
|
||||
const PIcon = pm.icon;
|
||||
return (
|
||||
<div key={i} className="rounded-xl border border-zinc-200 dark:border-white/10 p-3 flex flex-col">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wide text-zinc-400">Variant {i + 1}</span>
|
||||
<span className={`inline-flex items-center gap-1 text-[10px] font-bold ${pm.color}`}><PIcon size={12} /> {pm.label}</span>
|
||||
</div>
|
||||
<p className="font-bold text-sm text-zinc-900 dark:text-white">{v.headline}</p>
|
||||
<p className="text-xs text-zinc-600 dark:text-zinc-300 mt-1 flex-1">{v.body}</p>
|
||||
{v.description && <p className="text-[11px] text-zinc-400 mt-1 italic">{v.description}</p>}
|
||||
<div className="text-[10px] text-zinc-400 mt-2 flex flex-wrap gap-x-3">
|
||||
<span>H {v.charCounts.headline.used}/{v.charCounts.headline.limit}</span>
|
||||
<span>B {v.charCounts.body.used}/{v.charCounts.body.limit}</span>
|
||||
</div>
|
||||
{v.policy.flags.length > 0 && (
|
||||
<ul className="mt-2 space-y-0.5">
|
||||
{v.policy.flags.map(f => (
|
||||
<li key={f.id} className="text-[10px] text-amber-600 dark:text-amber-400/80 flex items-start gap-1"><AlertTriangle size={10} className="mt-0.5 shrink-0" /> {f.message}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<Btn size="sm" variant="outline" icon={Plus} className="mt-3"
|
||||
disabled={v.policy.status === 'blocked'}
|
||||
onClick={() => addCreative({ platform, serviceType, headline: v.headline, body: v.body, description: v.description, aiGenerated: true, policyStatus: v.policy.status, policyFlags: v.policy.flags.map(f => f.message) })}>
|
||||
Save to library
|
||||
</Btn>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Creative library */}
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-zinc-700 dark:text-zinc-300 mb-3">Creative library</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{creatives.map(c => {
|
||||
const pm = POLICY_META[c.policyStatus] || POLICY_META.review;
|
||||
const PIcon = pm.icon;
|
||||
const media = mediaAssets.find(m => m.id === c.mediaAssetId);
|
||||
return (
|
||||
<Card key={c.id} className="!h-auto" pad="p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<PlatformBadge platformId={c.platform} size="sm" />
|
||||
{c.aiGenerated && <span className="inline-flex items-center gap-0.5 text-[10px] font-bold text-purple-500"><Sparkles size={10} /> AI</span>}
|
||||
</div>
|
||||
<StatusChip status={c.approvalStatus} />
|
||||
</div>
|
||||
{media && (
|
||||
<div className="mb-2">
|
||||
<MediaThumb asset={media} />
|
||||
</div>
|
||||
)}
|
||||
<p className="font-bold text-sm text-zinc-900 dark:text-white">{c.headline}</p>
|
||||
<p className="text-xs text-zinc-600 dark:text-zinc-300 mt-1">{c.body}</p>
|
||||
{c.description && <p className="text-[11px] text-zinc-400 mt-1 italic">{c.description}</p>}
|
||||
|
||||
<div className={`mt-2 inline-flex items-center gap-1 text-[11px] font-semibold ${pm.color}`}><PIcon size={13} /> {pm.label}</div>
|
||||
{c.policyFlags?.length > 0 && (
|
||||
<ul className="mt-1 space-y-0.5">
|
||||
{c.policyFlags.map((f, i) => <li key={i} className="text-[10px] text-amber-600 dark:text-amber-400/80 flex items-start gap-1"><AlertTriangle size={10} className="mt-0.5 shrink-0" />{f}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 mt-3 pt-3 border-t border-zinc-100 dark:border-white/5">
|
||||
<Btn size="sm" variant="success" icon={CheckCircle2} disabled={c.policyStatus === 'blocked' || c.approvalStatus === 'approved'} onClick={() => setCreativeApproval(c.id, 'approved')}>Approve</Btn>
|
||||
<Btn size="sm" variant="danger" icon={Ban} onClick={() => setCreativeApproval(c.id, 'blocked')}>Block</Btn>
|
||||
<Btn size="sm" variant="ghost" icon={PenLine} onClick={() => openEdit(c)}>Edit</Btn>
|
||||
<Btn size="sm" variant="ghost" icon={ExternalLink} className="ml-auto"
|
||||
title="Open the public lead-capture page this creative's ad links to"
|
||||
onClick={() => navigate(previewUrl(c))}>
|
||||
Preview form
|
||||
</Btn>
|
||||
{c.policyStatus === 'blocked' && <span className="text-[10px] text-red-500 font-semibold">Cannot publish until resolved</span>}
|
||||
</div>
|
||||
|
||||
<VersionHistory creative={c} mediaAssets={mediaAssets} onRevert={revertCreative} />
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const inputCls = 'w-full rounded-xl border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 px-3 py-2 text-base sm:text-sm text-zinc-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-amber-500';
|
||||
|
||||
function CharCount({ used, limit }) {
|
||||
if (!limit) return null;
|
||||
const over = used > limit;
|
||||
const near = !over && used > limit * 0.85;
|
||||
return <span className={`text-[10px] font-mono ${over ? 'text-red-500 font-bold' : near ? 'text-amber-500' : 'text-zinc-400'}`}>{used}/{limit}</span>;
|
||||
}
|
||||
|
||||
// Media tile — shows the asset's photo (uploaded object-URL or a topic-matched image)
|
||||
// over its gradient, which stays as a graceful fallback if the image can't load.
|
||||
function MediaThumb({ asset, selected, onClick }) {
|
||||
const [err, setErr] = useState(false);
|
||||
const src = asset.url || asset.photo;
|
||||
const showImg = src && !err;
|
||||
const tile = (
|
||||
<>
|
||||
<div className={`relative w-full h-20 rounded-lg overflow-hidden border ${selected ? 'border-amber-500 ring-2 ring-amber-500/40' : 'border-zinc-200 dark:border-white/10'}`}
|
||||
style={{ background: `linear-gradient(135deg, ${asset.gradient?.[0] || '#71717a'}, ${asset.gradient?.[1] || '#3f3f46'})` }}>
|
||||
{showImg
|
||||
? <img src={src} alt={asset.name} loading="lazy" onError={() => setErr(true)} className="w-full h-full object-cover" />
|
||||
: <div className="w-full h-full flex items-center justify-center"><Image size={20} className="text-white/80" /></div>}
|
||||
{selected && <span className="absolute top-1 right-1 bg-amber-500 text-white rounded-full p-0.5"><CheckCircle2 size={12} /></span>}
|
||||
</div>
|
||||
<p className="text-[10px] text-zinc-500 dark:text-zinc-400 truncate mt-1">{asset.name}</p>
|
||||
</>
|
||||
);
|
||||
return onClick
|
||||
? <button type="button" onClick={onClick} className="block text-left w-full">{tile}</button>
|
||||
: <div className="w-full">{tile}</div>;
|
||||
}
|
||||
|
||||
const fmtWhen = (iso) => {
|
||||
if (!iso) return '';
|
||||
try { return new Date(iso).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }); }
|
||||
catch { return iso; }
|
||||
};
|
||||
|
||||
// Version history (spec §7/§8) — prior copy versions with one-click revert.
|
||||
function VersionHistory({ creative, mediaAssets = [], onRevert }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const versions = creative.versions || [];
|
||||
if (versions.length === 0) return null;
|
||||
return (
|
||||
<div className="mt-3 pt-3 border-t border-zinc-100 dark:border-white/5">
|
||||
<button onClick={() => setOpen(o => !o)}
|
||||
className="flex items-center gap-1 text-[11px] font-semibold text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-200 transition-colors">
|
||||
<History size={12} /> Version history ({versions.length})
|
||||
<ChevronDown size={13} className={`transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
{open && (
|
||||
<ul className="mt-2 space-y-1.5">
|
||||
{versions.slice().reverse().map(ver => {
|
||||
const pm = POLICY_META[ver.policyStatus] || POLICY_META.review;
|
||||
const media = mediaAssets.find(m => m.id === ver.mediaAssetId);
|
||||
return (
|
||||
<li key={ver.v} className="flex items-start justify-between gap-2 rounded-lg bg-zinc-50 dark:bg-white/5 px-2.5 py-1.5">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[11px] font-semibold text-zinc-700 dark:text-zinc-200 truncate">v{ver.v} · {ver.headline}</p>
|
||||
<p className="text-[10px] text-zinc-400 truncate">{ver.body}</p>
|
||||
<p className="text-[10px] text-zinc-400">{fmtWhen(ver.savedAt)} · <span className={pm.color}>{pm.label}</span>{media ? ` · ${media.name}` : ''}</p>
|
||||
</div>
|
||||
<Btn size="sm" variant="ghost" icon={Undo2} onClick={() => onRevert(creative.id, ver.v)}>Revert</Btn>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Manual create/edit (spec §8/B2 "manual edit") — platform-aware char limits + a live
|
||||
// compliance scan so hand-written copy is held to the same guardrail as AI drafts.
|
||||
function CreativeEditor({ initial, mediaAssets = [], onCancel, onSave }) {
|
||||
const [platform, setPlatform] = useState(initial.platform);
|
||||
const [headline, setHeadline] = useState(initial.headline);
|
||||
const [body, setBody] = useState(initial.body);
|
||||
const [description, setDescription] = useState(initial.description);
|
||||
const [mediaAssetId, setMediaAssetId] = useState(initial.mediaAssetId || '');
|
||||
|
||||
const limits = CHAR_LIMITS[platform] || CHAR_LIMITS.meta;
|
||||
const policy = useMemo(() => scanCopy(headline, body, description), [headline, body, description]);
|
||||
const pm = POLICY_META[policy.status];
|
||||
const PIcon = pm.icon;
|
||||
const over = (limits.headline && headline.length > limits.headline)
|
||||
|| (limits.body && body.length > limits.body)
|
||||
|| (limits.description && description.length > limits.description);
|
||||
const valid = headline.trim() && body.trim() && policy.status !== 'blocked' && !over;
|
||||
|
||||
const labelCls = 'text-xs font-semibold text-zinc-600 dark:text-zinc-300 mb-1.5 flex items-center justify-between gap-2';
|
||||
|
||||
return (
|
||||
<Card title={initial.id ? 'Edit creative' : 'Write your own creative'} icon={PenLine}
|
||||
subtitle="Platform-aware character limits + live compliance scan. Editing resets approval to pending."
|
||||
action={<button onClick={onCancel} className="p-1 rounded-lg text-zinc-400 hover:bg-zinc-100 dark:hover:bg-white/10"><X size={18} /></button>}>
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-[160px_1fr] gap-3">
|
||||
<label className="block">
|
||||
<span className="text-xs font-semibold text-zinc-600 dark:text-zinc-300 mb-1.5 block">Platform</span>
|
||||
<select value={platform} onChange={e => setPlatform(e.target.value)} className={inputCls}>
|
||||
{PLATFORMS.map(p => <option key={p.id} value={p.id}>{p.short}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className={labelCls}>Headline <CharCount used={headline.length} limit={limits.headline} /></span>
|
||||
<input value={headline} onChange={e => setHeadline(e.target.value)} className={inputCls} placeholder="Short, benefit-led headline" />
|
||||
</label>
|
||||
</div>
|
||||
<label className="block">
|
||||
<span className={labelCls}>Body <CharCount used={body.length} limit={limits.body} /></span>
|
||||
<textarea rows={3} value={body} onChange={e => setBody(e.target.value)} className={`${inputCls} resize-none`} placeholder="Explain the offer clearly — no exaggerated or unverified claims." />
|
||||
</label>
|
||||
{limits.description > 0 && (
|
||||
<label className="block">
|
||||
<span className={labelCls}>Description <CharCount used={description.length} limit={limits.description} /></span>
|
||||
<input value={description} onChange={e => setDescription(e.target.value)} className={inputCls} placeholder="Optional supporting line" />
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* Media attach (spec §7 media_asset_id) */}
|
||||
<div>
|
||||
<span className="text-xs font-semibold text-zinc-600 dark:text-zinc-300 mb-1.5 block">Media <span className="font-normal text-zinc-400">· optional</span></span>
|
||||
<div className="grid grid-cols-3 sm:grid-cols-5 gap-2">
|
||||
<button type="button" onClick={() => setMediaAssetId('')} className="block text-left w-full">
|
||||
<div className={`h-20 rounded-lg border flex items-center justify-center ${!mediaAssetId ? 'border-amber-500 ring-2 ring-amber-500/40 text-amber-500' : 'border-zinc-200 dark:border-white/10 text-zinc-400'}`}>
|
||||
<ImageOff size={18} />
|
||||
</div>
|
||||
<p className="text-[10px] text-zinc-500 dark:text-zinc-400 truncate mt-1">No media</p>
|
||||
</button>
|
||||
{mediaAssets.map(a => (
|
||||
<MediaThumb key={a.id} asset={a} selected={mediaAssetId === a.id} onClick={() => setMediaAssetId(a.id)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`flex items-start gap-1.5 text-xs font-semibold ${pm.color}`}>
|
||||
<PIcon size={14} className="mt-0.5 shrink-0" /> {pm.label}
|
||||
</div>
|
||||
{policy.flags.length > 0 && (
|
||||
<ul className="space-y-0.5 -mt-1">
|
||||
{policy.flags.map(f => <li key={f.id} className="text-[11px] text-amber-600 dark:text-amber-400/80 flex items-start gap-1"><AlertTriangle size={11} className="mt-0.5 shrink-0" /> {f.message}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center justify-end gap-2 pt-3 border-t border-zinc-100 dark:border-white/5">
|
||||
{policy.status === 'blocked' && <span className="text-[11px] text-red-500 font-semibold mr-auto">Resolve blocked claims before saving</span>}
|
||||
{over && policy.status !== 'blocked' && <span className="text-[11px] text-red-500 font-semibold mr-auto">Trim copy under the platform limit</span>}
|
||||
<Btn variant="ghost" onClick={onCancel}>Cancel</Btn>
|
||||
<Btn icon={CheckCircle2} disabled={!valid}
|
||||
onClick={() => onSave({ id: initial.id, platform, headline: headline.trim(), body: body.trim(), description: description.trim(), mediaAssetId, policy })}>
|
||||
{initial.id ? 'Save changes' : 'Save to library'}
|
||||
</Btn>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* LeadFormMapper — editable builder for platform field → CRM field mapping, custom
|
||||
* fields, required flags, transform + validation rules, consent text, privacy URL, the
|
||||
* thank-you CTA, and live/draft status (spec §4, §7 lead_forms + field_mappings, §8).
|
||||
*
|
||||
* Edits write straight to the module state; consent/thank-you/mapping are no longer a
|
||||
* static display. A test lead would land in the CRM fields shown in the target column.
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ClipboardList, ArrowRight, Lock, ShieldCheck, FileText, CheckCircle2,
|
||||
ExternalLink, Plus, Trash2, Power, Sparkles, AlertTriangle, X, FlaskConical,
|
||||
} from 'lucide-react';
|
||||
import { useSocialAds } from '../SocialAdsContext.jsx';
|
||||
import { BLUEPRINTS, BLUEPRINT_BY_ID } from '../data/blueprints.js';
|
||||
import { suggestQuestions, SENSITIVE_EXCLUDED } from '../ai/questionOptimizer.js';
|
||||
import { runTestLead } from '../data/testLead.js';
|
||||
import { Card, SectionHeader, PlatformBadge, StatusChip, Btn } from '../components/ui.jsx';
|
||||
|
||||
const TRANSFORMS = ['none', 'titlecase', 'lowercase', 'e164', 'bool', 'int', 'date'];
|
||||
const inputCls = 'w-full rounded-lg border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 px-2.5 py-1.5 text-base sm:text-sm text-zinc-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-amber-500';
|
||||
|
||||
export default function LeadFormMapper() {
|
||||
const {
|
||||
leadForms, campaigns, updateLeadForm, updateFormField,
|
||||
addFormField, removeFormField, toggleFormStatus, addLeadForm,
|
||||
} = useSocialAds();
|
||||
const navigate = useNavigate();
|
||||
const [activeId, setActiveId] = useState(leadForms[0]?.id);
|
||||
const [suggestions, setSuggestions] = useState(null);
|
||||
const [test, setTest] = useState(null);
|
||||
|
||||
const form = leadForms.find(f => f.id === activeId) || leadForms[0];
|
||||
|
||||
// The public preview mirrors what a lead sees: the form's campaign decides the
|
||||
// blueprint headline/offer (?c); the form's platform sets the ad source (?src).
|
||||
const campaign = campaigns.find(c => c.id === form?.campaignId);
|
||||
const blueprintId = campaign?.blueprintId || BLUEPRINTS[0].id;
|
||||
const previewUrl = `/ads/lp?c=${blueprintId}&src=${form?.platform}`;
|
||||
const serviceType = campaign?.serviceType || BLUEPRINT_BY_ID[blueprintId]?.serviceType || 'storm';
|
||||
|
||||
const selectForm = (id) => { setActiveId(id); setSuggestions(null); setTest(null); };
|
||||
const runTest = () => setTest(runTestLead(form));
|
||||
const createForm = () => { const f = addLeadForm(); selectForm(f.id); };
|
||||
const optimize = () => setSuggestions(suggestQuestions(form, serviceType));
|
||||
const addSuggestion = (q) => {
|
||||
addFormField(form.id, { source: q.source, target: q.target, transform: q.transform, required: false, validation: q.validation });
|
||||
setSuggestions(prev => prev.filter(s => s.source !== q.source));
|
||||
};
|
||||
const addAllSuggestions = () => { suggestions.forEach(addSuggestion); setSuggestions([]); };
|
||||
|
||||
if (!form) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SectionHeader icon={ClipboardList} title="Lead Form Mapper" description="Map platform fields to CRM fields, edit consent + thank-you, and set live/draft status.">
|
||||
<Btn variant="outline" icon={Plus} onClick={createForm}>New form</Btn>
|
||||
<Btn variant="outline" icon={ExternalLink} onClick={() => navigate(previewUrl)} title="Open the public lead-capture page for the selected form">
|
||||
Preview
|
||||
</Btn>
|
||||
</SectionHeader>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-4">
|
||||
{/* Form list */}
|
||||
<div className="lg:col-span-1 space-y-2">
|
||||
{leadForms.map(f => (
|
||||
<button key={f.id} onClick={() => selectForm(f.id)}
|
||||
className={`w-full text-left p-3 rounded-xl border-2 transition-all ${f.id === activeId ? 'border-amber-500 bg-amber-500/5' : 'border-zinc-200 dark:border-white/10 hover:border-amber-300'}`}>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<PlatformBadge platformId={f.platform} size="sm" showName={false} />
|
||||
<StatusChip status={f.status === 'live' ? 'active' : 'draft'} label={f.status} />
|
||||
</div>
|
||||
<p className="font-bold text-sm text-zinc-900 dark:text-white truncate">{f.title}</p>
|
||||
<p className="text-[11px] text-zinc-400">{f.fields.length} fields mapped</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Editable detail */}
|
||||
<div className="lg:col-span-3 space-y-4">
|
||||
<Card icon={ClipboardList} title={form.title}
|
||||
subtitle={`Platform form ID: ${form.platformFormId}`}
|
||||
action={
|
||||
<button onClick={() => toggleFormStatus(form.id)} title="Toggle live / draft"
|
||||
className={`shrink-0 inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs font-semibold transition-colors ${form.status === 'live' ? 'text-emerald-600 dark:text-emerald-400 bg-emerald-500/10' : 'text-zinc-500 bg-zinc-100 dark:bg-white/5'}`}>
|
||||
<Power size={13} /> {form.status === 'live' ? 'Live' : 'Draft'}
|
||||
</button>
|
||||
}>
|
||||
{/* Title + platform */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-[1fr_auto] gap-2 items-end mb-4">
|
||||
<label className="block">
|
||||
<span className="text-[11px] font-semibold text-zinc-500 mb-1 block">Form title</span>
|
||||
<input value={form.title} onChange={e => updateLeadForm(form.id, { title: e.target.value })} className={inputCls} />
|
||||
</label>
|
||||
<span className="text-[11px] text-zinc-400 pb-2">Maps to: name, phone, email, address, role, urgency, consent</span>
|
||||
</div>
|
||||
|
||||
{/* AI question optimizer (spec §6) */}
|
||||
<div className="rounded-xl border border-purple-200 dark:border-purple-500/20 bg-purple-50/60 dark:bg-purple-500/5 p-3 mb-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Sparkles size={15} className="text-purple-500 shrink-0" />
|
||||
<p className="text-xs text-zinc-600 dark:text-zinc-300">Suggest 2–4 qualifying questions to raise lead quality.</p>
|
||||
</div>
|
||||
<Btn size="sm" variant="outline" icon={Sparkles} onClick={optimize}>Optimize questions</Btn>
|
||||
</div>
|
||||
|
||||
{form.fields.length >= 6 && (
|
||||
<p className="text-[11px] text-amber-600 dark:text-amber-400/80 mt-2 flex items-start gap-1">
|
||||
<AlertTriangle size={12} className="mt-0.5 shrink-0" /> This form already has {form.fields.length} fields — keep it low-friction; add only what improves routing.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{suggestions && (
|
||||
<div className="mt-3">
|
||||
{suggestions.length === 0 ? (
|
||||
<p className="text-[11px] text-zinc-500">No new qualifying questions — this form is well-covered.</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-purple-500">{suggestions.length} suggested</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Btn size="sm" variant="ghost" icon={Plus} onClick={addAllSuggestions}>Add all</Btn>
|
||||
<button onClick={() => setSuggestions(null)} className="p-1 rounded-lg text-zinc-400 hover:bg-zinc-100 dark:hover:bg-white/10"><X size={15} /></button>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="space-y-1.5">
|
||||
{suggestions.map(q => (
|
||||
<li key={q.source} className="flex items-start justify-between gap-2 rounded-lg bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-white/10 px-2.5 py-1.5">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold text-zinc-800 dark:text-zinc-100">{q.label}</p>
|
||||
<p className="text-[10px] text-zinc-400 truncate">→ <span className="font-mono text-blue-600 dark:text-blue-400">{q.target}</span> · {q.rationale}</p>
|
||||
</div>
|
||||
<Btn size="sm" variant="outline" icon={Plus} onClick={() => addSuggestion(q)}>Add</Btn>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
<p className="text-[10px] text-zinc-400 mt-2 flex items-start gap-1">
|
||||
<ShieldCheck size={11} className="mt-0.5 shrink-0 text-emerald-500" /> Guardrail: never requests sensitive data ({SENSITIVE_EXCLUDED.join(', ')}); capped at 4 to avoid over-friction.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Field mappings */}
|
||||
<div className="space-y-2">
|
||||
{form.fields.map(f => (
|
||||
<FieldEditor key={f.source} field={f}
|
||||
onChange={patch => updateFormField(form.id, f.source, patch)}
|
||||
onRemove={() => removeFormField(form.id, f.source)} />
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 mt-3">
|
||||
<Btn size="sm" variant="ghost" icon={Plus} onClick={() => addFormField(form.id)}>Add field</Btn>
|
||||
<Btn size="sm" variant="outline" icon={FlaskConical} onClick={runTest}>Send test lead</Btn>
|
||||
</div>
|
||||
|
||||
{test && <TestLeadResult result={test} onClose={() => setTest(null)} onRerun={runTest} />}
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Card title="Consent text" icon={Lock} subtitle="Edited consent should be re-reviewed by Compliance">
|
||||
<textarea rows={4} value={form.consentText} onChange={e => updateLeadForm(form.id, { consentText: e.target.value })}
|
||||
className={`${inputCls} resize-none`} />
|
||||
<label className="block mt-2">
|
||||
<span className="text-[11px] font-semibold text-zinc-500 mb-1 flex items-center gap-1"><ShieldCheck size={12} /> Privacy policy URL</span>
|
||||
<input value={form.privacyPolicyUrl} onChange={e => updateLeadForm(form.id, { privacyPolicyUrl: e.target.value })} className={inputCls} />
|
||||
</label>
|
||||
</Card>
|
||||
|
||||
<Card title="Thank-you screen" icon={FileText} subtitle="Shown after submission">
|
||||
<textarea rows={4} value={form.thankYou} onChange={e => updateLeadForm(form.id, { thankYou: e.target.value })}
|
||||
className={`${inputCls} resize-none`} />
|
||||
<p className="text-[11px] text-zinc-400 mt-2">Consent proof (text, URL, timestamp, channel) is stored with every submission before any marketing automation runs.</p>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldEditor({ field, onChange, onRemove }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-zinc-200 dark:border-white/10 p-3">
|
||||
<div className="flex items-center justify-between gap-2 mb-2">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-zinc-400">Field mapping</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button onClick={() => onChange({ required: !field.required })}
|
||||
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[10px] font-bold ${field.required ? 'text-emerald-600 dark:text-emerald-400 bg-emerald-500/10' : 'text-zinc-400 bg-zinc-100 dark:bg-white/5'}`}>
|
||||
<CheckCircle2 size={11} /> {field.required ? 'Required' : 'Optional'}
|
||||
</button>
|
||||
<button onClick={onRemove} title="Remove field" className="text-zinc-400 hover:text-red-500 p-1"><Trash2 size={14} /></button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<Labeled label="Platform field">
|
||||
<input value={field.source} onChange={e => onChange({ source: e.target.value })} className={`${inputCls} font-mono`} placeholder="e.g. full_name" />
|
||||
</Labeled>
|
||||
<Labeled label="CRM target" icon={ArrowRight}>
|
||||
<input value={field.target} onChange={e => onChange({ target: e.target.value })} className={`${inputCls} font-mono`} placeholder="e.g. contact.name" />
|
||||
</Labeled>
|
||||
<Labeled label="Transform">
|
||||
<select value={field.transform || 'none'} onChange={e => onChange({ transform: e.target.value })} className={inputCls}>
|
||||
{TRANSFORMS.map(t => <option key={t} value={t}>{t}</option>)}
|
||||
</select>
|
||||
</Labeled>
|
||||
<Labeled label="Validation rule">
|
||||
<input value={field.validation || ''} onChange={e => onChange({ validation: e.target.value })} className={inputCls} placeholder="e.g. email, phone, required" />
|
||||
</Labeled>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Labeled({ label, icon: Icon, children }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="text-[11px] font-semibold text-zinc-500 mb-1 flex items-center gap-1">
|
||||
{Icon && <Icon size={11} className="text-amber-500" />} {label}
|
||||
</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
// Test-lead → CRM mapping preview (spec phased C "Test lead maps correctly into CRM fields").
|
||||
function TestLeadResult({ result, onClose, onRerun }) {
|
||||
return (
|
||||
<div className="mt-4 rounded-xl border border-zinc-200 dark:border-white/10 bg-zinc-50 dark:bg-white/5 p-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<FlaskConical size={15} className="text-amber-500" />
|
||||
<span className="text-xs font-bold text-zinc-700 dark:text-zinc-200">Test lead → CRM mapping</span>
|
||||
{result.ok
|
||||
? <StatusChip status="active" label="Maps correctly" />
|
||||
: <StatusChip status="blocked" label={`${result.issues} issue${result.issues > 1 ? 's' : ''}`} />}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Btn size="sm" variant="ghost" icon={FlaskConical} onClick={onRerun}>Re-run</Btn>
|
||||
<button onClick={onClose} className="p-1 rounded-lg text-zinc-400 hover:bg-zinc-100 dark:hover:bg-white/10"><X size={15} /></button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{result.rows.map(r => (
|
||||
<div key={r.source} className={`rounded-lg px-2.5 py-2 border ${r.valid ? 'border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900' : 'border-red-200 dark:border-red-500/30 bg-red-50 dark:bg-red-500/10'}`}>
|
||||
<div className="flex items-start gap-2">
|
||||
{r.valid
|
||||
? <CheckCircle2 size={14} className="text-emerald-500 shrink-0 mt-0.5" />
|
||||
: <AlertTriangle size={14} className="text-red-500 shrink-0 mt-0.5" />}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-1.5 text-[11px]">
|
||||
<code className="font-mono text-zinc-500 dark:text-zinc-400">{r.source}</code>
|
||||
<span className="text-zinc-400">“{r.raw}”</span>
|
||||
{r.transform !== 'none' && <span className="text-[10px] px-1 py-0.5 rounded bg-zinc-100 dark:bg-white/10 text-zinc-500">{r.transform}</span>}
|
||||
<ArrowRight size={11} className="text-amber-500" />
|
||||
<code className="font-mono text-blue-600 dark:text-blue-400">{r.target}</code>
|
||||
<span className="font-semibold text-zinc-700 dark:text-zinc-200">= {r.value || '∅'}</span>
|
||||
{r.required && <span className="text-[9px] uppercase font-bold text-zinc-400">req</span>}
|
||||
</div>
|
||||
{!r.valid && <p className="text-[10px] text-red-500 mt-0.5">{r.issue}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[10px] text-zinc-400 mt-2">Simulated payload — a real webhook lead would resolve into these CRM fields before consent + triage.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user