From 2043ac897ced72b014d69d901950165b94a40fb9 Mon Sep 17 00:00:00 2001 From: Iliyan Angelov Date: Tue, 18 Nov 2025 23:35:19 +0200 Subject: [PATCH] updates --- ...add_badges_to_page_content.cpython-312.pyc | Bin 0 -> 1057 bytes ...dd_map_url_to_page_content.cpython-312.pyc | Bin 1072 -> 1072 bytes .../versions/add_badges_to_page_content.py | 26 + .../__pycache__/page_content.cpython-312.pyc | Bin 2600 -> 2640 bytes Backend/src/models/page_content.py | 1 + .../page_content_routes.cpython-312.pyc | Bin 16205 -> 16989 bytes .../system_settings_routes.cpython-312.pyc | Bin 11216 -> 41337 bytes Backend/src/routes/page_content_routes.py | 20 +- Backend/src/routes/system_settings_routes.py | 789 +++++++++++- .../email_templates.cpython-312.pyc | Bin 13015 -> 29773 bytes .../utils/__pycache__/mailer.cpython-312.pyc | Bin 4311 -> 6626 bytes Backend/src/utils/email_templates.py | 401 +++++-- Backend/src/utils/mailer.py | 92 +- Frontend/src/App.tsx | 3 + Frontend/src/components/layout/Footer.tsx | 135 ++- Frontend/src/components/layout/Header.tsx | 54 +- .../src/components/rooms/RoomAmenities.tsx | 2 +- Frontend/src/components/rooms/RoomFilter.tsx | 2 +- .../src/contexts/CompanySettingsContext.tsx | 133 ++ Frontend/src/pages/AboutPage.tsx | 19 +- Frontend/src/pages/ContactPage.tsx | 26 +- .../src/pages/admin/PageContentDashboard.tsx | 235 ++-- Frontend/src/pages/admin/SettingsPage.tsx | 1066 ++++++++++++++++- .../src/pages/auth/ForgotPasswordPage.tsx | 30 +- .../src/pages/customer/PaymentResultPage.tsx | 28 +- .../src/services/api/pageContentService.ts | 7 + .../src/services/api/systemSettingsService.ts | 201 ++++ 27 files changed, 2947 insertions(+), 323 deletions(-) create mode 100644 Backend/alembic/versions/__pycache__/add_badges_to_page_content.cpython-312.pyc create mode 100644 Backend/alembic/versions/add_badges_to_page_content.py create mode 100644 Frontend/src/contexts/CompanySettingsContext.tsx diff --git a/Backend/alembic/versions/__pycache__/add_badges_to_page_content.cpython-312.pyc b/Backend/alembic/versions/__pycache__/add_badges_to_page_content.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1c38df1e32d85e9ca03ba84505b6dd7af4c3e933 GIT binary patch literal 1057 zcmah{&ui2`6wV~sY?^j!D}{onEc9Xy+oZL%bSVmL5B1PROA*N>WHaOLw%JUaWZSN% zEec+H>s3KesTWc3Ul2SA7Rq?67jKo-OHaPZ?%GJ5|$hZBP<7~E;}+3z7i;oid83rGmeH8Ckvuu9cxcirw?ovtkG1= zzDJKejNP(_DPB9LxIPUN5+;VRLN;n~jfVN#i^cpu7N+Y+T+I7EnYl4V9?W>t zwz0s7myrA-sA7K7o}4b&6NQPX{DfV!#W!xJFpTe_8xwJvMkRjf{5pK#^!a|uL09Qu z6_9od)UK2uFe%o0Wc7Z4#VE=2!ZoeuomecN%8QfFV6Piv<&C;0ssi@9b&%AQ%uI>r zPBR&EtwZrDbg74pAn?ioF*8hT=7#9lr$M70ayj<6w$Sm_RkD@5mfGU@<9k+>)`?XK zx2#1HuO~FJmS{qP!aSwxwXkB%d;U5JvE>D%UatAp24P}!v2{M5<581m8&QRMn6P1} zauFmR1J#ngsoK-!?d2Wo#pKJ`=d-Wwzh@sd-)_EZew_L;c4uF`%Pzn=ALI{I@I*sx z8`S@2faSk9Q%Wog`dFrJliF!JBRpT;01n_)SIkdl+aoC{qvkC=AMLQ z=7oOMg)x#K<_0DaP#?QAOlf4|LTB!Ktk2acZUp2ey8>D9KI7}4jueE@4_QO%uR#fo lew9X#G-=>kOFtO8+R7aajeyDxw~V$XqZRbikkNSB(Qk2o1VjJ; literal 0 HcmV?d00001 diff --git a/Backend/alembic/versions/__pycache__/cce764ef7a50_add_map_url_to_page_content.cpython-312.pyc b/Backend/alembic/versions/__pycache__/cce764ef7a50_add_map_url_to_page_content.cpython-312.pyc index dca8999b3af1a524d031b3f6cb311392b7f36259..0f094e2de30dcc0ebfa265ce45d88588204fd140 100644 GIT binary patch delta 18 YcmdnMv4Mm0G%qg~0}wpi$f?c(04!t#F8}}l delta 18 YcmdnMv4Mm0G%qg~0}!0v$f?c(04w$c82|tP diff --git a/Backend/alembic/versions/add_badges_to_page_content.py b/Backend/alembic/versions/add_badges_to_page_content.py new file mode 100644 index 00000000..54f7cb0c --- /dev/null +++ b/Backend/alembic/versions/add_badges_to_page_content.py @@ -0,0 +1,26 @@ +"""add_badges_to_page_content + +Revision ID: add_badges_to_page_content +Revises: cce764ef7a50 +Create Date: 2025-01-14 10:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = 'add_badges_to_page_content' +down_revision = 'cce764ef7a50' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Add badges column to page_contents table + op.add_column('page_contents', sa.Column('badges', sa.Text(), nullable=True)) + + +def downgrade() -> None: + # Remove badges column from page_contents table + op.drop_column('page_contents', 'badges') + diff --git a/Backend/src/models/__pycache__/page_content.cpython-312.pyc b/Backend/src/models/__pycache__/page_content.cpython-312.pyc index 6ba79360df11dd88c50ece75db8dd6934511450f..ce674008010f9c01b2439caaa392036e5aa8c366 100644 GIT binary patch delta 217 zcmZ1>azTXeG%qg~0}xz%ER!kAxsflEiHVJQb0yO)My6EW$%!oDlRKEjCik(ZPEKZ) zo_vT!PoFo1X$|{oCXgluhA2fQhE$eRr4*(VmNlHKnW3V}U{RG+)hwUM+^q77>IgnW z1u_X%<;^P3s4+R4wTn@6^EXy5MmDyj#FX^Z$^Y0EGA`S^fSrSp=O(Z41eXOSS9moV g+%~V^Fkxi$nS7ViN>H0Im2rZ~R|XJWBnLDZ0N&v;1ONa4 delta 175 zcmca0vOQpvRB$7q(Tq8#_Xsa~+BQ1(}6&t8w6h(i( zA@4{7=^q{7e*67>W_EXWcIG#~y$7%U(-oSzZ?&2Tl%xL^I{o4(OznMaiG}4-q6fMbEQa&8H&AEbZ@O)<+)ra0{RC0KKX0*Kf?3$%$+Z5f2d*qqD#^od+ZGB?21s%vsd&YOX;_=U+KMA z$NpJA$lHvx+Kllwg}hrU2NZ$j^BzrkJ6kYBv~YnPtPm3gJYLqiDdM=%^cVD!R?(Dq z!-;OTXt2%9YDv*k6cx>BTJ+w~JVlbEvS+QwBt_)zOS;MvO}{eHROKg|YW-N0Dl>Pw zjDBi*)3nld;&|~?X^!nN{hW3%yZMjV<#Z&NgVu5{nmm{lDM@SA%_L&$H*XRi^})Wd#aNme7qj;niBO(8nXM!>@-`J^BXX4A%BGLf!RHw`xRUBF;$dVk6pDkM^l%Bo^Wf z)P`A z8*e2d@e*hJR;H2ITu*FPB_zI$ZTv_A8?O^Y5+cs%-|ubgdgg;&&-^*F*gUQ<;>db0 zqapU+)^`59iErCti6qvi$nZA$Guz+VZlrE@zTSE3@LwLiee}b?z|VIrN7~tp?Tz3N zG`sqwIzp`OZ%tjBs&)U;l2ONDfYlvLYd@5p4A3hR;fVyf7pI`_C5)4jaIY&f=@Ra{ zbfE7`!eoF|?QxR@-W8w7AKdxAy{ci4@;Y|j^$L67T%F1>_8XU%dwH0&FfBFZK6cwJ zGM78c?zp`OM%ZQdAjL_|>&MV#zjM2pqu(qDiUy}O+wbwR8IPatglQbs0WI(1(;JUC zy*WQ5z!b(QE);1|qX;miwSRC*YyUq}n*GZ2oY;rc)pGv$snW5Nv*#+MbCqd%fK7-K z>}=Z2-WOvA3>MiAd6<1FCbXmAu!4EaE02Ma$I+zFw4+J0OX)=Qi)%sNf;9y++tBFH zw4vFKW(Sz8PL@!6&;-%!MuV$$4x7iY$a_JpI8K!+$2Rs>o&pTvMnfo~&Le=XApbk_sju%v)E zULw}$Eqx=lxgOgLYYLEySVOp|`|9C7SW`ee8*e2d@e(UvoCEd90IVq>zKw1CNCF$L z6AZCYUpqfHvfimEwu`Z?zF1ovWdfx!i84h7$JnoZUUMM6+TZ1Hiv5Fc-Y^8US1sxm zcGQ1xa56)$Y&TEzlDl>a`mW=N2srn0=-lfyPNsx=n=_M9;eJ#H`hH3Peh{YyJCk-` zOx)d?2P>A77i1Zx*0Bq7CHZ6k-d3|1K=5_8&q&B5q2xL-k_v5BD?YXmxJe!Cb|4A% zP;iubQCq!lt+k;<@w0{CH>v?8Fi+*8LNDtTssxVV3g$)80MP)!&1sbgKm;JR7SRIH z0>K!~i=qRf1LA0rHXv<4oGqdUq6gw4q`G#R0gwTZyVb}D#0UguH!q3_hzW?;B4!|F zAl??S0I>kUg~5wr1!4u_YY`g|8xY*GSMRUb0oehy##nIxaR9+}#f#zu;sg?E5f=~_ zkZ_B*fw+N0TEqjy10>oaA`lTs3`r*%#S6#_D2^T$MFJuLNv!&WRQ9r;dP3~uumQV{ zVS^WVi$98b0GkDWd4|0bjhKq2qPYlTP~HbFR7FL4fGdAYq7x7$(PE4|gt4<~ELv}9 zAu`%-2v3np>m?OCPm`iuA&eFs3I*$g$V-bZNGoX3t!Tk|6dhPmX#?w3^k5~$0M@4% z!TJ>w*nnaN8&oV{Ly8q_Sh0bPD0Z+>#W4VfnBoK*S6pBdiW_WF@qkS!BG`7t3pTAt zjQ%OT6jc1>!}FhIuO?jX=bEnqlB2OpA?V%_7}o&%yM(m= zXtQqpSj<+AH$5fPe1z|kKhwM|yak~c%hzeqcq#O1=pwyHzA8LRF47+e7iqTZk2=}g z0}-|}*+)0AuO#>DcuvKBoE)Tm>_3v3ZcN9rS{awa>ehgho^K*=3Vice(Vs2N&B3lE z53(6?H*8jRj5%$qF^ByywVCGGzo(Mcf{e?4ZMS0n_Cen;`nb;JNi=-t!K80v?0TUU zyJUza~9i@XObZ6{~Xo;_Vz(aWV5<~)Mp4C3#Ze`QFQO(sM2_u45|)fSyY|Kx=?lF89k_Sc;jAFc@j*i z1yENf)lI}||JI&sd*(+!w6NQqbMzG^c74Qt*0r6UW3>B;>U%JNU#ADs^oo7INWLRJ znI&&(K)o$a1<75C@~&2#wvxMku0!JVR&uw%^?*22AnzJcziScqCCR(-Jy~#mVZsak z!n`vK&iftcyr1oz@(B-o0+0v6-f5GtXhL0GwDwMq35#RsEN<)3_r-*z z80w{D?|zq1ccHF}t^-M-o*6n25k83MK!1=F;07y3p8XV$SB{_5!ZS6C83f-*KhyuA zjr2~wXPXvdnxtsU2iSi20()3^mYpvoVbg{$Gd)B=sCjy=CPtq|N_A{o-wyV9ALO&z z@(ZoX7qC*N19qatPPAB&e}nb+=jbV()ar`<8%B1epSfRZ_Lhej1ANQ9%tOqs0g0Yr z&ky`bZXdKbnANPVO8lP6w#i92tmT)eI?(i>$uY;E1QBF4TeWGjM)q1d8eLyfJnO^^ zSKf~Xql$ch{ncPHKC~JZ{;yadKgT{Fj72dy-H_-WLKhR#)%^8oHoU1ld>DOxqi0Y( zi-yOoW~$uGhc@KJTd>hKG+#!u9Zc4^`pm}=Orzo1@bjQngqKTSlX-mEjTJmZU|8aL z@p^9TzHlC+bT>Y(*vHJl(Eg|21@}8sr)C zk}J`0wbxzkssI3z<|Ti&;qR~e`y2k@x_?;J0p?lrlGxr5yXs<>sz-#0^OCQl;VaaA z1=WZMGv}pXS0gx74-TnjM3_1+NtuS!TbFuODO_S3^HR8{5gx6FM^!f>OrY0qDI&rQdMT1?M8@lpaaBTuDfD_PKO)Sb*RK^s z6k_sl(t`>U>Gdu~gY2E*41eeZdt*3ZN@AN7*)mO8a3o|7bguS$Io$K@5v6?)Y9Wmt zBT{Tx8hPZ1+_I>GMzlTFw>eBIX)~HW`}7FAJ+g_G+49J~i4l5bhif`Z-j%#)bK=y1 z<~<%bY_G;80ec$>lV`jkj1QPW=L2Z9h&JA9`@-#KO7QfMwmSMB|64_ zIy!d%|8U^nMl0I$vvU>xowcGpF*|!k#{FbPIDerc7qJn}5cwdOPvA%RXV&M2v9q(q z3uj7Odu?oTx1$E6~||}qm@r$mtyCF+(e*^(kTP8?gdtRzn2oRAffcBELAv!v@D zYqMz$)NX@X>J+fry0u$01q!F=;i5pl6mZg_acVmv5`+%X0(MgrMNt%!<3o@HZQmP- zlzJ_|xBvH>x4XmHH#2Yla9X%=D*Zc2vJp7$d^0>Zxt)-A(5W5$>*D?2|1^D6py!3C z5TL&h-j+hS_Pjgi%r_kB&1LBi3{$a=e6-Fixz5w3%j?%Z9{Y?b(?z}F-)we3UxeTL z2dT~Yq%eNvu<<7bqb|;O(`8wv$IRPl%#xtrG0SvXZlnh-M*4H}vvkJdGiWM5&5zL; zC1P^IHRJR-SvIL$-Lc35Z)tSqzVtQAryApa(R~ypPyfzXJUv> zOG*hCYas5xivMEd8xdwk-$LA>mHR5;Y&o1|RwOp!j;@@j#CprIUe<`jPTcCs;Ki|T zj4=s)2XRMM4p*Yx;(Yl6}jbX2UR8Y@B}8`EY8YQ#if5XZIFzZ9o8hZHslHUA#7y zo=AzWrA(k-YZoUvsnykF!<6Qkmb=pLDjDbvPeN#;?|Pa{UfnRCh140K%(JzD3gBY) zDO59i>F?!s45c4HHcrcSNTT^69&7V*V1TUJZt>E~UY}7@!I|mx8hB1dF_xz!{m2{O z$=K9mfkYH@Le8Lzb3)E|!LXksNb&AkM)B-37VoY3#hvtT(XbKHY!7`yd0gp+hN}J8 z%;Eg>?Bda4{%CQZ)=U39IN?e{i5^OWvge=bu`BikZ>35Xrw{t^``k zfmUWkVj~iybtTkU4t26dBz7YCmQI{6Eti;tzJo}?m2^eTmenl7Cqbf1u}kJke5f2B zVs0cJB868DR3g3QNH3F-c<SB~Ce$6`L^r*o?&E7t(@51mdtB2XFQ$(d^4e?=2ETS#K=29h zeNcOdK+Y2@DXuStgy??ywa`VuOTQONfPEx9EC%MC^snJCy&cY%f_iXC(4NqR=gqpH z2d8o2=7(+oVgMr5i3mgl;;0iN5F-#={P>}pfS7=|>ZAck0}ywen1PsqcnB%2UuFSh z0VLOhtU#rOyUKx#e6 z1;hm;R3~mAZXn@0@c{7viPVVTRAUrU?hkmyw0`GAWcT+jIvGL`sIa>~fgg)Qrg_lQR6*SD>k5NL9-sw}s z;QX%j-4qq>ru%W9Em?B*3r1MFqzmGHQmntLT(Py1 zW!(hUtHTPLQ*<*}pKbx`*R5a!x(#elZv?CAcCaB`0vpyHU?aK{Y*cr3!YQV^!NzqD z*d|>D+pK%RCUgaCi|(VsSA~-*{QZaV4>{G1zx`iBvZ)P*Bnd;J;*ikK#{2>w4*Fs& zAUv`@6zcpV8$)5WayhLNUEFD^7H#p!k*8;7pIzT+fMvp(f8!CtURrEzP4j&R zXBOXg{*Te_rN3@XC^+D!;8$pajcTph+IEP3()#R`lbMV_-)M`%@6A#m%p zA2L6x0LBGTsU+IQLV*&p;~>LM^Nd)R{f3pGeMgJJ0T18h+CryJdHr+kJNT3w!8~vva~@G}!UBci;n) z==U%px;;!ZN}i8Dct)g^(Kb5W87*P){srLymvGuSHAcR!>@|@e8bH0I>>VUm1(a8f z%4CLI4RRe;_JzpR98x`m?;*us%@??ceGHPIXauy#xZK6 zY}}9TMsfHM6}BeThY|8VJc8-~8om(mqO&oH{#tSZTjpzTp3_T2S_duajctxx&1TJ{z zgWJy74wmJEtNHoW{HN%RZ6`}XUY4$Unk$}U*^^`T0OH5*jRr23aE_tXEh4>59Lr?C)X{ zA}m=~)wWVa9Vn{<%!v$(*421-CBD5J-_G2Kuxee6bW|e4<;XCT5qbaZkqRO#TUVo< zmFQ?WI?DWrux{PxSP&5wt{e9XAqvyWBh3vFlu?w1Sdf-R(!34JSLoq-2NM_r^=l{n z$B3Tn0T1feA>xnU@^8ECie9oa(V#fqH8;3yj5JtnAAax{y*Ro>I7HtXy?;U!PT%dB z>?JSzeP}zCy`zR##7@w!^zOF7>H3H;B?{L^dndb0H@ZY1H+nr&M7(Jn+&3)V95#V| zorqH+J$BbFVH^GBU4;kn+nRrHRE@_L3q_u?RpZl(i$^rv2&&?-<3;Uh48nBK9t86~ z{0PsRk1Qh#i@D=R@^@*+fniZx_zgWZw#VNs*lt;z?}>1JUnB-MGk{`;=Plic!mQl25r=PS_Uu!vk-P9oA6$`_B_?s#LHx0m!tw%ha@R$?;gucBqs+}+Pwmbn znfqR%L{|1MPcntOzFM=NyMfxpLGG$V4z2Vpcd!t5!?k`yxErmt#<&}=xlP<{uJtm( z-Ih}A)=BQCYRzfxw$@rR+-)P0vb6u~Bx~nxw${rI?sgKFmvwR3O=Lgo;j))lUGMQh z#^H}J1E&)W#9;Z7`n*alcLzrf2P1qrDA_V&hk;li2?`{b0%(Z{ z>2_=H)AwDSm9aBwpOQb?*Dtx#x9Hy_217q2NmX$1*>4jiUY|-iQ}@0yp>n zi=Lt`Q7pyM0cw^Wqh~c^npy3bc2+m0o7Io$XANV9S>u?IMi@=NG;1C+lejiunavu@ zB5_^7I%^%XlDIyQJ!>1Y&E|~dkb6VGKASt1OX9{r-dG-#V+`bv<&)onu>$g2I9AA- zSaYCgws@=4g|%thkafr?pXj3IGbpmMfq ztP0{eJ~wNJzg(Yt(!l1us2i)+P`Wj$goVhuXhrKq80lNu^MR}GA!hSp6+>a)~PnKn{Y zidxz>>4*Ds^`lx*L;EKE_$)Qlr1hg#QA>x0I!Li~FH&s1&-MeYu(HNFp>Dst%ETD! z`3${l@O3IyTce`xE>d?B)ZMJ8d;2CM+oG&nZ@Z%X?TT7< zZZb+8ik2GLPQ{((k?Pd44L&`)&1aD6HL+cadUtJ7?{-B^UsSz26!ms58a+FIiHpHw zOq%x}KYSv%5cY9N;{o5iZ;lNmvk#p-d3@mfgl|6V56&eGp|Cf+5K0}C0!s81eXkdus@I}JE`+o|-C2ENZX_lx__?<~@6$C03u(UXFq1;U%G$I7IRXJJ-%CJPg z0=2zD{iOzKg*dAvciK_Pql>slg50b(0OP@s4l$=r3^Cy#<2@4!24G6@K4P2>hv&On zTO%fngdp}Q-AcwM?B&9tbN=wON5hpsA2UzS4F~62 z4+X=%KyzO(cov4Zwa+_o7Dl)=#7(qvxPU^fAu%e$jFbx~7Y!dA30cH!%n9hbABS=CWp^?fLviWIea*;)Tws*TNJ8zY4zxmjqs zlnl$yFwglW{pXXquy-mHF}DgUabhv4Vb4q`XWt0LzW{&tZ$j!(iiUM!-eiS@rL`r_ z@C=Y$nU@STW5|3)0fBb5o}#4u=8X^{v9U}q(IGRdVYRFd{^?)UvW8b-9kH4vdPXGt z6xkuws<=%>Ij4I1OwLAFvF16N)j=-%MhLD*J$)u`BP^C8x7tmGaj}rAn&L{AHQ{3M zo;8MX?UHLrddF^>MAGefC}~;qtmYhjjykV>k~&BKOYJ$@V~+G3^o5!EfHyn|>yr_u zm`SY;rhb0mOu#?Ez&f7~`scz7fH9L?a4rlfo@B0=GliyLaTr4j6BB?5liJ5e4vgH; zCQV|YNz1}KB13lE3m8<4pIN-2;c%9dI(X>)*uI-i$mX& z$Sb&1f3g16-dJ8kJg=S4YrkuC#;q>i>WWr%@z$>UT1~#??=3km)qJ;ZT~C?qADbw9 z@w$O3t-i<9B&urR|Hnq1BkyCqvB0)Y8MAHc1ynI}ugraQ_{wl%-@*946RZ19T%L-% z+IUx6+||vyy4PGi(bG>QdI#dY$5(rgD;}=8x}%SeL5YPnDA8_#63v!hKd@8Q!cQn# z-*eYidR_Zh{kQ3VJ-BAu9X0L#?Yb6{e;3*g{d%76FQ8xCpWW9?{U{gXKg!$hgqt^; z@aD~CbAPV(&Fw|~R_%|idWioxR}1M$J;5!>Y;g&U;}j>Y!mY5UP2d=dmxM{$Aa!d@ z!qTipT-`IuGE!xUZ{o6cNz1vy5|&w#_7+xqb_YzGeMvim^=4dR{!n&U!Z7$Y-ZY4{ zK2I-cX0kG}!EIQ=Arg*K!4xu-8kS&YBjGt26jB-aPK*I=R!yJ5H!|ZA+W}bB3c0E?9>Xo*X4UkW+KsSS3Y!f(itSaP zMF2OOL>R!$8k%~Z3d1fXT#(YZtcTHQ6jS71u~jssSZ6umW)WgZFOIKr<+0Xf{j>TE zY;KR#oFW)j1)Mm zGd{-4bT$WosPS>k;n8EvwvP543_>vVzLu$$Cg#-WfhK0))QKhrkiLg&g@U;n2qH(1 zr?!^nbAA99@qo0y;C90LLx5Emfuv&QIX~IQ!WLwlg`EosV-AO( zYk|O1oYVr|PwE%u=W(wI_~w#Ef5;n{pZ1cygX=(0+>p3V3`i5ZFh<&`f%Y7PyQJ33 zvPr{4aCX)oP8xx-;(VcLvQcq>glvN~(Cm-mw`LIW6 zVUMy>`GtS@&S%X}lvS@Be0exg-S)<@*N&|m{A+9F-xXCS3hEL?u0)wX;cB=Vyb^q1 z))(7W4ni&2Ie#|z(%^+1FCV*SvizC(CG*Q!35Zx;vRv4)W-5h^w3ta0@BJdSOQQ+5 zOA~IFd6dt9eyItu*!WH+8^T0@1GZ(?D_qlK?1P+C&Mtt(Qq` z))1DqLIr|0%A^5Hs~~7oq#DU>Zy>-3?3RcJLyQtXk9hPlrsD=-5Cl9yPy`~kq)Fu7 zxF&pxh>9Rxk4;8LfN=tWqyZ2pkm{=Cso)&}rKM2LJ}j1Cn^O==f_Nab5D0I9b%?+M zYLysZ?n%hF2!Ej{1Om3P79h4MytMP;&R0ib`Hk`XRzANqmfyZ&x|f^3Vn`HMd;+9t z&dR}rHSf=cUm8vnyDoj>;x`fw=4$zs@kdfu^wM_J-+JJhf>DEYu#T;zu4E>w~czUlZN=4+syqX+Bf$U_2+AUoUe!Y zk4v4Q@}eP#;|gV4(cHak_S3T+OkSEGhz z)wqyUX*N=tmaBs;N2T}BYfVP4RpAt)ltW6?aXp)CK?YWf5M;zvHz`F~_Kdn82A_-Y zY#y{aKectBIFVdr>oCgO1zQl#5}z#@#X4mr8%S!b%f@Go8MJ*-q#m~p5EugnVdlMy zvmg{!-#(JL0-gaZJOL8jaTJdfrILs^Cp7In6Y!Dr!c_V>-Ac_kfa-a`Mcl${`WU%ZU}DS=d17&!s=3lu4&dAI*VP2)Bu@2F7;fHg17Vp zf)n0AsJBG~`xlV)K9cOuQ6J=&y{^4>_@CQu<*qUviDE}0zvxx>W&bNJ*V}n#6QAFF z%Y3`)w)>Wi&+m;|dpQQOk!B6vFmYeOo2M{%8iHhrRI@nd<5;DSO_r$+vG~+B?F}g> zDj)ANXDJU8)2E2@W7TIN7*P-h0Zg31U={Z}!Hjy5m**`KtYjJl(vt z8$@<)WgZ6dR8_xG@>D7FRIFRH=Dn|Lfmt%|y{WyA zKfujYRVQle6E$^-`i4YPOTy(&xa$*Dbsy)N^DHZdkBca)E78ysZ|LJ2`c?*``L(>Y z?t}JS(e9ym_X)oHM67)jlbd;K%iX%hXj5;z=>Xq!AXYbk$!^|Soj86betdTI_-xeO z6?gCB-TUJ1BfR^_ntLc3I=3=-p@+9JiIK0xN6xN}oK*s?x{pM^zL0|WD61;bIv8&q z;#-HX=tkb!bhoK3+I}eBKEk(;#F~y_ay@VLe3Vt0XgLsXIl{Lb!L$b6+W0}&o@npM zc<&hBI~MEu3MRMn){eW4t2G*!lBY?2c1skkd zezUllW#qS{<<~=gy=wlfP4ZjQ@@u&jAU0G;MwuuR%bAfxoMIn=jFQN>85PbmL$$O8 z9}2{sfrwoV<5F9IR8=94mJH+4h7V#_DGq$Bmd#erbJHe?U4e+4kyJZ!Kb8z)FF~xT z`%=dwolJiCnCPI#pLI+?jLqhJA!DNdQpZG>#yUKFO!S{)Oms_n*8YWziK+nq2dPB` zwqm*AGI52md2$?>oP1z%jBEj02>3&@qy-{gvjpvexSqtdoCyePP$Y<}lBDWWsS;+v z)Uc!ikf9Z6k~%?=Cgo>~UNy1BDS zi$H;aJ}0tM$n$lXYNN=_dVXb@4Xw~=pY0-lGP z!g3f4kg|_dO9L%%JB*rwgPTV9Ll~f#aKw|x9f9AZo>a-8yYtS|oe`VF7R#5m|r~zgMI_ zVB9i*uEJj^F0lc&8Bkv*3Ykme7ssPDJ8xIT3ired`}xBDSmD5mCE={O+Ipom+R%5W zFXlWLcMkK;;h6K-N>0L2eYN6BMbxwJPF2h?5O*Bq9Ya($;mUvMYU(|Ki?uy&1czac}b{B6)4x`xi_qP0(eBYe{LxSuAVn~o+ z8ZH?BB0EvP?T0xxaxNJE+FtW_&iX`2W5QXTV2&oLTHa`VtrgTMrFj>Oz<}iDUou=Y zTsZd^IoQe4Eo<%N?Y*1T+NxS>Tf*r{FryEt)wyM@dwBbv&1!8?t+h1)J!VdBQLCgn zS~sY1WvD^g^HJxX*T(L2BpP;ou>0^0*W26&VApc!*4SFpzUaxvKNuSOpG~)2Yi)<3 zUpsTRs`gqWR@DJ4*!5$vx}LkW4cB{PwcWtRfo$2BjZ927GBIVJLya_KqF+3!J!+!f zG13r!$CNju*S_Pd9ip`FQhJELtJgw$MBf^k4bP`Zn^A^HSeict0$!9h`zcfdvGhNq z(N+RfK~E4>(3d86T}mgOBvKLDmb%0(8g%CR5dHFDU>GE_U4%gi=aLT5#LG{D>P92m zQ-Hilk|+Y{q65+eiD*fFDwn|OS;?AX6A0r&Axts^YXBl>v%1VOoJe47Ql~|R+yo-) zp%7r3AS$D_Qm3)mNXUj!o=2`t>XQgCvE>iVArVoJoI_$C#T;N+k+p0D!dH?mimike z1C|#q5n7F}}Mvt(q6lo@hM zvSJw$BT&dEC40`TgeR<=hX4@l^@X|bNHIC*bGmN8olW|l{f8=pAZ!(Um z=?@)8i#$UlCQA5?l7t^^0?9=r{FZQ~cn=bON${{_5yvG>!k@EjdDfD_OcX^LiG+Xn zxkaRr-NYjC`9LjoERn6;@JdX-Wv*?3vz5Shunhk=B!{8 zp0vpc#46HC?jh@VI1ozO#EgF=MSx5?gV_H&Ft`fhIhjr5`C06$8q!eYK`XlK;=eoXxjr-r3IQcij5woxVH$(XK;${-LP#kU)Ds z2ib`lfcqxKp2q+;RqjO$zJaE?+S3=ZFHfx6tJbrisCB#6e3-uJMxpd!`mfw5$3IL19qp`(I~sUL!}Z;_ zH8IETgrj!r`|^qps@rch$2Kbfd#idVGp6(Cth2BqR)!qpge zwel_ylp|l_8c39R;$_WzS@ZR|+mo@ffq2^{@BFoE>rJF5bCIVtnHETHaoJZ7F8o28}3H zaz7=&zSv6>!>8lJ6RX1$*S-<2+r!uGiPs(E>kh8f9gg~EFBmQ?@%HM((Z}OQy{kvP z3YgWpgVCw82(W6ezE{^Mf=aQ^rjclaW%@jQR1YT9_9HIpM}>z?@bmL>O!~RYd{m?T zd0o+wJ=&k|(L?+ljTX{VNF6Pv)6^0Y4X*(W*>~vgXhb@G6VmO0I zVc4YqU`ZfR!djSzj7y}+fYMa2-}s36O`GHgTM)7SWiyCRbC$HAAVD-rF0^>QX%!*B zrd-EWn<-f|DO*P`Tb3+9uLGhB=^C6_2niI z3I!!7&NNE~R0Y61ST})Jy!GPx+yqkbes2Pyc!M{AFtHWl8>q(Rh$G2X5N=skU~Dp8 zVvEIJg(^cmhotm)0vwkxrqc!my4 zgmq$TBqbEcQ-ljr8kh56MjFKw`B&^)S}jcqzA;Z+3Cem|*K*#oc^Q0TbEHL7LOmMm zC@>^_(3lLaB!mArDd?pfqgX7mAf@v=kv`m?TmMhWwv*9b#_IVjbL6zIDRs>Fnh+weM;~9lx~SMi+U)58;0`} zV1OJ->7U3!#JWLXW|;vz;arzt;haV?8K8+M;<{f1js2eH67`Z^4bjQsB60BlaG}x? zmG+u!B@DrXX^thZ0vMMT+Dod2h5#YiV=Pi(iHKQ(1JO$w^*lFe$$~%&y;a$SWYG-` zCfXj&2+_uY0;dC42JT0yJY%{d2o9!5XL1Bfb;9iJ!`%2vO9xY)drml*ViWZ@!V&36 zlX$Pryo*l-=zoix9_p8V{T%fNb8;!INR6tdpfusENHC3?Y6`xWLwNs4BU!sC!3@3= zC^!*MK_O4yGKH(L1wSj2Gj%Bm zlj5CX;Hdf(9PynJ9)%)x897K#@w`?B;0X6J%m^I71v^V$VJVd`Oj*o}PfBu-t+m~=HBc>D7kQ_=gB8}qe zRA0MkA}X{_kJ|x{@;2Q$%#R+#+U2B!Y4p zI9vTV9)cd=xF82=UN}D&79>4HX}k5?{ZFx^&7?pIHE%w51tIrgfbY4QkSk+5Nt$rN z#{tE2S1|_;VbU%PAuCFMloBIi3i^En#l1R&2%P!a*A!AA5*5%&f!b7XZ$K72-x%tJ zZTdH8WFD_sE>aCNp;_ekIs$vVTJSnP%I~1y63p`oZWM${wEp=U9e5Ln-DUn}# z+51m>uI;?;TXlEdb=D=S>k<`%kf*c=#8ibv>sqRM8=8`q-pk3m+{)*8u8-c%iSHcd zcMiWj5!-npI(m}dd5Uj7b+^c!VBCq)Bapc`4>ITGty^=<44SRN&&_`F^S@5~KX~_X zH8kY|t1|&&7rP^2-}Atz%eJ8wT$y@EHFcYm>L4XlB<$r0`#vePl%!TB?C_vZN_CP{ zCSi9a?7K+nx{b1yM6=xFf$IZj?M1`&g9-bT@X$(`vxF0ZsOD7_+m*S%@5Z`@D%h_< zQQ`K_EGn#T{bBZv?5Lf&mcMGRL0RGUbvxy3h&!5jNAvZwuoxV@35RFP`wv=nUwQgY z9!LhG9eweRLB3<~&Ywh2kHtE^603eHQPCQ&=;SLpquU1VaIuOb30F(p)xo(s7%gv(m$&of?YA0Z<-M_z zy{c(tOti8+UfIQ0cHIibDhFd_2S0~&5EkxwXf3Xn(OokMSNYz)V14(k0S!_fZdciSkin zG)s+XPEkIsPbVA`TGV*7N&87~Jw%9I(G8KF&;oE=FlibmjxEN=la}%E*&w?B=R3xe z*75PLFL(pEbB~X6HYh%sGd@m;cygjTG(LVKwIQK4i@PBP;LwT+A< z!p9K{u_|ZIP$Gx3L%Mr>oY7vS&y7<5dOO)5`-%s}Cpmq>6gE6H2J=z*fUJ~jrYv&?DDdpW<}X>GoOtwTvDvuW@^~4uMq_F16=9<~& z8kVU!)Lk>&T{Fs@I@!PQ_wb@vL@KMN^1!kFlTRKWV5Y;ffnGU1^Va8O<)@gtXBK?+ zFcaV<0UEKV>eR`>=ABg;AffPLz?YteS?Zli+!%dD1)U4=i>w`#HZi z(8Pqib0P3s1!kYSRr3V=bH3(jpMPpP+|9JNY*&Yx2nK>&H{{JJs^`1v37UAh` zW_w%P{Q1qGxGDc!H`B%d{}xn-nFqED7_DxmL$#RY4ZUcYhT#QEfNyMgg5!c-BBsF8 z*c{uY9Kzb%iGbhgK2MTtMRC}Ff|zG zru|``I)v1RPSrkOyTZQn;b!nEG6lmi0gdr->P4La;{@=yflXcr!3;rxLeR#vZOoxE zI>23mJF4&_43B$q7yjE=Dz^6#x+F9e+6YYq4|9H&ar@fvpL&TIJ%j$pz1x^WrDrKm zCCz^5;wIfAZ$t<@;B_g!g!xJOjeg-Z-9$JmT8wQX3$Zai0u=r@zhKH=(V~;1IBz*?je< zR+9j>dB;-6icVQ_aNeyZ*ly4 zSpPH3L;&nn_EZT-EMpHKK-2BL|KZhd30oEtxq?U+if_*-trd#PNK#ag!UJkGAQWl+ zo+_2$Q^}Fpc;%Z|)w_Q6PhaZU*ci-&SI=}uFCo;K{@~oCe`#}R^aQ!uiB z{)RA42Z+zVP&Y0+@@PZxWUv+#R0$?1ubzOvwk9(qAAQS27X$26~g0UQB zexS8L^RcMRz3b6)p9BZ6imcu{K;>_qyQfvHSDP%j4XuZDad;Bvo`K2o`JaJ_3*$N= zbKAuDK|A9El1W${N)fe0E#kC;$sl?;mFJvjaL^@J%5@DWgJ7Wm0ZPh6Z(TrxOic^L zDi)2nq|lb}QP<79G;L47@~jInb3jY%l~#i4>g!P(GjSH5@C865?-SdKV(dBJIU-bt zQJV6@A`v*^EhYt4%JG!p9pr(*N|m;C7V*U+BZ9}cPzWvz+1444r+hqs#?3#O(gK9G z&axk7BM4IjHlxH5E%OL@)z{j_MWuL^h23$p7g>Ro<_HlCea0myGbsc&rK6=$(q7eh zB~vIZX%=WM)IoBBCuU(~W7$pM^v(zFjTxXfLHD6p^6HCDS;~i&j4Ge@j#L5 z7V`GUJC?=rM?=U^H4dlTR& z2rQz@YIF|{_*&Sb&j@qoQ|ibjvMoq> z5nN2-7BPU;!-`9@UvDAy0^n~tU9mZdEoCG5e$fRbC@!J%z}kl*he=aaGsw-G_D!5s zY-XaOlr&7#h_)mx(2bN1GHFDA?4ZVsoaz@W8dA@29#Vrtb}MNFGO8fP5NOaqRG8wn z*}{-%>SoA6%Sh=U=@^ztwn^ozdF+Bl?q9&{3QixcMIo)O@<|BDkkS$rpzzJTTC{h4FnDxFJYdu=NK3cot0O!(7r#z{u(qqPT>)+gJ=L;gV)j-S}*1DwGBHh?`TFpohyL`H68m zS?k=}0JRIH$xaAT8n~x5dfVN?lFP-Y1&bBdtyu7`EbeIK9j&X5qbu3#hP{qm>(A0u_W}C0b)BLI=pBh2`)>sx z+D~swY}be~Pn*~KHBZom))n)5SYvRYm&zQ+%2NR5v?i=YD?Jb^F(=GLD>>_w zp~SqNYb&(=s*K9cduiai2iMCfbKZk0$dZ8n1wDyeC;W%AlR1`Op|3dNFz$hk$}hdt zda*T@=Uy=i3JS-r+YPtNqQ!&v%IgxPRq@g$zO*S;+5-Me@I0XCg?T+63i%CGfW9SR zaPFmiOP0Vm6%=2l|9o)8eAi_AGwVy%XmR^rRp0vhzpjgS^zj{i(f0m39e4b9z7j2b zEc(>fR;+6#FH~)-BO|J|Sbn?H%|J%}w$T{3=MC0te_Yj154yEKb?f2gr}bKRJOTNo3y5oS-RIy(sZn9+G0>^_ z^k^*!U!@=}#vsk8NFz?dAkCymBgYIO&8$e%V~ZipqDV7f+9C}fl_)z!9P#!CXXoK; z!)WSz04crA2zbC29tiJXj(7)-_ECTb{<2e#=cj|pTk=XJCGeE>2E6R##lH!abN>>9 zcQC;BQ@}F+3S;kL@E!*5WAFO)jl_Lq5g8`#pal< zHc{hw?%0LXmnSYh9n*Od?z-oOFYLVBe{oMtSC?@oL$2vOLM2#q#MGMVT$)ocZ0rm0 zcmFudov1WQ9ZIF~fa($rCbdt&!BqJC!Pz zO4iWJr@=>t82>65*M4d321n-gkN80ZFslW%l?II6_1q8~tHo_6;{p>+)pWH3wBmT6 zF)tjl)vLN3EK*Nb^WjR$F@mB^qh=7Daa>o$WEz|>Di8FkLBJ;1B|wI~ksZUUh= z%DV|f`Vn5Wb|{bVqUpNzRWrB~SMxh-Quv*db0qB|N&>ngNIoHxFQ!a-pF#OFCT1X*5pEUo5&~E#st<1oCW>J! z9Ua{;%k>EvRe%)sQcYMy3kIzaC|%#G8ZI^z@eAr`eKLG7PDwiZ>M7+a3f+yH0eN}QL@Up)W1EmqtbFW$})Ry<%;qCDkQ2=qgd6Z~fsI#w(nWECg&4aN68vAXXGq~O|kM_b&{%{#i+96iye zy(>o&y+`7`r&fDUDITvnx}#qiUpYdEqT@|YyPSkCS*X`u%c1CxNM30|{)r}@9Tf{SLa8|DJ;D-AR zWxDDUuKJJjEkK6p)7&yVN3w8y#+MP(|kT%^dY~a3MyB7j)W2ZClFlSfPT>#TF5_aN8xvShUjln~cWQ&f$n9 zKn|(JVC5p6G8fv8Awby0yO+PIuqTlna=~|x)N)xD}COq~oP#>CYE#2u6id|%yfz=@~=1_U|oiU6S-J>xJ!&LNsc&g<|+tFdO7Ra ztPC5Zyhydc=&I0`iVx0=Cd?O3#_nAw5BY^(*&r3 zI|f27u3`Q|7|5hy>8AB3^6NL;P4x;zCYjZqtpHQZBpWyg{sry86?>(k1 z&TQkEZQxDiM2tCc=wLz&ckna#M~o^>TU7roqRRW^GDx45vx9)sM`^*+s)VQ zj`lnj9X%DRdpseVp@YSH<&h2d8_HA=_(mHVh}+OW-1V7kzXhUy*h#-QR5;|M-gVFr zf7h8eT&#V!sdhL|`(BO zDZYw~tPv=#EjR|Q5}wzI?U#Rlkcx7-=(S=7U$~4*gaE1~M|=dURib#rG=(6vLrxQ8 zsS^S=pl`tk7kqogLRy_AQ$I7f{bpR?y}~Z8ifz;j@GaG}x4^n&ok1Z|#wEWI(tD#5 z?fT)17w}Ik^94Npj6dRJND` zcqn_xgr_y+c0LKX6-Bof=$F4P#AI4~2b^Mjxr>AqB+PSD?G#xxu&aEZtg0Q99!{v9 zgYz#>!nqL|bGU^s{i=E?P_}jeTxDx#hUM0YF~um!y+N8c<6?`2-T?M25!W<~EYiQ^ zVjbbf#JIw}wqoDO>@oQ|u`|L5-&vQq3*1oCK9v?Rkj=F3Wa$|0OBxr*m)+r8?=zRl=cS~0)kNzeAwLw9~}?EEw;3q`Bc|3KQ|RI5kdfBNzJ)4pXRov z>X7IJE(rw*b|OyFN&x_@rj%A9U?D;ZBGPvg`Is?^+>zIb6bTk0LO!$+5$fmu4Hk@y zkjH~cM`9;GPHY8a$InO}Bn$A1_v72z+Q$3#9~eI|@Ytz=(UVCX2L`^}5Eg<+7mu0Y z0K(BSgD@mX{p?xR&uz^=B@F?vE%47L4P^LWZ2cip4j4Slfx`1FF=^ob9oAqx=jG<0 z14$EBgeTXMhS0(!euv!T^#?IAl+*>`n|4Y4IS!aN4tb%6FlWUGf_O+^#O`1WomYEu z1v82N0~7XPKv*)EdNNS>k?KIu%YyKqOWI*p$8l6eZY`NDrObQ5_z|&SQj6vi{~cpy zGEn#=Nlyx2mnWPYs-C%DLJ-13T1qC4#05?+KUJ~F$zi=54f|{axwtZki?g3VUw1hL z@tn%loXYEkanEkvvpen?# zRj(NDX4zKGeYfXw>uQ!KJrkawOnuw)ffXeGuh#$hSL1~Zd|^Ypu$?b#j~DLb3wPe$ z#TOp9v&a`72hD&fFK#N~O(oX~Zs~wMS~GQlqiL5DwSopPP{_%@&~>TzVsFe=6}NeKn`hP5 zdHwMf?Y;c+6;qT6iFUn5g3V>!xdwXh8?qpcwvetqU-;){;bJ!^+T_)pfVF<&EHL z!C37Mymco$T|cb2QL!@g*Vfv<175bEHc?a|elXRjE3>T(ty{zoruM8^8OiN6_J_G< zjOEv_nC|A+UR%6XAIsmpVj`NEL~-Z6vKsg@-RjyCE(5HA>5Ef~Nrh{MxQ*9%}Aev`0P{RR>sMuvn0klpP5?Smt9CSRrn z>4fxIRnhdN`MUS|Xtc2X_5n0>*?)KA7t4*1?{^^-)jUs+Sis$0;ZPg(BL{u7LiZ!* zfo8b*smXq{iu!5EejVJrRYnix8r~|`Vw?fqPWx6DJyc+LYr78P-C2HZ1zx0vd{Vw-_PcbsC16jqft_ zklXrhr4i%xCQNV38!>9%)9j~4G}`wydboMtsD%n5c4==&X;;g?x|4jjb|!!wguw)~qSvUOXrlK&)EW*t{@ zC*hl`FM{6vfOL3MB#i!m)*F?rhn7ifg4{BZpaFv1lt#kq!_tvi`FpM)ycZbRM;$+l zvIX*?&aiZ#R_v9Ua^g|b*et@xS_$I#GFP+-T!GkE zfGY$zU}k_FHc0F+inGZzvr~-8zrYR`i))Lp!|Cr5vcnQ{sK_O`tdrh8nH?q{O%=-o z#wM*qV24W-?C>9dCCluvB#;X45qmn*E5_tsan6C+WyDlqZu`Wz{0k6O;vKNVNTrht zj`t%wtdau`lBe*~GP1)e%x(rdEHS)d*|LBSn6{&0Y8pE%F^A9!B|Ged1NO)cmjOFm zF0sQmfaO#wr`+H~KoZobsiZ*MVcx(FoAA&(u(5x^lGr1#!=`UM8atfAixed(&Xlbk zKsT*3Dr~Mei!e%ZZzRT695;9iT|#dF2TN`xA&<0+z+sqC5qe2-j-cE-GJ8ze;mU~n z6yZlCb~r^Fq;tfTgUI3{j|XrfXL`oRu)vyfvtVAz3ieX^h*jX7&y(ZDkrLtVnK`y) z)<3}oL&3>#3mSGs%)%4oWV3~6=$Wa;^Rof*2|y7IT$)KIOa+D7$NT#r_#Q0~DNEGT zpm|=V!7E}4*}_BVTB1d?^nLmgusbgo>%cV~={!B+!9xynYzRGrfj1OQOB?t9Le8X3 zWE6or10Uug9A<`jjxbW6ntP^vaCU5->y3Cac&gOk$)8%1K>sTg5yD;X*F;ygT6Frh z_QJFA;ugNR<$5Sy+{qW6{!Iw`_d= z182_=h=?LUHW4*a5swIuec4Pr(bd*=w2!+7wQwInaFY8E7$r(iY;44(Du_sR6HXh#w>e0tDMg&zxFhr)p5_|zB+eh zZq?OsOA{~O#h32_|MV*(ck_#4MW>^CpI9q8y_SC(TsYljYU9jqp4pu!9e_e5S7hq4 zi-y&#>hzZM$u0R@vWtq!tJ7DeUw<~{+8%f9;az(aHj+Dd>khDytWerWmLa!Z5p{2m zyL(pMJ$EbIuOE8l*?7e^zG7RvVmDv0J6_SpSM)^(PR9qn$`5=sKH%dAe9=jNY+#13 zn2Ca;telE?PA#8PyOvWQZQc`a9^jh?)|wBZ^x`l`FRJ4OjeJ4lT0t|g_NC0#mMbl* z&X((aaYq;L=!!XZfH%1;M?9;F&#Jn%eJ!gYQC$7fp+t6RJiD6DuDsDi za_HU{FX7nez2EALTV1@>6|G8*S*dc&N^s0d6V=Tt$JVTscRlSW(ujGw@wPV6wDX6@ zZX8=V_SaU=-?^F+1)eksM}=+Wm@MJg3nGz6m2hmoS5dDb;po?-NjMJ65{~{m`|or{ z-3Q|ZhgJ&?5edg(kZ`!Z7zsz)t=wDMXyLZoVU%zj*iypr0)4E5{#Ku3q=b6YMGqTw zZ&n;=hMTvt?ZX!8t@8bKaPu=AJ-o~CGrbn$rb3W2{H&ZF?lJt#rNekt7N$4T!+Ui< zYw0h6oA(S@?0ZHU;}-MqcI|ulCByC7_ZkhD(yqsp?K+J2&``vCdlB+|jbWtF_`ZoA z$+Et0He%dv!t|27W3Afv8}?Jj8nnM?(8J9yTD4GtXLr&#>4k%5^L`F@Y!0_m1?C6A z2E`$F#F-(0Pt|-q;0;Vn`(_tg;0utP1rrTG(E(i&81V*=rGbhsbV}v@V4G04*HLu`KsXLAct5G^lB-TkYSdI^ovwp%UA0j0;z)^dk<^=n7Rw^aRyRLS2^w!fir{)Wo>8_N8jsOrC^ z_I^mYKBPK7q-s8-+SUywy5?HvIt4$sK!^%Ix8XY~@B`K^meQ5txY@~@otJmU%+=2s zJ~HP;ogLBc$D+GVteHojGk}OvWBQKso6eP^F}mcVJjdnQKX3h5TTX-EFy|Bey|1M- zj&%*h*7wm$V6cpFR=!qos~A4o9&>bmkjiUphrId4>st7|mshl|hhM10Z2ttGq)NlI zX{ogS7b-q-FdK`~JD?~iLJPn5pgDMt1>~-4Ahy0!Y|Y^qU8G*X&Qt;LOA3HrETDK@ z1F`jIq!wTaoAqa&hC*@+*R>cSJ;F$;OBhX+NKz-EbYVOX#^?g|Mh#+t*e#5ZW?=+N z0bisTT|c2wmaq#B0NRFN2ca7a(n1s4_{Q1AoRNzlmBZPCzo?QR;~T3cIcyDb%z7VVb0>guRXro;7cB+BBGhwkvfZ>;!GV1DoOACv z_nz;Zd-K;9>dnRGw~0ht!Ef82qt53AMfru4jnC)^cV1K#<+5TcHggq^naop7)zeJP zi$qCMZKd;4*1sFxi#;1Fudyb(sr4){SBeaCX^9ENGW$MLJ>DtJ}^BSP!j zl}(W~=JOzMDoY{bzJ`XJ*k_y>A6()=vF2IYFr#j@7$3ikxmZV2&DfG zlBN(s)v@7#ct1V*5*;3P5{wY+BA7-94XC>Ad8@PxGpOcun5C&b1e6*K5#$MWBlvWX zocy35eos$EpFzWHE1kK*w7YDN_;5Je`exua)@)}&eoqm|{pHpJsD?&|Fds%~cAS7{ z$oE5zWLXQ%a@|n3YBgT5+nG^PUGe@UABfVD8ReABWiyRu(H!c){kj`GA{|<>g-V9o z7Vn?$B$`XDm7yC^gZzU!RK)R#`u4rFob6u!``*2Ks|*gKio-k(KHbzD((}W_e)f?_ zOg=qw2Q3Pa%X6y+JWt9C1gFHg$$Im365m9a@1p4}8Q&l%5)f4~jPldJ~`sK4g(X*h&q7?np4<9=X4 z;9qot3+<-K(MoE(hsl&(H%Ai(2#yjQBakegBZ;mO97o9a04EtcPs1A|8il=+^7cqp zC80eyzd`)|}6eg|V`Qr>i+Yj|oHwWM9i?px=MnwcOGL?lord zVSyMdCTWO|9N53b8Oq?7cJnkRvokMrMP`G)$3Jl z?)tNJpEm&#WXg6-e!~yAw^?U-Gj*ue@s*T(%J(ePYV{zD*iI<`dJ%xG4Ny-2rMB8B z5w*`<;|PYXyZk7;iz;0f|2@P%G{)F1B|G=MGPkB|UsJ|@Rid4 str: + """Normalize image URL to absolute URL""" + if not image_url: + return image_url + if image_url.startswith('http://') or image_url.startswith('https://'): + return image_url + if image_url.startswith('/'): + return f"{base_url}{image_url}" + return f"{base_url}/{image_url}" + +logger = logging.getLogger(__name__) router = APIRouter(prefix="/admin/system-settings", tags=["admin-system-settings"]) @@ -300,3 +322,768 @@ async def update_stripe_settings( db.rollback() raise HTTPException(status_code=500, detail=str(e)) + +@router.get("/smtp") +async def get_smtp_settings( + current_user: User = Depends(authorize_roles("admin")), + db: Session = Depends(get_db) +): + """Get SMTP email server settings (Admin only)""" + try: + # Get all SMTP settings + smtp_settings = {} + setting_keys = [ + "smtp_host", + "smtp_port", + "smtp_user", + "smtp_password", + "smtp_from_email", + "smtp_from_name", + "smtp_use_tls", + ] + + for key in setting_keys: + setting = db.query(SystemSettings).filter( + SystemSettings.key == key + ).first() + if setting: + smtp_settings[key] = setting.value + + # Mask password for security (only show last 4 characters if set) + def mask_password(password_value: str) -> str: + if not password_value or len(password_value) < 4: + return "" + return "*" * (len(password_value) - 4) + password_value[-4:] + + result = { + "smtp_host": smtp_settings.get("smtp_host", ""), + "smtp_port": smtp_settings.get("smtp_port", ""), + "smtp_user": smtp_settings.get("smtp_user", ""), + "smtp_password": "", + "smtp_password_masked": mask_password(smtp_settings.get("smtp_password", "")), + "smtp_from_email": smtp_settings.get("smtp_from_email", ""), + "smtp_from_name": smtp_settings.get("smtp_from_name", ""), + "smtp_use_tls": smtp_settings.get("smtp_use_tls", "true").lower() == "true", + "has_host": bool(smtp_settings.get("smtp_host")), + "has_user": bool(smtp_settings.get("smtp_user")), + "has_password": bool(smtp_settings.get("smtp_password")), + } + + # Get updated_at and updated_by from any setting (prefer password setting if exists) + password_setting = db.query(SystemSettings).filter( + SystemSettings.key == "smtp_password" + ).first() + + if password_setting: + result["updated_at"] = password_setting.updated_at.isoformat() if password_setting.updated_at else None + result["updated_by"] = password_setting.updated_by.full_name if password_setting.updated_by else None + else: + # Try to get from any other SMTP setting + any_setting = db.query(SystemSettings).filter( + SystemSettings.key.in_(setting_keys) + ).first() + if any_setting: + result["updated_at"] = any_setting.updated_at.isoformat() if any_setting.updated_at else None + result["updated_by"] = any_setting.updated_by.full_name if any_setting.updated_by else None + else: + result["updated_at"] = None + result["updated_by"] = None + + return { + "status": "success", + "data": result + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.put("/smtp") +async def update_smtp_settings( + smtp_data: dict, + current_user: User = Depends(authorize_roles("admin")), + db: Session = Depends(get_db) +): + """Update SMTP email server settings (Admin only)""" + try: + smtp_host = smtp_data.get("smtp_host", "").strip() + smtp_port = smtp_data.get("smtp_port", "").strip() + smtp_user = smtp_data.get("smtp_user", "").strip() + smtp_password = smtp_data.get("smtp_password", "").strip() + smtp_from_email = smtp_data.get("smtp_from_email", "").strip() + smtp_from_name = smtp_data.get("smtp_from_name", "").strip() + smtp_use_tls = smtp_data.get("smtp_use_tls", True) + + # Validate required fields if provided + if smtp_host and not smtp_host: + raise HTTPException( + status_code=400, + detail="SMTP host cannot be empty" + ) + + if smtp_port: + try: + port_num = int(smtp_port) + if port_num < 1 or port_num > 65535: + raise HTTPException( + status_code=400, + detail="SMTP port must be between 1 and 65535" + ) + except ValueError: + raise HTTPException( + status_code=400, + detail="SMTP port must be a valid number" + ) + + if smtp_from_email: + # Basic email validation + if "@" not in smtp_from_email or "." not in smtp_from_email.split("@")[1]: + raise HTTPException( + status_code=400, + detail="Invalid email address format for 'From Email'" + ) + + # Helper function to update or create setting + def update_setting(key: str, value: str, description: str): + setting = db.query(SystemSettings).filter( + SystemSettings.key == key + ).first() + + if setting: + setting.value = value + setting.updated_by_id = current_user.id + else: + setting = SystemSettings( + key=key, + value=value, + description=description, + updated_by_id=current_user.id + ) + db.add(setting) + + # Update or create settings (only update if value is provided) + if smtp_host: + update_setting( + "smtp_host", + smtp_host, + "SMTP server hostname (e.g., smtp.gmail.com)" + ) + + if smtp_port: + update_setting( + "smtp_port", + smtp_port, + "SMTP server port (e.g., 587 for STARTTLS, 465 for SSL)" + ) + + if smtp_user: + update_setting( + "smtp_user", + smtp_user, + "SMTP authentication username/email" + ) + + if smtp_password: + update_setting( + "smtp_password", + smtp_password, + "SMTP authentication password (stored securely)" + ) + + if smtp_from_email: + update_setting( + "smtp_from_email", + smtp_from_email, + "Default 'From' email address for outgoing emails" + ) + + if smtp_from_name: + update_setting( + "smtp_from_name", + smtp_from_name, + "Default 'From' name for outgoing emails" + ) + + # Update TLS setting (convert boolean to string) + if smtp_use_tls is not None: + update_setting( + "smtp_use_tls", + "true" if smtp_use_tls else "false", + "Use TLS/SSL for SMTP connection (true for port 465, false for port 587 with STARTTLS)" + ) + + db.commit() + + # Return updated settings with masked password + def mask_password(password_value: str) -> str: + if not password_value or len(password_value) < 4: + return "" + return "*" * (len(password_value) - 4) + password_value[-4:] + + # Get updated settings + updated_settings = {} + for key in ["smtp_host", "smtp_port", "smtp_user", "smtp_password", "smtp_from_email", "smtp_from_name", "smtp_use_tls"]: + setting = db.query(SystemSettings).filter( + SystemSettings.key == key + ).first() + if setting: + updated_settings[key] = setting.value + + result = { + "smtp_host": updated_settings.get("smtp_host", ""), + "smtp_port": updated_settings.get("smtp_port", ""), + "smtp_user": updated_settings.get("smtp_user", ""), + "smtp_password": smtp_password if smtp_password else "", + "smtp_password_masked": mask_password(updated_settings.get("smtp_password", "")), + "smtp_from_email": updated_settings.get("smtp_from_email", ""), + "smtp_from_name": updated_settings.get("smtp_from_name", ""), + "smtp_use_tls": updated_settings.get("smtp_use_tls", "true").lower() == "true", + "has_host": bool(updated_settings.get("smtp_host")), + "has_user": bool(updated_settings.get("smtp_user")), + "has_password": bool(updated_settings.get("smtp_password")), + } + + # Get updated_by from password setting if it exists + password_setting = db.query(SystemSettings).filter( + SystemSettings.key == "smtp_password" + ).first() + if password_setting: + result["updated_at"] = password_setting.updated_at.isoformat() if password_setting.updated_at else None + result["updated_by"] = password_setting.updated_by.full_name if password_setting.updated_by else None + + return { + "status": "success", + "message": "SMTP settings updated successfully", + "data": result + } + except HTTPException: + raise + except Exception as e: + db.rollback() + raise HTTPException(status_code=500, detail=str(e)) + + +class TestEmailRequest(BaseModel): + email: EmailStr + + +@router.post("/smtp/test") +async def test_smtp_email( + request: TestEmailRequest, + current_user: User = Depends(authorize_roles("admin")), + db: Session = Depends(get_db) +): + """Send a test email to verify SMTP settings (Admin only)""" + try: + test_email = str(request.email) + admin_name = str(current_user.full_name or current_user.email or "Admin") + timestamp_str = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S UTC") + + # Create test email HTML content + test_html = f""" + + + + + + + +
+

✅ SMTP Test Email

+
+
+
+
🎉
+

Email Configuration Test Successful!

+
+ +

This is a test email sent from your Hotel Booking system to verify that the SMTP email settings are configured correctly.

+ +
+ 📧 Test Details: +
    +
  • Recipient: {test_email}
  • +
  • Sent by: {admin_name}
  • +
  • Time: {timestamp_str}
  • +
+
+ +

If you received this email, it means your SMTP server settings are working correctly and the system can send emails through your configured email server.

+ +

What's next?

+
    +
  • Welcome emails for new user registrations
  • +
  • Password reset emails
  • +
  • Booking confirmation emails
  • +
  • Payment notifications
  • +
  • And other system notifications
  • +
+ + +
+ + + """ + + # Plain text version + test_text = f""" +SMTP Test Email + +This is a test email sent from your Hotel Booking system to verify that the SMTP email settings are configured correctly. + +Test Details: +- Recipient: {test_email} +- Sent by: {admin_name} +- Time: {timestamp_str} + +If you received this email, it means your SMTP server settings are working correctly and the system can send emails through your configured email server. + +This is an automated test email from Hotel Booking System +If you did not request this test, please ignore this email. + """.strip() + + # Send the test email + await send_email( + to=test_email, + subject="SMTP Test Email - Hotel Booking System", + html=test_html, + text=test_text + ) + + sent_at = datetime.utcnow() + + return { + "status": "success", + "message": f"Test email sent successfully to {test_email}", + "data": { + "recipient": test_email, + "sent_at": sent_at.isoformat() + } + } + except HTTPException: + # Re-raise HTTP exceptions (like validation errors from send_email) + raise + except Exception as e: + error_msg = str(e) + logger.error(f"Error sending test email: {type(e).__name__}: {error_msg}", exc_info=True) + + # Provide more user-friendly error messages + if "SMTP mailer not configured" in error_msg: + raise HTTPException( + status_code=400, + detail="SMTP settings are not fully configured. Please configure SMTP Host, Username, and Password in the Email Server settings." + ) + elif "authentication failed" in error_msg.lower() or "invalid credentials" in error_msg.lower(): + raise HTTPException( + status_code=400, + detail="SMTP authentication failed. Please check your SMTP username and password." + ) + elif "connection" in error_msg.lower() or "timeout" in error_msg.lower(): + raise HTTPException( + status_code=400, + detail=f"Cannot connect to SMTP server. Please check your SMTP host and port settings. Error: {error_msg}" + ) + else: + raise HTTPException( + status_code=500, + detail=f"Failed to send test email: {error_msg}" + ) + + +class UpdateCompanySettingsRequest(BaseModel): + company_name: Optional[str] = None + company_tagline: Optional[str] = None + company_phone: Optional[str] = None + company_email: Optional[str] = None + company_address: Optional[str] = None + + +@router.get("/company") +async def get_company_settings( + db: Session = Depends(get_db) +): + """Get company settings (public endpoint for frontend)""" + try: + setting_keys = [ + "company_name", + "company_tagline", + "company_logo_url", + "company_favicon_url", + "company_phone", + "company_email", + "company_address", + ] + + settings_dict = {} + for key in setting_keys: + setting = db.query(SystemSettings).filter( + SystemSettings.key == key + ).first() + if setting: + settings_dict[key] = setting.value + else: + settings_dict[key] = None + + # Get updated_at and updated_by from logo setting if exists + logo_setting = db.query(SystemSettings).filter( + SystemSettings.key == "company_logo_url" + ).first() + + updated_at = None + updated_by = None + if logo_setting: + updated_at = logo_setting.updated_at.isoformat() if logo_setting.updated_at else None + updated_by = logo_setting.updated_by.full_name if logo_setting.updated_by else None + + return { + "status": "success", + "data": { + "company_name": settings_dict.get("company_name", ""), + "company_tagline": settings_dict.get("company_tagline", ""), + "company_logo_url": settings_dict.get("company_logo_url", ""), + "company_favicon_url": settings_dict.get("company_favicon_url", ""), + "company_phone": settings_dict.get("company_phone", ""), + "company_email": settings_dict.get("company_email", ""), + "company_address": settings_dict.get("company_address", ""), + "updated_at": updated_at, + "updated_by": updated_by, + } + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.put("/company") +async def update_company_settings( + request_data: UpdateCompanySettingsRequest, + current_user: User = Depends(authorize_roles("admin")), + db: Session = Depends(get_db) +): + """Update company settings (Admin only)""" + try: + db_settings = {} + + if request_data.company_name is not None: + db_settings["company_name"] = request_data.company_name + if request_data.company_tagline is not None: + db_settings["company_tagline"] = request_data.company_tagline + if request_data.company_phone is not None: + db_settings["company_phone"] = request_data.company_phone + if request_data.company_email is not None: + db_settings["company_email"] = request_data.company_email + if request_data.company_address is not None: + db_settings["company_address"] = request_data.company_address + + for key, value in db_settings.items(): + # Find or create setting + setting = db.query(SystemSettings).filter( + SystemSettings.key == key + ).first() + + if setting: + # Update existing + setting.value = value if value else None + setting.updated_at = datetime.utcnow() + setting.updated_by_id = current_user.id + else: + # Create new + setting = SystemSettings( + key=key, + value=value if value else None, + updated_by_id=current_user.id + ) + db.add(setting) + + db.commit() + + # Get updated settings + updated_settings = {} + for key in ["company_name", "company_tagline", "company_logo_url", "company_favicon_url", "company_phone", "company_email", "company_address"]: + setting = db.query(SystemSettings).filter( + SystemSettings.key == key + ).first() + if setting: + updated_settings[key] = setting.value + else: + updated_settings[key] = None + + # Get updated_at and updated_by + logo_setting = db.query(SystemSettings).filter( + SystemSettings.key == "company_logo_url" + ).first() + if not logo_setting: + logo_setting = db.query(SystemSettings).filter( + SystemSettings.key == "company_name" + ).first() + + updated_at = None + updated_by = None + if logo_setting: + updated_at = logo_setting.updated_at.isoformat() if logo_setting.updated_at else None + updated_by = logo_setting.updated_by.full_name if logo_setting.updated_by else None + + return { + "status": "success", + "message": "Company settings updated successfully", + "data": { + "company_name": updated_settings.get("company_name", ""), + "company_tagline": updated_settings.get("company_tagline", ""), + "company_logo_url": updated_settings.get("company_logo_url", ""), + "company_favicon_url": updated_settings.get("company_favicon_url", ""), + "company_phone": updated_settings.get("company_phone", ""), + "company_email": updated_settings.get("company_email", ""), + "company_address": updated_settings.get("company_address", ""), + "updated_at": updated_at, + "updated_by": updated_by, + } + } + except HTTPException: + raise + except Exception as e: + db.rollback() + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/company/logo") +async def upload_company_logo( + request: Request, + image: UploadFile = File(...), + current_user: User = Depends(authorize_roles("admin")), + db: Session = Depends(get_db) +): + """Upload company logo (Admin only)""" + try: + # Validate file type + if not image.content_type or not image.content_type.startswith('image/'): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="File must be an image" + ) + + # Validate file size (max 2MB) + content = await image.read() + if len(content) > 2 * 1024 * 1024: # 2MB + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Logo file size must be less than 2MB" + ) + + # Create uploads directory + upload_dir = Path(__file__).parent.parent.parent / "uploads" / "company" + upload_dir.mkdir(parents=True, exist_ok=True) + + # Delete old logo if exists + old_logo_setting = db.query(SystemSettings).filter( + SystemSettings.key == "company_logo_url" + ).first() + + if old_logo_setting and old_logo_setting.value: + old_logo_path = Path(__file__).parent.parent.parent / old_logo_setting.value.lstrip('/') + if old_logo_path.exists() and old_logo_path.is_file(): + try: + old_logo_path.unlink() + except Exception as e: + logger.warning(f"Could not delete old logo: {e}") + + # Generate filename + ext = Path(image.filename).suffix or '.png' + # Always use logo.png to ensure we only have one logo + filename = "logo.png" + file_path = upload_dir / filename + + # Save file + async with aiofiles.open(file_path, 'wb') as f: + await f.write(content) + + # Store the URL in system_settings + image_url = f"/uploads/company/{filename}" + + # Update or create setting + logo_setting = db.query(SystemSettings).filter( + SystemSettings.key == "company_logo_url" + ).first() + + if logo_setting: + logo_setting.value = image_url + logo_setting.updated_at = datetime.utcnow() + logo_setting.updated_by_id = current_user.id + else: + logo_setting = SystemSettings( + key="company_logo_url", + value=image_url, + updated_by_id=current_user.id + ) + db.add(logo_setting) + + db.commit() + + # Return the image URL + base_url = get_base_url(request) + full_url = normalize_image_url(image_url, base_url) + + return { + "status": "success", + "message": "Logo uploaded successfully", + "data": { + "logo_url": image_url, + "full_url": full_url + } + } + except HTTPException: + raise + except Exception as e: + db.rollback() + logger.error(f"Error uploading logo: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/company/favicon") +async def upload_company_favicon( + request: Request, + image: UploadFile = File(...), + current_user: User = Depends(authorize_roles("admin")), + db: Session = Depends(get_db) +): + """Upload company favicon (Admin only)""" + try: + # Validate file type (favicon can be ico, png, svg) + if not image.content_type: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="File type could not be determined" + ) + + allowed_types = ['image/x-icon', 'image/vnd.microsoft.icon', 'image/png', 'image/svg+xml', 'image/ico'] + if image.content_type not in allowed_types: + # Check filename extension as fallback + filename_lower = (image.filename or '').lower() + if not any(filename_lower.endswith(ext) for ext in ['.ico', '.png', '.svg']): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Favicon must be .ico, .png, or .svg file" + ) + + # Validate file size (max 500KB) + content = await image.read() + if len(content) > 500 * 1024: # 500KB + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Favicon file size must be less than 500KB" + ) + + # Create uploads directory + upload_dir = Path(__file__).parent.parent.parent / "uploads" / "company" + upload_dir.mkdir(parents=True, exist_ok=True) + + # Delete old favicon if exists + old_favicon_setting = db.query(SystemSettings).filter( + SystemSettings.key == "company_favicon_url" + ).first() + + if old_favicon_setting and old_favicon_setting.value: + old_favicon_path = Path(__file__).parent.parent.parent / old_favicon_setting.value.lstrip('/') + if old_favicon_path.exists() and old_favicon_path.is_file(): + try: + old_favicon_path.unlink() + except Exception as e: + logger.warning(f"Could not delete old favicon: {e}") + + # Generate filename - preserve extension but use standard name + filename_lower = (image.filename or '').lower() + if filename_lower.endswith('.ico'): + filename = "favicon.ico" + elif filename_lower.endswith('.svg'): + filename = "favicon.svg" + else: + filename = "favicon.png" + + file_path = upload_dir / filename + + # Save file + async with aiofiles.open(file_path, 'wb') as f: + await f.write(content) + + # Store the URL in system_settings + image_url = f"/uploads/company/{filename}" + + # Update or create setting + favicon_setting = db.query(SystemSettings).filter( + SystemSettings.key == "company_favicon_url" + ).first() + + if favicon_setting: + favicon_setting.value = image_url + favicon_setting.updated_at = datetime.utcnow() + favicon_setting.updated_by_id = current_user.id + else: + favicon_setting = SystemSettings( + key="company_favicon_url", + value=image_url, + updated_by_id=current_user.id + ) + db.add(favicon_setting) + + db.commit() + + # Return the image URL + base_url = get_base_url(request) + full_url = normalize_image_url(image_url, base_url) + + return { + "status": "success", + "message": "Favicon uploaded successfully", + "data": { + "favicon_url": image_url, + "full_url": full_url + } + } + except HTTPException: + raise + except Exception as e: + db.rollback() + logger.error(f"Error uploading favicon: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + diff --git a/Backend/src/utils/__pycache__/email_templates.cpython-312.pyc b/Backend/src/utils/__pycache__/email_templates.cpython-312.pyc index f446400c75bc406457fc927542acc05d431ba493..6414cf352d73f5d8c6e78764c6088e0869032ef3 100644 GIT binary patch literal 29773 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 M6#Y7mrF8xN2iMQdyZ`_I diff --git a/Backend/src/utils/__pycache__/mailer.cpython-312.pyc b/Backend/src/utils/__pycache__/mailer.cpython-312.pyc index 40b320bd1e8646551b7b1ccc1875b527345c6816..99b84e6547bb431b16dd620ad010e64c2f6aec6e 100644 GIT binary patch literal 6626 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 BBAx&M delta 2143 zcmah~T})F~96zV`_T%1u(%##amO`Nlw4k7Pp|7fAu#CYfJJE#3>Zl$PCl6&<(M zIJY#*UdTzNi6J4`!z5t9Y{`5Zds&=y38fF!#%%5@*`v#RSe8BP+~T!tnt77*KmYst zfB(<_v_Ewe>n%C6nFe@WgPz!DmKlp1UY=>%Pig?KLo*Z;;^_sb$N@NM!ms)jLqT zo;H&kv<$D(a%t2Y(%KEW^ec3IMqNb%*)&YeMJU!mAX(MG(tlH!(=%rMOXvf=3H_|` z(wt$u8Gy7w0%#jmW5?c{*xN=Q^@civ!s4$CftSiVW1#3XEz@GROvPPF#aa_*UeKUg z!fUoD+$Yhi;OuFWY`O_JhfHJ2%#vxd zObVo|6*RI=Y~a|mMXFKDMR#Z>BU@C*IQM#0R6(yoDWIa9Q`JpdWwUJMTzjxc2AFe~ zIf%LHGDiqr*&5%hT2@Y}N#$jN^DLltlQZx-MGfagF_Sm*8a>X89X{`xnkWRao~u#r zNz}*&LCg8lHm){Zm9${IPQfu&i!mR@$TEkel^(@|YPtZRaXmHWmjUi3Fpz9w&ND_;grND{0CC0aU2T$VEX`4 zh_8ACYtb3|eoKjnC8d%!k(%NKfkB_zcj|SC)Yup=prXA|OU9C;DfG9!ZC8mpbnz@d zDaBGr?EKOsUou1@$>;}y6VhblY$As1EizAtJ*~)NXD|R`66`bztv6Wj>*OdHc91!mRCo)-wmB=r(p*)UI-r8HOt_uM z1!1_=kPGP)zMW9;4r&k(xjj7tyNL(8wV3}75Q8)UgPNhCphf6HOYWVp8U5-G^iX5G zG*BKrp+^~n4u$k8M7igA@CT+qID*MvhCLH0?tFsZCmhG0%HO?s1ZAo{d!JCAtgA?Q o@|5RBSAp7eMfbaI7(&Yp$5Wu3bK?5B99TbB4n8a6!aLZQ~&?~ diff --git a/Backend/src/utils/email_templates.py b/Backend/src/utils/email_templates.py index b70ef0a9..42a4b614 100644 --- a/Backend/src/utils/email_templates.py +++ b/Backend/src/utils/email_templates.py @@ -3,10 +3,123 @@ Email templates for various notifications """ from datetime import datetime from typing import Optional +from ..config.database import SessionLocal +from ..models.system_settings import SystemSettings -def get_base_template(content: str, title: str = "Hotel Booking") -> str: - """Base HTML email template""" +def _get_company_settings(): + """Get company settings from database""" + try: + db = SessionLocal() + try: + settings = {} + setting_keys = [ + "company_name", + "company_tagline", + "company_logo_url", + "company_phone", + "company_email", + "company_address", + ] + + for key in setting_keys: + setting = db.query(SystemSettings).filter( + SystemSettings.key == key + ).first() + if setting and setting.value: + settings[key] = setting.value + else: + settings[key] = None + + return settings + finally: + db.close() + except Exception: + return { + "company_name": None, + "company_tagline": None, + "company_logo_url": None, + "company_phone": None, + "company_email": None, + "company_address": None, + } + + +def get_base_template(content: str, title: str = "Hotel Booking", client_url: str = "http://localhost:5173") -> str: + """Luxury HTML email template with premium company branding""" + company_settings = _get_company_settings() + company_name = company_settings.get("company_name") or "Hotel Booking" + company_tagline = company_settings.get("company_tagline") or "Excellence Redefined" + company_logo_url = company_settings.get("company_logo_url") + company_phone = company_settings.get("company_phone") + company_email = company_settings.get("company_email") + company_address = company_settings.get("company_address") + + # Build logo HTML if logo exists + logo_html = "" + if company_logo_url: + # Convert relative URL to absolute if needed + if not company_logo_url.startswith('http'): + # Try to construct full URL + server_url = client_url.replace('://localhost:5173', '').replace('://localhost:3000', '') + if not server_url.startswith('http'): + server_url = f"http://{server_url}" if ':' not in server_url.split('//')[-1] else server_url + full_logo_url = f"{server_url}{company_logo_url}" if company_logo_url.startswith('/') else f"{server_url}/{company_logo_url}" + else: + full_logo_url = company_logo_url + + logo_html = f''' +
+ {company_name} + {f'

{company_tagline}

' if company_tagline else ''} +
+ ''' + else: + logo_html = f''' +
+

{company_name}

+ {f'

{company_tagline}

' if company_tagline else ''} +
+ ''' + + # Build footer contact info + footer_contact = "" + if company_phone or company_email or company_address: + footer_contact = ''' +
+ + ''' + if company_phone: + footer_contact += f''' + + + + ''' + if company_email: + footer_contact += f''' + + + + ''' + if company_address: + # Replace newlines with
for address + formatted_address = company_address.replace('\n', '
') + footer_contact += f''' + + + + ''' + footer_contact += ''' +
+

📞 {company_phone}

+
+

✉️ {company_email}

+
+

📍 {formatted_address}

+
+
+ ''' + return f""" @@ -14,19 +127,22 @@ def get_base_template(content: str, title: str = "Hotel Booking") -> str: {title} + - - + +
- -
-

Hotel Booking

+
+ {logo_html}
- + - - - + + + + """ content = f""" -

Payment Received

-

Dear {guest_name},

-

We have successfully received your payment for booking {booking_number}.

+
+
+
+ 💳 +
+
+
+

Payment Received

+

Dear {guest_name},

+

We have successfully received your payment for booking {booking_number}.

-
-

Payment Details

+
+

Payment Details

+ - @@ -34,9 +150,10 @@ def get_base_template(content: str, title: str = "Hotel Booking") -> str: -
+ {content}
-

This is an automated email. Please do not reply.

-

© {datetime.now().year} Hotel Booking. All rights reserved.

+
+

This is an automated email. Please do not reply.

+

© {datetime.now().year} {company_name}. All rights reserved.

+ {footer_contact}
@@ -47,51 +164,89 @@ def get_base_template(content: str, title: str = "Hotel Booking") -> str: def welcome_email_template(name: str, email: str, client_url: str) -> str: """Welcome email template for new registrations""" + company_settings = _get_company_settings() + company_name = company_settings.get("company_name") or "Hotel Booking" + content = f""" -

Welcome {name}!

-

Thank you for registering an account at Hotel Booking.

-

Your account has been successfully created with email: {email}

-
-

You can:

-
    -
  • Search and book hotel rooms
  • -
  • Manage your bookings
  • -
  • Update your personal information
  • +
    +
    +
    + +
    +
    +
    +

    Welcome, {name}!

    +

    We are delighted to welcome you to {company_name}.

    +

    Your account has been successfully created with email: {email}

    + +
    +

    🎁 What you can do:

    +
      +
    • Search and book our exquisite hotel rooms
    • +
    • Manage your bookings with ease
    • +
    • Update your personal information anytime
    • +
    • Enjoy exclusive member benefits and offers
    -

    - - Login Now + +

    """ - return get_base_template(content, "Welcome to Hotel Booking") + return get_base_template(content, f"Welcome to {company_name}", client_url) def password_reset_email_template(reset_url: str) -> str: """Password reset email template""" content = f""" -

    Password Reset Request

    -

    You (or someone) has requested to reset your password.

    -

    Click the link below to reset your password. This link will expire in 1 hour:

    -

    - +

    +

    Password Reset Request

    +

    A password reset request has been received for your account.

    +

    Click the button below to reset your password. This link will expire in 1 hour.

    + +
    + Reset Password -

    -

    If you did not request this, please ignore this email.

    +
    + +
    +

    ⚠️ If you did not request this password reset, please ignore this email and your password will remain unchanged.

    +
    """ - return get_base_template(content, "Password Reset") + company_settings = _get_company_settings() + company_name = company_settings.get("company_name") or "Hotel Booking" + return get_base_template(content, f"Password Reset - {company_name}", reset_url.split('/reset-password')[0] if '/reset-password' in reset_url else "http://localhost:5173") def password_changed_email_template(email: str) -> str: """Password changed confirmation email template""" content = f""" -

    Password Changed Successfully

    -

    The password for account {email} has been changed successfully.

    -

    If you did not make this change, please contact our support team immediately.

    +
    +
    +
    + +
    +
    +
    +

    Password Changed Successfully

    +

    The password for account {email} has been changed successfully.

    + +
    +

    🔒 If you did not make this change, please contact our support team immediately to secure your account.

    +
    """ - return get_base_template(content, "Password Changed") + company_settings = _get_company_settings() + company_name = company_settings.get("company_name") or "Hotel Booking" + return get_base_template(content, f"Password Changed - {company_name}", "http://localhost:5173") def booking_confirmation_email_template( @@ -111,57 +266,66 @@ def booking_confirmation_email_template( deposit_info = "" if requires_deposit and deposit_amount: deposit_info = f""" -
    -

    ⚠️ Deposit Required

    -

    Please pay a deposit of €{deposit_amount:.2f} to confirm your booking.

    -

    Your booking will be confirmed once the deposit is received.

    +
    +

    ⚠️ Deposit Required

    +

    Please pay a deposit of €{deposit_amount:.2f} to confirm your booking.

    +

    Your booking will be confirmed once the deposit is received.

    """ content = f""" -

    Booking Confirmation

    -

    Dear {guest_name},

    -

    Thank you for your booking! We have received your reservation request.

    +
    +
    +
    + 🏨 +
    +
    +
    +

    Booking Confirmation

    +

    Dear {guest_name},

    +

    Thank you for choosing us! We have received your reservation request and are delighted to welcome you.

    -
    -

    Booking Details

    +
    +

    Booking Details

    - - + + + + + + - - + + + + + + - - + + - - - - - - - - - - - + + +
    Booking Number:{booking_number}Booking Number:{booking_number}
    Room:{room_type} - Room {room_number}
    Room:{room_type} - Room {room_number}Check-in:{check_in}
    Check-out:{check_out}
    Check-in:{check_in}Guests:{num_guests} guest{'s' if num_guests > 1 else ''}
    Check-out:{check_out}
    Guests:{num_guests}
    Total Price:€{total_price:.2f}
    Total Price:€{total_price:.2f}
    {deposit_info} -

    - +

    """ - return get_base_template(content, "Booking Confirmation") + company_settings = _get_company_settings() + company_name = company_settings.get("company_name") or "Hotel Booking" + return get_base_template(content, f"Booking Confirmation - {company_name}", client_url) def payment_confirmation_email_template( @@ -176,45 +340,54 @@ def payment_confirmation_email_template( transaction_info = "" if transaction_id: transaction_info = f""" -
Transaction ID:{transaction_id}
Transaction ID:{transaction_id}
- - + + - - - - - - - + + + {transaction_info} + + + +
Booking Number:{booking_number}Booking Number:{booking_number}
Amount:€{amount:.2f}
Payment Method:{payment_method}
Payment Method:{payment_method}
Amount Paid:€{amount:.2f}
-

Your booking is now confirmed. We look forward to hosting you!

+

✨ Your booking is now confirmed. We look forward to hosting you!

-

- +

""" - return get_base_template(content, "Payment Confirmation") + company_settings = _get_company_settings() + company_name = company_settings.get("company_name") or "Hotel Booking" + return get_base_template(content, f"Payment Confirmation - {company_name}", client_url) def booking_status_changed_email_template( @@ -225,37 +398,47 @@ def booking_status_changed_email_template( ) -> str: """Booking status change email template""" status_colors = { - "confirmed": ("#10B981", "Confirmed"), - "cancelled": ("#EF4444", "Cancelled"), - "checked_in": ("#3B82F6", "Checked In"), - "checked_out": ("#8B5CF6", "Checked Out"), + "confirmed": ("#10B981", "Confirmed", "✅", "#ecfdf5", "#d1fae5"), + "cancelled": ("#EF4444", "Cancelled", "❌", "#fef2f2", "#fee2e2"), + "checked_in": ("#3B82F6", "Checked In", "🔑", "#eff6ff", "#dbeafe"), + "checked_out": ("#8B5CF6", "Checked Out", "🏃", "#f5f3ff", "#e9d5ff"), } - color, status_text = status_colors.get(status.lower(), ("#6B7280", status.title())) + color, status_text, icon, bg_start, bg_end = status_colors.get(status.lower(), ("#6B7280", status.title(), "📋", "#f3f4f6", "#e5e7eb")) content = f""" -

Booking Status Updated

-

Dear {guest_name},

-

Your booking status has been updated.

+
+
+
+ {icon} +
+
+
+

Booking Status Updated

+

Dear {guest_name},

+

Your booking status has been updated.

-
+
+

Status Information

- - + + - - - + + +
Booking Number:{booking_number}Booking Number:{booking_number}
New Status:{status_text}
New Status:{status_text}
-

- +

""" - return get_base_template(content, f"Booking {status_text}") + company_settings = _get_company_settings() + company_name = company_settings.get("company_name") or "Hotel Booking" + return get_base_template(content, f"Booking {status_text} - {company_name}", client_url) diff --git a/Backend/src/utils/mailer.py b/Backend/src/utils/mailer.py index d059a589..dfdcb627 100644 --- a/Backend/src/utils/mailer.py +++ b/Backend/src/utils/mailer.py @@ -4,33 +4,89 @@ from email.mime.multipart import MIMEMultipart import os import logging from ..config.settings import settings +from ..config.database import SessionLocal +from ..models.system_settings import SystemSettings logger = logging.getLogger(__name__) +def _get_smtp_settings_from_db(): + """ + Get SMTP settings from system_settings table. + Returns dict with settings or None if not available. + """ + try: + db = SessionLocal() + try: + smtp_settings = {} + setting_keys = [ + "smtp_host", + "smtp_port", + "smtp_user", + "smtp_password", + "smtp_from_email", + "smtp_from_name", + "smtp_use_tls", + ] + + for key in setting_keys: + setting = db.query(SystemSettings).filter( + SystemSettings.key == key + ).first() + if setting and setting.value: + smtp_settings[key] = setting.value + + # Only return if we have at least host, user, and password + if smtp_settings.get("smtp_host") and smtp_settings.get("smtp_user") and smtp_settings.get("smtp_password"): + return smtp_settings + return None + finally: + db.close() + except Exception as e: + logger.debug(f"Could not fetch SMTP settings from database: {str(e)}") + return None + + async def send_email(to: str, subject: str, html: str = None, text: str = None): """ Send email using SMTP - Uses settings from config/settings.py with fallback to environment variables + Uses system_settings first, then falls back to config/settings.py and environment variables """ try: - # Get SMTP settings from settings.py, fallback to env vars - mail_host = settings.SMTP_HOST or os.getenv("MAIL_HOST") - mail_user = settings.SMTP_USER or os.getenv("MAIL_USER") - mail_pass = settings.SMTP_PASSWORD or os.getenv("MAIL_PASS") - mail_port = settings.SMTP_PORT or int(os.getenv("MAIL_PORT", "587")) - mail_secure = os.getenv("MAIL_SECURE", "false").lower() == "true" - client_url = settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173") + # Try to get SMTP settings from database first + db_smtp_settings = _get_smtp_settings_from_db() - # Get from address - prefer settings, then env, then generate from client_url - from_address = settings.SMTP_FROM_EMAIL or os.getenv("MAIL_FROM") - if not from_address: - # Generate from client_url if not set - domain = client_url.replace('https://', '').replace('http://', '').split('/')[0] - from_address = f"no-reply@{domain}" + if db_smtp_settings: + # Use settings from database + mail_host = db_smtp_settings.get("smtp_host") + mail_user = db_smtp_settings.get("smtp_user") + mail_pass = db_smtp_settings.get("smtp_password") + mail_port = int(db_smtp_settings.get("smtp_port", "587")) + mail_use_tls = db_smtp_settings.get("smtp_use_tls", "true").lower() == "true" + from_address = db_smtp_settings.get("smtp_from_email") + from_name = db_smtp_settings.get("smtp_from_name", "Hotel Booking") + logger.info("Using SMTP settings from system_settings database") + else: + # Fallback to config/settings.py and env vars + mail_host = settings.SMTP_HOST or os.getenv("MAIL_HOST") + mail_user = settings.SMTP_USER or os.getenv("MAIL_USER") + mail_pass = settings.SMTP_PASSWORD or os.getenv("MAIL_PASS") + mail_port = settings.SMTP_PORT or int(os.getenv("MAIL_PORT", "587")) + mail_secure = os.getenv("MAIL_SECURE", "false").lower() == "true" + mail_use_tls = mail_secure # For backward compatibility + client_url = settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173") + + # Get from address - prefer settings, then env, then generate from client_url + from_address = settings.SMTP_FROM_EMAIL or os.getenv("MAIL_FROM") + if not from_address: + # Generate from client_url if not set + domain = client_url.replace('https://', '').replace('http://', '').split('/')[0] + from_address = f"no-reply@{domain}" + + # Use from name if available + from_name = settings.SMTP_FROM_NAME or "Hotel Booking" + logger.info("Using SMTP settings from config/environment variables") - # Use from name if available - from_name = settings.SMTP_FROM_NAME or "Hotel Booking" from_header = f"{from_name} <{from_address}>" if not (mail_host and mail_user and mail_pass): @@ -54,10 +110,10 @@ async def send_email(to: str, subject: str, html: str = None, text: str = None): message.attach(MIMEText("", "plain")) # Determine TLS/SSL settings - # For port 587: use STARTTLS (use_tls=False, start_tls=True) # For port 465: use SSL/TLS (use_tls=True, start_tls=False) + # For port 587: use STARTTLS (use_tls=False, start_tls=True) # For port 25: plain (usually not used for authenticated sending) - if mail_port == 465 or mail_secure: + if mail_port == 465 or mail_use_tls: # SSL/TLS connection (port 465) use_tls = True start_tls = False diff --git a/Frontend/src/App.tsx b/Frontend/src/App.tsx index 94e626c7..96973d43 100644 --- a/Frontend/src/App.tsx +++ b/Frontend/src/App.tsx @@ -10,6 +10,7 @@ import 'react-toastify/dist/ReactToastify.css'; import { GlobalLoadingProvider } from './contexts/GlobalLoadingContext'; import { CookieConsentProvider } from './contexts/CookieConsentContext'; import { CurrencyProvider } from './contexts/CurrencyContext'; +import { CompanySettingsProvider } from './contexts/CompanySettingsContext'; import OfflineIndicator from './components/common/OfflineIndicator'; import CookieConsentBanner from './components/common/CookieConsentBanner'; import AnalyticsLoader from './components/common/AnalyticsLoader'; @@ -121,6 +122,7 @@ function App() { + + diff --git a/Frontend/src/components/layout/Footer.tsx b/Frontend/src/components/layout/Footer.tsx index 166117fb..5f098593 100644 --- a/Frontend/src/components/layout/Footer.tsx +++ b/Frontend/src/components/layout/Footer.tsx @@ -12,13 +12,26 @@ import { Youtube, Award, Shield, - Star + Star, + Trophy, + Medal, + BadgeCheck, + CheckCircle, + Heart, + Crown, + Gem, + Zap, + Target, + TrendingUp, + LucideIcon } from 'lucide-react'; import CookiePreferencesLink from '../common/CookiePreferencesLink'; import { pageContentService } from '../../services/api'; import type { PageContent } from '../../services/api/pageContentService'; +import { useCompanySettings } from '../../contexts/CompanySettingsContext'; const Footer: React.FC = () => { + const { settings } = useCompanySettings(); const [pageContent, setPageContent] = useState(null); useEffect(() => { @@ -37,6 +50,38 @@ const Footer: React.FC = () => { fetchPageContent(); }, []); + // Get phone, email, and address from centralized company settings + const displayPhone = settings.company_phone || null; + const displayEmail = settings.company_email || null; + const displayAddress = settings.company_address || '123 ABC Street, District 1\nHo Chi Minh City, Vietnam'; + + // Get logo URL from centralized company settings + const logoUrl = settings.company_logo_url + ? (settings.company_logo_url.startsWith('http') + ? settings.company_logo_url + : `${import.meta.env.VITE_API_URL || 'http://localhost:8000'}${settings.company_logo_url}`) + : null; + + // Icon map for badges + const iconMap: Record = { + Award, + Star, + Trophy, + Medal, + BadgeCheck, + CheckCircle, + Shield, + Heart, + Crown, + Gem, + Zap, + Target, + TrendingUp, + }; + + // Get badges from page content + const badges = pageContent?.badges || []; + // Default links const defaultQuickLinks = [ { label: 'Home', url: '/' }, @@ -76,16 +121,26 @@ const Footer: React.FC = () => { {/* Company Info - Enhanced */}
-
- -
-
+ {logoUrl ? ( +
+ {settings.company_name} +
+ ) : ( +
+ +
+
+ )}
- {pageContent?.title || 'Luxury Hotel'} + {settings.company_name || pageContent?.title || 'Luxury Hotel'}

- Excellence Redefined + {settings.company_tagline || 'Excellence Redefined'}

@@ -94,16 +149,20 @@ const Footer: React.FC = () => {

{/* Premium Certifications */} -
-
- - 5-Star Rated + {badges.length > 0 && badges.some(b => b.text) && ( +
+ {badges.map((badge, index) => { + if (!badge.text) return null; + const BadgeIcon = iconMap[badge.icon] || Award; + return ( +
+ + {badge.text} +
+ ); + })}
-
- - Award Winning -
-
+ )} {/* Social Media - Premium Style */}
@@ -225,37 +284,37 @@ const Footer: React.FC = () => {
- {((pageContent?.contact_info?.address || '123 ABC Street, District 1\nHo Chi Minh City, Vietnam') + {(displayAddress .split('\n').map((line, i) => ( {line} - {i < 1 &&
} + {i < displayAddress.split('\n').length - 1 &&
}
)))}
-
  • -
    - -
    -
    - {pageContent?.contact_info?.phone && ( - - {pageContent.contact_info.phone} + {displayPhone && ( +
  • +
    + +
    +
    +
    + {displayPhone} - )} -
  • -
  • -
    - -
    -
    - {pageContent?.contact_info?.email && ( - - {pageContent.contact_info.email} +
  • + )} + {displayEmail && ( +
  • +
    + +
    +
    +
    + {displayEmail} - )} -
  • + + )} {/* Star Rating Display */} diff --git a/Frontend/src/components/layout/Header.tsx b/Frontend/src/components/layout/Header.tsx index feac7ee0..ea9dad06 100644 --- a/Frontend/src/components/layout/Header.tsx +++ b/Frontend/src/components/layout/Header.tsx @@ -12,9 +12,9 @@ import { Phone, Mail, Calendar, - MessageSquare, } from 'lucide-react'; import { useClickOutside } from '../../hooks/useClickOutside'; +import { useCompanySettings } from '../../contexts/CompanySettingsContext'; interface HeaderProps { isAuthenticated?: boolean; @@ -32,6 +32,16 @@ const Header: React.FC = ({ userInfo = null, onLogout }) => { + const { settings } = useCompanySettings(); + + // Get phone and email from centralized company settings + const displayPhone = settings.company_phone || '+1 (234) 567-890'; + const displayEmail = settings.company_email || 'info@luxuryhotel.com'; + const logoUrl = settings.company_logo_url + ? (settings.company_logo_url.startsWith('http') + ? settings.company_logo_url + : `${import.meta.env.VITE_API_URL || 'http://localhost:8000'}${settings.company_logo_url}`) + : null; const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); const [isUserMenuOpen, setIsUserMenuOpen] = useState(false); @@ -66,14 +76,18 @@ const Header: React.FC = ({ @@ -87,16 +101,26 @@ const Header: React.FC = ({ className="flex items-center space-x-3 group transition-all duration-300 hover:opacity-90" > -
    -
    - -
    + {logoUrl ? ( +
    + {settings.company_name} +
    + ) : ( +
    +
    + +
    + )}
    - Luxury Hotel + {settings.company_name} - Excellence Redefined + {settings.company_tagline || 'Excellence Redefined'}
    diff --git a/Frontend/src/components/rooms/RoomAmenities.tsx b/Frontend/src/components/rooms/RoomAmenities.tsx index ae8a7b34..87936311 100644 --- a/Frontend/src/components/rooms/RoomAmenities.tsx +++ b/Frontend/src/components/rooms/RoomAmenities.tsx @@ -710,7 +710,7 @@ const RoomAmenities: React.FC = ({
    - {safeAmenities.slice(0, 10).map((amenity, index) => ( + {safeAmenities.map((amenity, index) => (
    link.remove()); + + // Add new favicon + const link = document.createElement('link'); + link.rel = 'icon'; + link.href = faviconUrl; + document.head.appendChild(link); + } + + // Update page title if company name is set + if (response.data.company_name) { + document.title = response.data.company_name; + } + } + } catch (error) { + console.error('Error loading company settings:', error); + // Keep default settings + setSettings(defaultSettings); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + loadSettings(); + + // Listen for refresh events from settings page + const handleRefresh = () => { + loadSettings(); + }; + + if (typeof window !== 'undefined') { + window.addEventListener('refreshCompanySettings', handleRefresh); + return () => { + window.removeEventListener('refreshCompanySettings', handleRefresh); + }; + } + }, []); + + const refreshSettings = async () => { + await loadSettings(); + }; + + return ( + + {children} + + ); +}; + diff --git a/Frontend/src/pages/AboutPage.tsx b/Frontend/src/pages/AboutPage.tsx index ddf7c3d1..661ebae6 100644 --- a/Frontend/src/pages/AboutPage.tsx +++ b/Frontend/src/pages/AboutPage.tsx @@ -14,8 +14,10 @@ import { import { Link } from 'react-router-dom'; import { pageContentService } from '../services/api'; import type { PageContent } from '../services/api/pageContentService'; +import { useCompanySettings } from '../contexts/CompanySettingsContext'; const AboutPage: React.FC = () => { + const { settings } = useCompanySettings(); const [pageContent, setPageContent] = useState(null); useEffect(() => { @@ -48,6 +50,11 @@ const AboutPage: React.FC = () => { fetchPageContent(); }, []); + // Get phone, email, and address from centralized company settings + const displayPhone = settings.company_phone || '+1 (234) 567-890'; + const displayEmail = settings.company_email || 'info@luxuryhotel.com'; + const displayAddress = settings.company_address || '123 Luxury Street\nCity, State 12345\nCountry'; + // Default values const defaultValues = [ { @@ -253,11 +260,11 @@ const AboutPage: React.FC = () => { Address

    - {(pageContent?.contact_info?.address || '123 Luxury Street\nCity, State 12345\nCountry') + {displayAddress .split('\n').map((line, i) => ( {line} - {i < 2 &&
    } + {i < displayAddress.split('\n').length - 1 &&
    }
    ))}

    @@ -270,8 +277,8 @@ const AboutPage: React.FC = () => { Phone

    - - {pageContent?.contact_info?.phone || '+1 (234) 567-890'} + + {displayPhone}

    @@ -283,8 +290,8 @@ const AboutPage: React.FC = () => { Email

    - - {pageContent?.contact_info?.email || 'info@luxuryhotel.com'} + + {displayEmail}

    diff --git a/Frontend/src/pages/ContactPage.tsx b/Frontend/src/pages/ContactPage.tsx index 0ddf6a00..bb80a4cc 100644 --- a/Frontend/src/pages/ContactPage.tsx +++ b/Frontend/src/pages/ContactPage.tsx @@ -3,9 +3,11 @@ import { Mail, Phone, MapPin, Send, User, MessageSquare } from 'lucide-react'; import { submitContactForm } from '../services/api/contactService'; import { pageContentService } from '../services/api'; import type { PageContent } from '../services/api/pageContentService'; +import { useCompanySettings } from '../contexts/CompanySettingsContext'; import { toast } from 'react-toastify'; const ContactPage: React.FC = () => { + const { settings } = useCompanySettings(); const [pageContent, setPageContent] = useState(null); const [formData, setFormData] = useState({ name: '', @@ -103,6 +105,11 @@ const ContactPage: React.FC = () => { fetchPageContent(); }, []); + // Get phone, email, and address from centralized company settings + const displayPhone = settings.company_phone || 'Available 24/7 for your convenience'; + const displayEmail = settings.company_email || "We'll respond within 24 hours"; + const displayAddress = settings.company_address || 'Visit us at our hotel reception'; + const handleChange = (e: React.ChangeEvent) => { const { name, value } = e.target; setFormData((prev) => ({ ...prev, [name]: value })); @@ -181,9 +188,9 @@ const ContactPage: React.FC = () => {

    Email

    -

    - {pageContent?.contact_info?.email || "We'll respond within 24 hours"} -

    + + {displayEmail} +
    @@ -193,9 +200,9 @@ const ContactPage: React.FC = () => {

    Phone

    -

    - {pageContent?.contact_info?.phone || 'Available 24/7 for your convenience'} -

    + + {displayPhone} +
    @@ -205,8 +212,8 @@ const ContactPage: React.FC = () => {

    Location

    -

    - {pageContent?.contact_info?.address || 'Visit us at our hotel reception'} +

    + {displayAddress}

    @@ -236,8 +243,7 @@ const ContactPage: React.FC = () => {

    - Our team is here to help you with any questions about your stay, - bookings, or special requests. We're committed to exceeding your expectations. + {pageContent?.content || "Our team is here to help you with any questions about your stay, bookings, or special requests. We're committed to exceeding your expectations."}

    diff --git a/Frontend/src/pages/admin/PageContentDashboard.tsx b/Frontend/src/pages/admin/PageContentDashboard.tsx index 1ca92592..d52089e8 100644 --- a/Frontend/src/pages/admin/PageContentDashboard.tsx +++ b/Frontend/src/pages/admin/PageContentDashboard.tsx @@ -23,7 +23,9 @@ import { Upload, Loader2, Check, - XCircle + XCircle, + Award, + Shield } from 'lucide-react'; import { pageContentService, PageContent, PageType, UpdatePageContentData, bannerService, Banner } from '../../services/api'; import { toast } from 'react-toastify'; @@ -138,7 +140,6 @@ const PageContentDashboard: React.FC = () => { subtitle: contents.contact.subtitle || '', description: contents.contact.description || '', content: contents.contact.content || '', - contact_info: contents.contact.contact_info || { phone: '', email: '', address: '' }, map_url: contents.contact.map_url || '', meta_title: contents.contact.meta_title || '', meta_description: contents.contact.meta_description || '', @@ -165,9 +166,9 @@ const PageContentDashboard: React.FC = () => { setFooterData({ title: contents.footer.title || '', description: contents.footer.description || '', - contact_info: contents.footer.contact_info || { phone: '', email: '', address: '' }, social_links: contents.footer.social_links || {}, footer_links: contents.footer.footer_links || { quick_links: [], support_links: [] }, + badges: contents.footer.badges || [], meta_title: contents.footer.meta_title || '', meta_description: contents.footer.meta_description || '', }); @@ -190,7 +191,13 @@ const PageContentDashboard: React.FC = () => { const handleSave = async (pageType: PageType, data: UpdatePageContentData) => { try { setSaving(true); - await pageContentService.updatePageContent(pageType, data); + // Remove contact_info for contact and footer pages since it's now managed centrally + const { contact_info, ...dataToSave } = data; + if (pageType === 'contact' || pageType === 'footer') { + await pageContentService.updatePageContent(pageType, dataToSave); + } else { + await pageContentService.updatePageContent(pageType, data); + } toast.success(`${pageType.charAt(0).toUpperCase() + pageType.slice(1)} content saved successfully`); await fetchAllPageContents(); } catch (error: any) { @@ -993,44 +1000,11 @@ const PageContentDashboard: React.FC = () => {
    -

    Contact Information

    -
    -
    - - setContactData({ - ...contactData, - contact_info: { ...contactData.contact_info, phone: e.target.value } - })} - className="w-full px-4 py-3 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200" - /> -
    -
    - - setContactData({ - ...contactData, - contact_info: { ...contactData.contact_info, email: e.target.value } - })} - className="w-full px-4 py-3 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200" - /> -
    -
    - - setContactData({ - ...contactData, - contact_info: { ...contactData.contact_info, address: e.target.value } - })} - className="w-full px-4 py-3 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200" - /> -
    +
    +

    + Note: Contact information (phone, email, address) is now managed centrally in Settings → Company Info. + These fields will be displayed across the entire application. +

    @@ -1065,6 +1039,23 @@ const PageContentDashboard: React.FC = () => {
    +
    +

    Help Message

    +
    + +