From 9c277e6db88c17f5fe765524039e8159d7bb8f51 Mon Sep 17 00:00:00 2001 From: dimitar Date: Tue, 24 Jun 2025 21:46:54 +0200 Subject: [PATCH] backup --- docs.md | 8 + ecomzone-v20.zip => ecomzone-v20-working.zip | Bin ecomzone-v21.zip | Bin 0 -> 24952 bytes ecomzone-v22-mark3.7.zip | Bin 0 -> 37982 bytes ecomzone/README.md | 61 ++ ecomzone/api_test.php | 143 ++++ ecomzone/classes/EcomZoneClient.php | 259 +++++++- ecomzone/classes/EcomZoneLogger.php | 203 ++++-- ecomzone/classes/EcomZoneProductSync.php | 460 ++++++++----- ecomzone/classes/interfaces/IProductSync.php | 11 + ecomzone/cron.php | 123 ---- ecomzone/ecomzone.php | 655 +++++++++++++++---- ecomzone/get_token.php | 53 ++ ecomzone/readme_api_fix.txt | 1 + ecomzone/test_api.php | 174 +++++ ecomzone/test_image_download.php | 194 ++++++ ecomzone/test_image_with_auth.php | 318 +++++++++ ecomzone/views/templates/admin/configure.tpl | 514 +++++++++------ 18 files changed, 2506 insertions(+), 671 deletions(-) create mode 100644 docs.md rename ecomzone-v20.zip => ecomzone-v20-working.zip (100%) create mode 100644 ecomzone-v21.zip create mode 100644 ecomzone-v22-mark3.7.zip create mode 100644 ecomzone/README.md create mode 100644 ecomzone/api_test.php delete mode 100644 ecomzone/cron.php create mode 100644 ecomzone/get_token.php create mode 100644 ecomzone/readme_api_fix.txt create mode 100644 ecomzone/test_api.php create mode 100644 ecomzone/test_image_download.php create mode 100644 ecomzone/test_image_with_auth.php diff --git a/docs.md b/docs.md new file mode 100644 index 0000000..b76e89d --- /dev/null +++ b/docs.md @@ -0,0 +1,8 @@ +# > [!IMPORTANT] + +> dont forget to set up ps_configuration -> ps_ssl + +## TODO + +update price to show recommended retail price in shop, for back office keep price and add recommended retail price. +fix stock, all products are out of stock diff --git a/ecomzone-v20.zip b/ecomzone-v20-working.zip similarity index 100% rename from ecomzone-v20.zip rename to ecomzone-v20-working.zip diff --git a/ecomzone-v21.zip b/ecomzone-v21.zip new file mode 100644 index 0000000000000000000000000000000000000000..9e834d01b04134816852b05d1fb737431b783262 GIT binary patch literal 24952 zcmdpdV|ZmtyKQVc>Daby+eyc^ZKvaoJGSkPZQHi(baFf2KIiLw_C4p^|2NN5Yh?Zz zbFEP|-x@V$mAn)%2nxXGpMRQ+=D+^=j}J%yd;nuZTWb$n8)G^ZWmo|4I*v+Hs$UhR zpXlrc4FCvo1PlQ1uMhcu(Lew|0OUseq1gch06_jbjiIfLiMc7Qo3+)y2&Zu>O}l=# z|Ax>ycHE|i9wF$`9g6KS&hE4uL!>}ls}%RB077J7nUud*4<9^K=PFNSxOI&u;Z2lyqeJ5@tP4)T0ESeP5TfqEw~2$FRX5hpvM&<^E= zHp6u;_iWe+_XoAqBq;erGq3NW{zK*!YJx$Ua~R^PjcnZnZYM4CNSPTMr|9xh;1Cwm z+E1^)JK|HpKicM7%m2HJYTy6>5dVv|4z@P5c4l_}Qg(j5#uSdE()1Jm(N*JWUu{=e zQG6Dw*ij=jd(x#AE8r*6bLrO>v`UX1%aYV)K-9K3+5Hny4x;eR-eYB}Ra~g5;2utpC78wmFTCn)W8Eqfn z+J8j@;Pp1_NvIE?yB6l%lBc|v1zMDsmLN&B8r)6cH0l|B5nBKS0jb8q8yz zwU?q^QM^KPZ2n5jM-C%tVba9CPP3us#y`lOEO^e)*wa8LjU-u2Z$WOin1! zsv7_w9^PPg(+a^IPqH3WNO6IY}){@rJ<4HbJEi&>%$I^Re1yd5I6KAbxC~Qe9xL0`D7FW_2lU&t1Fj}80r05*@$?wk-#z(L$Khmj2^h<={ z5>i-lXVRvzi4`!^%4zw(^$NgO7Fg8gI`a56xawY9G1E^nk|;>mh-4SGsADaLPSuq1 zq&chgP5SYb%HkbgLx9LupecGMvM|`-0BwdvrYayh3*}bPV=0|eRthCL@57t9X4pj`znQrywv#*La+8WHGrLJ-@;pppeYn36=2 z`OB?kTum@tFzJ~%N#$|?Es|W#*hXq$<`)U2*#q+*$FarWo!ESjfJ`Ti^=mTbCKwyP z;Dg6xK*Y@8!+h{06pl=IWL?Ek4ZzyFvsb@`@IVa4FCIQ^u;XGS%}%8TvZFh00+wZrKo#m*#{*P0e>}gz?%S!OkU7(Q=)R`sNJj;3)zf&Yqu4g z2`W-!oC)B=d^EJEl;C$t46fc&K`RcV(QQVZm08PMJCC~U=B)4yBJOZD%F_fM`G<>> z+PmK01@DLhX?O%(f_?Mw?W5cHwFBD4NxGTNPaDHaV0awNAGH5?v)WKU94qk>-Ave9 z&jzw56`j>e2Pe9Ykg2$&J5+=o5~l4=fVdw?6hoHlGAm@^cf% z`igf7>@4s#Z|}L)`3>01>aFv$I80~H5FC9Q_!716?duFy`qBkKv@Us3@DPgH+E2ya z%jr6ISY5<0!z*6z#lw5q%gHvBP;0Cf8*vUbum*fbA*y5;D$G~}cNS>}cPkj-T*9oEsWr76Yl_KE+f$KEk=QoZ~L{g*!BgPDp;eah7{D#tlA;TT6b=rKYr zE7YVZ#;#Pqo^0UIP6aT2@Y(v@{wPYke3Kb8;y|(Re4w>sHZz+0`E~1j&agv^H@kz) zIRlt(TxT4Yu8*r zSQ<%PPKC=HtCxZchCVsc(?M$v&0 zOL_jB_-eU;@X+>CBxEs~U;V%5tWPQah@@WfM(AEBs|KKJgzq zjbm9luCm^=`hxac3(xf@ofo#ZjvTt7NJP)qkz^5@(%ze~Geb%gQv`tWbJ?7J_(1#R zKLL<%oQL)rVTUbgQ$Ms*y`OSxih3GFpRw^~#v{Bo?ypIwPyV_#qQ~M?tqb$n6so>b zBRo4e-Wc7;ME{wd*CD3XIgQX8-IX&r&2qmBaA|%;-U0tD^uYcIQc!4*9T&D)uLfKh zB`74`QkNQ%?+u}aDstj`Fmlg=Ii}#xM7-D;9(dZaDcp8bI9DeD*4xxD7Q3iKw9m%v;e>TjKAs;04XEK=Rd{F zzGM_{E=Pr=ae4sM_)tE>O>kMSI=+bE>*-v3KXamc#cqZP&0VJz=W5Ej+C#<#y||T! z=0>G;6tY2&ks%56h!ibZl4@HYmu$1(+Nzl2*Tm7)p#`>3HQF=0U_gSbsD^!m5%fnN zQ}L4%rjCsjm}|y-yO$0SJ`Ad`5i*7ss7{%M>pe;t_%=8kO2&pFE{{_5bm)s1LFXwN zDkWROo_2FTCmYUx{IUywN7{_lJJU782tOth1$<=vh6j7!gS;{2)9-VaNPfN+DC^9GsAsz9MUc$bKcFe+W(T)34h)i1x4p5gsN#t9A$=Xy22iG{!?L zyf2Rc=5QFvJt!!rEWW2@mtegFN-bPONeSZ(x!j1AGjOv*DL|nciQ7n|GtbGo&V9y) z-dVOPK|#IzE(D{n7kp=jZd9i_7?Q;k zK$p1(K+g9;cs^|M*nOumW3@Ns-3JY~l5e!rq9-pL{C5x5+r@Dd&m-5orB3=^jdz>g zL0T9uZAq$KzS|ajcMojfNT96-2$0{ykVMCdAV;eWuXaEqbk2IB-j09r0d0uypZ_o) zn3-NL#pvS&iJROr&nNC&oXD8yo;`=Q~WhGkEwuTs5XUS zVI43=2c28%*ESw&GEZkb@J4Am43uGw$<&#eSo8&(7LSx}eF~l)gds6;3L6>%l+nz9 zmi3DtkW4KmcwTUu+O*vsEsQ9^Qk?_?62@2`X^z>#cTRq?0#9@4F&pNaVfzW2I`5EX z>DZJ3wF}@2VWaXYbJc?VW(2vT9W7k1ht{2oeVX*WeE7Q@I+b8%SnlGdFtu2>Mr4-@ z>T0;fRLlzH8X9st{K@YeJ-x*0d8IU#Ajw(GahpMmX1&URa&H(Ir>}a)Pk>- z8}IpY>yZQ{PDRKnmB|>JdM#`ja)J7$P&~5?>yr{@aDwAo98(m~@ zzFSBfQFUm}Efl74{`CViPFls91QVRtOFmU`zy);EO3GWh9MK`-q#?CfCC9j3rj^Re z1oHv9p%L)w&&TM69a1L_ct(A)m*lyo44pVc-5GLJ`F4KZ7Hlf_Jz;JpC*BmIGfY`I zuO6552ta0n3BX!hNS_@s1QUHBJ)b>Eb3EfXt1xo%kF+9(WOgGB@YRY()$p)u3$_5mM@x|gn7_%yJ?T7c@+l2$-xRN1HSlc`dm4ntqQO?UDkG{xgk96 zs_klu!(Mi*czt!)$%*IU6V2wo{A!+yFWSYot39jpH56u za5DvbYR21H%iS?aILW~`4P#4N@)e9+2j)eseHbQE@#b=CDvx&|Xv`*eEzl6K4?CFw zB5%YVj`0RGE!2S}z?A)->J8Nsi{Mi0M-2==I;AlL$KENBDFp@yGtz{)M`PQxo*ToA zDtOZ7q++Wti@0_+LCo1{z ztGX0lVyZoM!E_uJ96cns^^&E5E9`v@gJo{`b0!h67AlMl8K}lj3y+jpP6m?MM42#x zN%#3CN|Y7rl#-X-YqK_~w}qM8LCg46nPNpXC`P+xahU3owxc4*K3eAvrl7v&_$Zhj zF*f{;9+Bgeq?G-7bqf0qd*a6YyPWtBmaY)qxMnIHTH@092ygf9VaFqDDg?^i=13rL zt7j#IKq;Wa%MLMLq00{+FE7eNrYma+!+{9cLKIt(#bY;u{bEM3ga~LbYg&zM?zL7V zWCU&!VT#@_9z--}4ueWsR5G6M4m3vPqViB@_Xxv@Z&kfR^ZKwloqPr_7u zW6^X}XP`Ya;{eo(eh@gtkB})Baabl*`1ZbaXhLE27^^(Q`B{5&XyLpZ)yk}_m0pUt zFr+g}=uGzm)wz6W+h{Z*XxTnTn)&%3HF)jb>AxS zn%YJ_^6U{A_A&_$D*C{hO&L~E(MVbTS+0_5?IdSj&C_e{pq>=}oJ*Wjp3!2ZVU8wi zKl`K*joq#&^EQ4W2|?z%DWhUMxrmw`nGvE0;-6-Mp`#5pWCBM0!S#FFz%NmBN&|udTL8% z__{IOL9rT1<(eJCm9dF@=!OPlb!xRnokK>P;z;{KU6dYwZ*Y9z;xh3vHTUK=eW=hY zO(RY-vdUDn$Mt9?_*`&Ywv%szcFgz{HdDDSjugF0VR_)7joXb6EVOQXxkVylkXO91 zMBWY+HH&cAWpRng%gU$+c@>_xAaG|i$urahQDxhjDu@Sh`dYjn#IsJCvt0j7X~83e z{Xjc>HL%N(`~j8jiZJ#MUk61VD{rLL`MxRZBdZxwd^QT+BLD4+!6$NKF)&) zF!fdSvi8#bgwYmxK-CT)(vrfWMHI;pS||FqwPvil89a;(9n%#C)S2fhI#doPuYN%b zm>I?s=KWVC%BWt}xQ0a#&W`o-4{{a576d!_)oW= zf71vM0Sy4~>FNJ+`=7ZXoBJQP|9K7ncen3iZtUv#SCse}BmQUmhtK~*HuJYA@skYm z|0Z)XwzjjKj>`+x+#NMF{^Q|HS{6{4@T9 z{u}w{LWunTP5xh3ifX^vKkx8~|G3Nl=PO0pf-CvRN zvE*N!^ojqFZ(ZZ`ckqeuk=YsG7{O^y&?*s;<7f>pkc1+jMD{Hno(c@4UU*rHTmip? zxbpDBTa3r5oqW{2ybFc6M&#zgEoKuZhAX1!C_2t_S)~seLsi(NW>=xp(s8q3R{nc5 z6oyCZ+VU~i9o#aE6o(n;5?MLRn!weaDPD+oYP7uiw>!Mi1EpoGhgPR=XfVS9uS*9T zt{2Eo2~z{%4xLfl7ceUzV7^^?p39d&-9KtagG0G<;vQh*fgwCE9$tp;s}C<{2Ve)A+M#ZYSjEiFUUM&X5NgEYKFbG! z`L5sH3e$d#OO>~`vw^n-T~vN8l+@g`;CjzQOLaqJ?rs%b(GwFjD%Pa;t2-*5vh_k7 zRNHjrkE$$C=V{Gh)6m_rwCk+AyaI_dGX;iKy*P`IAPGew-kHg7YlsRnGA!-1&Z9)X zY_80p#US*W)2RrgNTKtAMlqz>2lh&d=L zTMG@i+Jwq^4@9es1DABiyFI}j;T{7xLTGF_hDJu(eg74!dd-4mvh<0o$m z)@2Txt^CFKj2TfdS)_Wvkl$;Zg&Z9;+W0UH|21O?2<$gQ#`sjkv$`<2=H3BuMm61B%W(?v3>4vfbM!%<4L3 z@byVs0WOI#2zyZ1zU;lhD-?r)3t&?mP@_05Z=qfjoBnjmm^vL=o%#-?&&L9%E<^L| zn|HkjIs~>7pBY;@=X0;@p@Q3JuU+3rC@0Qh>emOCDE~3(IcGXYmh}ZG@|y|OGNv>3 zPSKTY-k1w88XH<>p{dHMjZ2zhjwY403~P8lj*{<)cpO<{QtOXiTqD{?MC4 zZMAnR&P&0sjDARvBL6|KvS|hg_FCa(lr9e# zD#CM@;71NBRD4QWe%)ozr(& zvR=^3t1=5HZl(m@T?!_E*z!J&`%RvG4MA#K9NNgUTSax;0FqT>`~_=L~f!iLfa|^WvnFIaXYQb10Eg(AL!F2589KM ze0Uqo5t&%}hx5fa=fLhAUc;zvEzpaENpa|a51Ua)K=WD-Q`>~2y026rZ_d5trY5&* z0w)aW(XHUJA$Cv2CLG*1PnSsl_5{~&cCHAso6Aj0kizywSQRs>&K*U_u(KqTP zeN8=^rM{_}=_^4?>`;|7|05xo2pEEqQe|Q$S-6R63NuTUrAO(h%t$S$YlnECNFm?w z1i9wK%Hyd!6)~gL<7oHwvAfr_PvC=g_i>>ulpfzfrSJF#sieRVF6yj7woeHVmenZ4)qsnWRLy!&|IUngw725NED$?|;{ zx){uH>nE_))?N~@5hD)wAvPC=tXK>Xn(?s zAIW!~He8Rauc;EX#ql&aj36-E33$nO;Qp}Kj@1UQCGV&vaEDan0;B)H# z%L9J;Da?QPDZ>BF6#Q2w<@nn{eHy_}{D&3z|JX@=I@*iBiS~Jo@^{h9ZJdl9O!N)^ z(=!7SSDH5del78d|B&fVHyr$paqc%G@&CXmA@5*os0T_kZgE`&;2pl|S(xIyg^G{?-8o z?8-BXFgz623#bSNCD{GPPSl+7*K|4eQu*nrMd%{XVvzws?Wr$@#4=b3*9n9MGoO!5|=!C@(euj`P zE)@dhNp{)sNEjTcJ-a{!;1p1psQ%A2a`X{a?2#Sm`@D z{^$7fl&v&n`mM+({zH{NqBkb|+4*3O1OPzvchL?1gHGi4ZWYbnyH$j&%#CfF{w=#1 zVXdhvWu@sS{^JG>YSxZvq6qJmy7v0aILxt|Gd^)dp30bH79=@@Yc6ru^dPcGEI=OE zH5rlbp01Yw1Ug3Cvuo&EORLF;nd_^bzBtyo(M^OWUy>`^{8~y4Xp$DPQftzW9Hox$ zhaTbl1+us+A85H+We2a%*-2B8ZTNx#ab9s@-NYnC#$GKPC0ktfQnrC%X{lyi$LM5i z&dCBD4yJ#=3{Mtht*y?`Z$BNr)e?P>=z2{Olo8wgL)h%0JEyqq@PV&svECvyf00&bnZlb=PbXg@!yj=eu|yd08t8{0lNVG6yVx4Yfq56@(g;&R+6$PUQu4IlQGCfM*^? zhz%|X+WszQ`DoXq732NW^p>YC{2`jC!N_OwWr6VV9T@#3?4aBEohD#0(=Foy$i z8A<=#*zq%(1G!<#dXn{lCZA(6V^snE7e$B?etl8*o`A9BieLP&FSde)+3(=vAoWwr zJ_myl>eT8-1}!eTq4~~Bv06@rUFbtoU69(8Dq0;A({aA$6~j2xl_xe7n?W`d{2(v5 z9a>9}ZtzH(RY8E6)ra;!K-AkNpglqN<1HhDskJrymSIOB`!l7O*exJ=>hRk1WWFy- zb8d0^$O91VKVYaBUq+BHe=#*R+G_#n0oDsb z9ifPza@Y}alTiWB3hf}sFFwt2oDX7s=-8?4JENg6S$TkM6rxcnmZ%<(umZ| z>>z`IEo$1!y$tgw*o3-=R}?GWDO>zAc!H`0rMrR)>&xnt4e0<o*}xlI%$LO(SqYp90prNIh+87L5Ccd^qsQ8Nix6h|;<#rw2J~FxWKG z1^&a{?JjPHETR>UeQz0yMI%;|^710v5OWc0$)$wjET%QImzlN~6YL5;E`HXe~9eC>1>z8{-)}1dndYF&KSz z^-7XO{Fr)95@sHbe4aNmW^!R{zDRRgMfg_vSrFHpqESzd{%%S_xElYx4CQGkbA86a z9Sdv@(R>&NbW4gaK4rHLmYAk2c z*&-Nhrs^ZUQWNBmsmX(y0Fc*jSX_cTNfWL@wI98D+oo%`rVXLKCWu;~@RnY_h>W&l zQEvNjVWZUNgRNrawx5)e%NHBSoS{1T?s<~r;>3aL3RkkBMY>$!wR$g99jQi0Le{Ro zXG-F-A&icuDKD)qEOi%bQ3L{IHrD0XXujV#v4aV`PCdTd9KR)jwtoC;UQEpt+hYU; z0QfA#e@b*r*`JCZNdW*r`M;Iu|F8)X)^@fIe=7BbN~P(MSf%MF{-aW_Yic;IvLpFC zR`IKsK&e=oGA^^ZpC?ccN4wCB5>YMp?(cLF6YERIVjwp}J6D798xqO&-<;>iaF=5M`Jk~Mr8#ehub|#Kwi~JIaDPYS+j~1u&i=;fL?z{G`84Ki@q3fyJ z=-{5-1BN0VP2G?wbM*JaXZwU&5LIW-0Kv+UtR^d&*?7lhT23LQ>zqwpL{@(e_M74U zcQD&;rwB`3w$*?|u9{hu_7LQ>QR?upyJiw<8UY;WKr_z#s*8bXRJP0=*j~*#%!b1_ zDm57H`amMyE}mqE6!IDAN+gMDukR%Y4RNinE>wJx^u5mJ)keC{q(7kiV15=)w75P= z9SVeT`T9Hot}~?5^LHzR_pe}eA1TCB9cHy5@EPK-wf5_E@JBpv9oW!o`BB&p&uv#h z2-yd`1=+1rMLW67OHI_EkWsTsg&2qu`14y4dXJ=ok5cM30*3nbQ9#6sjmk&nO)*a<(99cLB`M6hB{xKtT zZ@Xh;`tbNZ%=@OL{2Ssz$i#%g{8KawV=o$VPc3nPjB)-QQq`K%8;ipr9q)4Z?XRRe zi=syB&fDet`+mKrs?yWrpZfKE%*243M{c}X*Brp%lSEFNC2D5>?DRf(ox@R)xRO?S5f>VH;NRmOusUBF zx?-}6#?zlgaA;?T6rTZtI$7DDF(=N&k4Nu;zm9on6h+xfv>=cnZZN7de$}cJ5CCxx zuumOT@cSSEg@)pX2do+AMZ;#rox{l~V<}S)|0>Ywx>sEygJZM~S9<|cn+N!njj2Zr zUH}9kF_l0Nslp1Qss-I!X<9#z2s9oNsHtpmObNieO5Z3Dnkgl+tIu24@T~XTZ2JEV84Y^o^Z%r=;9oo#95c1b#+e#Cvb&@et9vlxVsJXd`Y#l zKu&vDNAdEE<6)~_#@2FWbGSP??8`lR)GFvsuvfuH#5X7Y&XKnEMxo9xT=ylzeyJ0W z^V?nIp&(KW)JjziMYSm^KmYQ1ecB zg<*YqlefyaH25tNlM|GSP<~6QR@;*p_$jA;>p@^A#F0^?G|2q=C@M6(Js^3A1cv z@SYr)?}dpSVEGLtgc2Boti@{s?!)~J5%){LWs0rF0-PHs>aCip8oVhLKRwUIioMdl?16LzbE*OO()uj&k$7rj$#@x4BZH<**s4(-276rp!)xcz0!`<2Z)hy+s z%FPhLS>kN4%ojpLkim&7jdBUhamwMExE9i|Y7%W*iu}1Jr_-Nwo#<{yM#->4Sml~Q zI=B%l@v|%(<23qam*NUJ8b>>=pI7}`hc(@bgN&X13uKy%0B9Ii9C9jqmyt_f;-bRl z(-`Y!4%LG-P3s4!eI=_@cjLKOH4^-2xNmD4OPGp-$jm$PVBL(`7%ShCuh^u%xhu~5 z6_3*GAMR|(zJ=#)Fiiz%$l6-bQb3{F&m-Oc_*%s4^wBSN4uSQR=9ei-0Db})hJ{K129F*lc&;?Ww&6@5 zb%1w2ZdIYN@x4^Us?g&&hd7CLXFAwZS7oc2qT(gPz2Du(wVpz$YV3h4wz3m}zN+-2 z6l0ErTHW`nVgwi(_`79@K<^Ht5-T=T)i_|gm->diO6VDzX+V^K&*SoCQP8h^{CbdA z3~zQ?^eX>3WW4*+RQAC5<^;Z4ff%ExEc0}1O>OPx2}ahhl9&w`L|4YqXh@u5cvEC| zi1!?qT?=C0m+@f;644i1<@(yE zi68UFUGn@+8c6>)cPZj#Xl&EG=W|KTjHr*xqM=n(*}9=*f&<2%fK zGpP;62m%#TNase2In4DOv;7ggADJP8+KP5BTnFt}+nc{+OPbL^X;9KYukJ{UIQA%F ziLE(cKLJ(4FPAL1cwK?+W2?_BkNjZCC%O-+p* z{vDb0wN;zC{+8+!{~=d{vZv)LJ;KLkmLLm7BSF;+3C=`IZEO(O4WWKu1=T>!zHk{0 zrWCEYSjVfZOZrno_Yx_R;rgg+XX|ErE3|8lAa}m*&m|(1))otz#f8IZ3m%OzntA#u zc+l={PeLcdY;2VNYwL;WA^mPO(dz{3CU!-|GJ3+v#;$MBUlG7ryc%(k@$$ar>sR}N zzOoM03sgAkT@tLMwn>JTU8FU&r}?=0apL?OPE+ZcV$NGKNGZ%I4yNS8Z+i8PFS6!H-^?LHyg{6UQRv7 zi#`LGH&GLJMl6nG(@Z=QBOnWLAa|8eXO?>*Q?(exZD576i<4lW6TlzU11Bgx4tnHt zE=6V5e6&GKx+poJF>Oge)!ZdOu5%}Cmd$mS!DOL-LXUp9M>->^qEekwz%Qf>$CKYP zONCkrpZtcX?STA#^(s1V(tn^O8%1lbAH{9ypyEi|lnty5D>HKLLmYJY7QaWz&1ozecCj@)JGI!&Jra^i0spsNbb#k#MGXMXqzGB z=&RAv6*0|DT~Xamv0`Cawy1bTGq;}?clz=wfs;^MZtlqRDhPSuyN>9$NA@}T!Jj;U zqJRJZKF9c<(XSctXY@y6{bMu1{}mYiM^Y=UZ)0Tjx5q%btJ-v(@pt9;jsJKI#?`+% z?)*MQb*N^>Gp5%f;j$t&`+XRxPAZfiYWbWb{+d(NkF@7O^MTYnZ;Y$e0| z9tj&KFY8)V5=H26}a9E?aGnvx+zS&Tq4qnE^LXENe7boFGl zpk3k{h29#SWy=4_3^7`_V&h%?){2i`kb$UyG7JQi{Y;zd^tV7>zSDs z^^>TF&pCq*c--Qwh4fXPx}!mYJsb+D_URh)U04^(A8Jd3dK5`WZ1EZcYVBY~+xfg8 z*THO-CE5VXY?TyyEH*n7SLx>C9G3VML0juw%!3R&;D{)LT||TdW|+cez@ZQkfe>o= z!|obr`a*u*NZV35*(LosqC!wwWdhRomZAmZPqn6K(Cu?>IG}Ps%I;w`Sv=Sdt7A|M zx&u=rD0K4JM#GT~6soC%17QzZ!R^NTZmwWVUwk+ptXMNt;9S#Yt9Y#=7{A_>BP|~E zq-v_`N(T!CO#|KMMyf#G^iY)L5J+unCoE&1OO_TlZ2!1v*sr-+7@`jFTTvJ32gF6} zLZ{!#MAUx`t?X7n5vBk=xF_rwh-{`3k1bukc~q#bnBNct<6I@ua2e)3QS1qKUbag>KD^(7`lr(yV9ackT? z38ZCXMBjM;RnC3H5ELVxssfK*8OBq@1P$llL?F_E=%>QZj?iZW_^d4L)UGbz+WA}> z=FRKr(zT_f{NDBz;)=Xw3oeMqbysUtW(eH)D?S?(kKI%YTMSU#tBEoEAFz3POz8bh z#oanJkR)nrit|)P7oIvTm>|*!9ADU&x!RtbWw$zTX7v2WW$}28qHoUa(FIA@UHX$0 zUrB=3e zAUpyNs9*|pye-RPRb16gw-x;_=4KY(qa@Z-)0%@Ev&l0RK1ZC9B;7)PnzHX#!x5!< z=w&$V;jvQg7}L=@$3rCGf<=bzJDg@SoEAMN%FocReL@4V^b z9*M0a^EAKUJ{qXnWz)uMsy%NXW#dwO6g8^J?xjf|3pY^g$Y6AmE4PU5gZfB&-5*P0 zE|xAf$LV-_x&>A?cf6*$Rqz+YE>66XklbC01W?1LCkf`gHJ&8@j<>yU`HWZ(%G`P3 z2pFP0<+ewM8Ynv{=DBXRrc>ygy$((RmA$wP-Y7h|t5Eo#*gmJAou(c$NU-0Q_4h>c zX9_Z|{PWHV!SEkj0slD#{dez8JLPIk)0iqvKk*;a&pMCw@54|to3~W*QHR8GsEfx; z8EKAm!{oJR#SOntLp^#N4p-MhNR(43l2CAUw7xx>qSFxr0HGMSdh0xqpaD|5qQ6Fy z+%7(*%^#@z#LuV#YJo7sa?;$GpL;c@Pk|dGs&aM{fBzNUCBhf#et7k&rmNGQD&~_E z+s&=k)m@abD6rmO^z4a6WdN4-#F^KI=W= zaeO?)|Ci#AxDPA4=;-c_8sF7YUd zjGSz)5oFB%s?1O9K7HI5g(o_!`j&ha?2IT9T-`Gm@L`D=Er4N8t2|cb_M2oBdM`&W zFE_zA=6k&H@uhE=l!+1-VnSfw3B3mo)Gs&Si9T3!2k>QPiO07N#>s-$xV@EekBqvQ zA7{BElku(6k<;@7-f5es{kG= zxkW9JmwtnB8+Kil@Dg#GgKnm2jF1{d0U?hgZBPPm6-Z8|z0vj6E-1^N^gv&ZBt|O- zHZJxomQPHRE2eHJZm4ao_--RR2HScY*5)gxx%xyai@TG`V~oGl%Z@Mt@)Bo)31N3Q z)qWM92Hx-xaS5#97rZqP5f-E3`JwjRJXXK0^wlc%tdxaXBpufNEv76+F9~G7oYBCP z7Ry|33>!jGD%T%y1(-G(s@Q$ z6PStkWoO$oQ8SK@JF=qw$}v*i_TVGEFCr^Qv+C%onJ?yeW60+}m=sfOXO|9~YtOM= z1MC3}u%VA%8C>uR9VjM)e$;is`l>Ou$7)#QjtkylnL8V@F!u+(HUpP#b#m=Y!}8vi z2Sajz0{T}UvPuvVos@Va+IA2eqU2WLRSgr$ECjC#%N2~m{vc6y+Q$qpnXz?bD}u>W zJ`uL$Q@`?quWpWRq0e@P#0siG-oYCXf#^q|Z@5a7c)}DR0L=dGwWs`bEQR25ihYR~ zLGuJg13*EIWO)Ii^lC_ajTR2VQm?BbD&8W6NQgi}6cK9P@cQWi)2WU?AkW)SyFYDhBj;{g(FC`35Pcu%5|^aAR~(089OZ-Wigy;?0$mX zT6yLUhxyd%gvzZ}zCYbKu$P_1+SMM3Jl=hEhEI#6qvgGO;Y-DOSBxzyOyd%q!}3)> zg#$H3^GV>)&Ltm3;hipY?;!(Q!`vX#*GmFWd|73cHn&5YAs?c|l=xz1b?Vd*obOiC zSy;)?Wut=$W5y~c)L3*NAF{Jbdr?9X#2CzM*AL_1@0&W~A01T5_Ds~o&E~c6KGBN{ zBIQa|Ljdyv`OM}!hAviUAW`jmq75VGY1V=5?$R%I?AuJ-ZE#%6Es~zn%|)1~s7=C1*b%F{FYuWbTyt+7*vj$y60U$oOu*ia4Eab@MXlgDewa1X(22RA_-5=Io*sG2E5)ec7x_W zVGQ|pzE3lu$Uo5x)HQ~#ygN~Q-Mn2Q-j0+ zizAq%i6`t-SUIQzyvsWcPGscyyd89oG+)INt=W7vu-g_0;_sAn{&(W?e~-6j4ZTN zKY2E`E0d@#Xcvg^zzS?)d=w64RBB$1V&4ER$)?5hDf0M?Fw_odYR-BSlt=dqn>e=l95$Lrq3=N(p2-S7UP9Vap zv*162k_I>fwSBXbK!d|FLZoWtx&-hQ1V++l zfhqSH0rgfG`Ma&YJrpwGR}u1j^;Y}c>At$@Rg3u`u%l)eL`M>zB$C@|i#9}8=1~m% zEI>YfIL|S-)$2p0x|_Y!&7&Bxy6#CP+ZTJU`35X+=oghv#9&OUz%O3_Mmc70vEg+R zzjQTZZYgUFxkGeg`QG|12c zg=8zm5bQ$iobsU%OF39aSx%xc=hhB!B4y}uKj1!*md&c94DweA$`CbvhH{&ishsQAb*%jTKNmU$aj1;X5tiqeQr&0;Qg zN`-D7&c8xbyD%H^UnPXrU>27pw<8LD?}pY5su9ZdE5DK$n1CJzyRrw8&ll$yoB2An z1?DNLOHl2YH+^bB^PO7}x2}#)B-V0)DR*tDRn7R)O{jF3gA3iFl04CoIJx60>=%xv zA>g9CyY$nA)lmtTu6L04*N3IYUyoiN8905I?5sEZ!~ds}D-VZqeZ!P}Z$e}(Bq7U* zL}BdvPWG&0&sIuAV>c-KQW-^dSqIrkwy{LkC~8FZZ3c0;eeuTt5)x5o&|6R*LdbG^wx?}%sℜ5{O>? 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 A(*OVf literal 0 HcmV?d00001 diff --git a/ecomzone-v22-mark3.7.zip b/ecomzone-v22-mark3.7.zip new file mode 100644 index 0000000000000000000000000000000000000000..30a5f258e6e733a5e60b74b0de17ab62100e3656 GIT binary patch literal 37982 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 ekT%{;7!2bjC1{O&G literal 0 HcmV?d00001 diff --git a/ecomzone/README.md b/ecomzone/README.md new file mode 100644 index 0000000..14cc381 --- /dev/null +++ b/ecomzone/README.md @@ -0,0 +1,61 @@ +# EcomZone PrestaShop Module - Troubleshooting + +## Issue #1: 405 Method Not Allowed Error +The module was encountering a 405 Method Not Allowed error with the message: "The ARRAY method is not supported for this route. Supported methods: GET, HEAD." + +### Root Cause +The issue was happening because the code was incorrectly passing parameters to the `makeRequest` method, causing the HTTP method to be set as an array instead of a string. + +### Fixes Applied +1. **Fixed `EcomZoneClient.php` Methods**: + - Updated `getCatalog` to explicitly pass "GET" as the HTTP method + - Updated `getProduct` to explicitly pass "GET" as the HTTP method + +2. **Enhanced `makeApiRequest` Method**: + - Added validation to ensure the method parameter is always a string + - Added more detailed logging for debugging + +3. **Fixed API Request Calls**: + - Updated all calls to `makeApiRequest` to explicitly specify "GET" as the method + +## Issue #2: Missing cURL Extension +The module was unable to make API requests because the PHP cURL extension was not installed. + +### Fix Applied +- Installed the PHP cURL extension using: `sudo apt-get install php-curl` + +## Testing Tools +Two testing tools have been created to verify API connectivity: + +1. **api_test.php**: + - A standalone script that tests API connectivity without requiring PrestaShop + - Verifies that the API token is valid and can retrieve product data + +2. **get_token.php**: + - Retrieves the API token from the PrestaShop configuration or database + - Useful for troubleshooting API authentication issues + +## Development Environment Testing +When testing in a development environment without a full PrestaShop installation: + +1. Use the standalone `api_test.php` script which doesn't require PrestaShop configuration files +2. The regular `test_api.php` script requires access to PrestaShop's `config/config.inc.php` and `init.php` files +3. Edit the paths in `test_api.php` if needed to point to your actual PrestaShop installation + +## Production Environment Testing +In a production environment: + +1. Copy the module to your PrestaShop modules directory +2. Install the module through the PrestaShop admin panel +3. Configure your API credentials in the module settings +4. Run the `test_api.php` script from within the module directory + +## How to Verify +1. Run the standalone API test script: `php -f api_test.php` +2. If the API connection is working, you should see product data from the EcomZone API +3. Check the module logs in `/log/ecomzone.log` for any new error messages + +If you continue to experience issues, please check: +- Your API token is valid and correctly configured +- The API URL is correctly set to `https://dropship.ecomzone.eu/api` +- Your web server has the required PHP extensions installed (cURL, JSON) \ No newline at end of file diff --git a/ecomzone/api_test.php b/ecomzone/api_test.php new file mode 100644 index 0000000..d320572 --- /dev/null +++ b/ecomzone/api_test.php @@ -0,0 +1,143 @@ + 'https://dropship.ecomzone.eu/api', + 'ECOMZONE_API_TOKEN' => 'klRyAdrXaxL0s6PEUp7LDlH6T8aPSCtBY8NiEHsHiWpc6646K2TZPi5KMxUg' // Replace with your actual API token + ]; + return isset($config[$key]) ? $config[$key] : $default; + } + + public static function updateValue($key, $value) { + return true; + } + } +} + +// Mock PrestaShop classes +if (!class_exists('Context')) { + class Context { + public $shop; + public static $instance; + + public function __construct() { + $this->shop = new stdClass(); + $this->shop->id = 1; + } + + public static function getContext() { + if (!self::$instance) { + self::$instance = new Context(); + } + return self::$instance; + } + } +} + +if (!class_exists('PrestaShopLogger')) { + class PrestaShopLogger { + public static function addLog($message, $severity = 1, $error_code = null, $object_type = null, $object_id = null, $allow_duplicate = false, $id_employee = null) { + return true; + } + } +} + +// Create log directory if it doesn't exist +$logDir = __DIR__ . '/log'; +if (!file_exists($logDir)) { + mkdir($logDir, 0755, true); +} + +try { + // Get API settings + $apiUrl = Configuration::get('ECOMZONE_API_URL'); + $apiToken = Configuration::get('ECOMZONE_API_TOKEN'); + + echo "API URL: " . $apiUrl . "\n"; + echo "API Token: " . (empty($apiToken) || $apiToken === 'YOUR_API_TOKEN' ? + "Not set - Please edit api_test.php and replace YOUR_API_TOKEN with your actual token" : + "Set (length: " . strlen($apiToken) . " chars)") . "\n\n"; + + if (empty($apiToken) || $apiToken === 'YOUR_API_TOKEN') { + throw new Exception("API token is not configured. Please edit api_test.php and replace YOUR_API_TOKEN with your actual token."); + } + + echo "Testing API connection...\n\n"; + + // Create API client + $client = new EcomZoneClient(); + + // Test API connectivity with catalog endpoint + echo "Requesting catalog data (page 1, limit 1)...\n"; + $result = $client->getCatalog(1, 1); + + if (isset($result['data']) && is_array($result['data'])) { + echo "Success! Received " . count($result['data']) . " product(s)\n"; + echo "Total products available: " . ($result['total'] ?? 'unknown') . "\n\n"; + + if (!empty($result['data'])) { + $sku = $result['data'][0]['sku'] ?? null; + + if ($sku) { + echo "Testing product detail retrieval...\n"; + echo "Requesting product with SKU: " . $sku . "\n"; + + $productDetail = $client->getProduct($sku); + + if (isset($productDetail['data']) && !empty($productDetail['data'])) { + echo "Success! Retrieved product details\n"; + echo "Product name: " . ($productDetail['data']['product_name'] ?? 'unknown') . "\n"; + } else { + echo "Error: Failed to retrieve product details\n"; + echo "Response: " . print_r($productDetail, true) . "\n"; + } + } + } + } else { + echo "Error: Invalid catalog response\n"; + echo "Response: " . print_r($result, true) . "\n"; + } + + echo "\nAPI test completed successfully!\n"; + +} catch (Exception $e) { + echo "ERROR: " . $e->getMessage() . "\n"; + + if ($e instanceof EcomZoneException) { + echo "Error Code: " . $e->getCode() . "\n"; + } + + echo "\nStack Trace:\n" . $e->getTraceAsString() . "\n"; +} \ No newline at end of file diff --git a/ecomzone/classes/EcomZoneClient.php b/ecomzone/classes/EcomZoneClient.php index f282a9c..62a2dac 100644 --- a/ecomzone/classes/EcomZoneClient.php +++ b/ecomzone/classes/EcomZoneClient.php @@ -8,25 +8,16 @@ class EcomZoneClient private $apiToken; private $apiUrl; private $maxRetries = 3; - private $timeout = 30; + private $timeout = 60; // Increased timeout public function __construct() { $this->apiToken = Configuration::get("ECOMZONE_API_TOKEN"); $this->apiUrl = Configuration::get("ECOMZONE_API_URL"); - if (empty($this->apiToken)) { - throw new EcomZoneException( - "API token not configured", - EcomZoneException::API_ERROR - ); - } - if (empty($this->apiUrl)) { - throw new EcomZoneException( - "API URL not configured", - EcomZoneException::API_ERROR - ); + $this->apiUrl = 'https://dropship.ecomzone.eu/api'; // Default API URL + Configuration::updateValue("ECOMZONE_API_URL", $this->apiUrl); } EcomZoneLogger::log("API Client initialized", "INFO", [ @@ -34,44 +25,60 @@ class EcomZoneClient ]); } + public function validateApiCredentials() + { + if (empty($this->apiToken)) { + throw new EcomZoneException( + "API token not configured", + EcomZoneException::API_ERROR + ); + } + + return true; + } + public function getCatalog($page = 1, $perPage = 100) { - $url = $this->apiUrl . "/catalog"; + $this->validateApiCredentials(); + $params = [ "page" => $page, "per_page" => $perPage, ]; - return $this->makeRequest("GET", $url, $params); + return $this->makeRequest("catalog", "GET", $params); } public function getProduct($sku) { - $url = $this->apiUrl . "/product/" . urlencode($sku); - return $this->makeRequest("GET", $url); + $this->validateApiCredentials(); + + return $this->makeRequest("product/" . urlencode($sku), "GET"); } - private function makeRequest($method, $url, $params = [], $data = null) + private function makeRequest($endpoint, $method = "GET", $params = [], $data = null) { + // Validate API credentials first + $this->validateApiCredentials(); + $retryCount = 0; $lastError = null; + $url = rtrim($this->apiUrl, '/') . '/' . ltrim($endpoint, '/'); + + // Add query parameters to URL if method is GET + if ($method === "GET" && !empty($params) && is_array($params)) { + $url .= (strpos($url, "?") === false ? "?" : "&") . http_build_query($params); + } do { try { EcomZoneLogger::log("Making API request", "INFO", [ "method" => $method, "url" => $url, - "params" => $params, "retry" => $retryCount, + "params" => $params ]); - // Add query parameters - if (!empty($params)) { - $url .= - (strpos($url, "?") === false ? "?" : "&") . - http_build_query($params); - } - $curl = curl_init(); $options = [ CURLOPT_URL => $url, @@ -86,36 +93,108 @@ class EcomZoneClient "Authorization: Bearer " . $this->apiToken, "Accept: application/json", "Content-Type: application/json", + "User-Agent: PrestaShop-EcomZone/1.0", + "Connection: close" // Avoid keep-alive issues ], + // SSL Options - disable verification for development + CURLOPT_SSL_VERIFYPEER => false, + CURLOPT_SSL_VERIFYHOST => 0, + // Verbose debugging + CURLOPT_VERBOSE => true, ]; + // Create a temporary file for curl verbose output + $verbose = fopen('php://temp', 'w+'); + curl_setopt($curl, CURLOPT_STDERR, $verbose); + + // Add POST data if provided if ($data !== null) { - $options[CURLOPT_POSTFIELDS] = json_encode($data); + $jsonData = json_encode($data); + curl_setopt($curl, CURLOPT_POSTFIELDS, $jsonData); + EcomZoneLogger::log("Request payload", "DEBUG", [ + 'data' => $jsonData + ]); + } + // For POST requests with params, add them to the body + else if ($method !== "GET" && !empty($params)) { + $jsonData = json_encode($params); + curl_setopt($curl, CURLOPT_POSTFIELDS, $jsonData); + EcomZoneLogger::log("Request params as payload", "DEBUG", [ + 'data' => $jsonData + ]); } curl_setopt_array($curl, $options); + + // Execute the request $response = curl_exec($curl); $httpCode = curl_getinfo($curl, CURLINFO_HTTP_CODE); + $contentType = curl_getinfo($curl, CURLINFO_CONTENT_TYPE); $error = curl_error($curl); + $errorNo = curl_errno($curl); + + // Get verbose information + rewind($verbose); + $verboseLog = stream_get_contents($verbose); + fclose($verbose); + + // Log detailed response information + EcomZoneLogger::log("API Response Details", "DEBUG", [ + 'http_code' => $httpCode, + 'content_type' => $contentType, + 'response_length' => strlen($response), + 'curl_error_code' => $errorNo, + 'curl_error' => $error, + 'verbose_log' => $verboseLog, + 'raw_response' => strlen($response) > 1000 ? + substr($response, 0, 1000) . '... [truncated]' : + $response + ]); + curl_close($curl); - if ($error) { - throw new Exception("cURL Error: " . $error); + if ($errorNo > 0) { + throw new Exception("cURL Error ({$errorNo}): " . $error); } + // Handle HTTP errors if ($httpCode >= 400) { - throw new Exception( - "HTTP Error: " . $httpCode . " Response: " . $response - ); + $errorMessage = "HTTP Error: " . $httpCode; + + // Try to extract error details from response + if (!empty($response)) { + try { + $errorData = json_decode($response, true); + if (json_last_error() === JSON_ERROR_NONE && !empty($errorData['error'])) { + $errorMessage .= " - " . $errorData['error']; + } else { + $errorMessage .= " - Response: " . substr($response, 0, 200); + } + } catch (Exception $e) { + $errorMessage .= " - Raw response: " . substr($response, 0, 200); + } + } + + throw new Exception($errorMessage); } + // Handle empty responses + if (empty($response)) { + throw new Exception("Empty response received from API"); + } + + // Decode JSON response $decodedResponse = json_decode($response, true); if (json_last_error() !== JSON_ERROR_NONE) { - throw new Exception("Invalid JSON response"); + throw new Exception( + "Invalid JSON response: " . json_last_error_msg() . + "\nRaw response: " . substr($response, 0, 500) + ); } EcomZoneLogger::log("API request successful", "INFO", [ "http_code" => $httpCode, + "content_type" => $contentType ]); return $decodedResponse; @@ -126,10 +205,18 @@ class EcomZoneClient EcomZoneLogger::log("API request failed", "ERROR", [ "error" => $e->getMessage(), "retry" => $retryCount, + "url" => $url, + "trace" => $e->getTraceAsString() ]); if ($retryCount < $this->maxRetries) { - sleep(pow(2, $retryCount)); + $sleepTime = pow(2, $retryCount); + EcomZoneLogger::log("Retrying request", "INFO", [ + "retry_count" => $retryCount, + "sleep_time" => $sleepTime, + "url" => $url + ]); + sleep($sleepTime); continue; } @@ -142,11 +229,117 @@ class EcomZoneClient } } while ($retryCount < $this->maxRetries); + // This should never be reached, but just in case throw new EcomZoneException( "Max retries reached: " . $lastError->getMessage(), EcomZoneException::API_ERROR, $lastError ); } + + /** + * Download an image from the EcomZone API with proper authentication + * + * @param string $imageUrl The full URL of the image to download + * @return array Array containing image data and metadata + * @throws EcomZoneException If the download fails + */ + public function downloadImage(string $imageUrl): array + { + try { + EcomZoneLogger::log("Downloading image", "DEBUG", [ + 'url' => $imageUrl + ]); + + // Initialize cURL + $ch = curl_init(); + + curl_setopt_array($ch, [ + CURLOPT_URL => $imageUrl, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_MAXREDIRS => 5, + CURLOPT_TIMEOUT => 30, + CURLOPT_SSL_VERIFYPEER => false, + CURLOPT_SSL_VERIFYHOST => 0, + CURLOPT_USERAGENT => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', + CURLOPT_HTTPHEADER => [ + 'Accept: image/webp,image/apng,image/*,*/*;q=0.8', + 'Accept-Encoding: gzip, deflate, br', + 'Authorization: Bearer ' . $this->getApiToken() + ] + ]); + + $imageData = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $contentType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE); + $error = curl_error($ch); + + curl_close($ch); + + // Check if the response is HTML instead of an image (authentication error) + $isHtml = stripos($contentType, 'text/html') !== false || + (strlen($imageData) > 15 && stripos($imageData, '') !== false); + + if ($isHtml) { + throw new EcomZoneException( + "Authentication error: Received HTML instead of image data. Check API token.", + EcomZoneException::API_ERROR + ); + } + + if ($httpCode !== 200 || empty($imageData)) { + throw new EcomZoneException( + "Failed to download image (HTTP $httpCode): $error", + EcomZoneException::API_ERROR + ); + } + + EcomZoneLogger::log("Image download complete", "DEBUG", [ + 'url' => $imageUrl, + 'content_type' => $contentType, + 'size' => strlen($imageData) + ]); + + return [ + 'data' => $imageData, + 'content_type' => $contentType, + 'http_code' => $httpCode + ]; + + } catch (Exception $e) { + EcomZoneLogger::log("Image download failed", "ERROR", [ + 'url' => $imageUrl, + 'error' => $e->getMessage() + ]); + + throw new EcomZoneException( + "Image download failed: " . $e->getMessage(), + EcomZoneException::API_ERROR, + $e + ); + } + } + + /** + * Get the API token, either from the class property or from the configuration + * + * @return string The API token + */ + protected function getApiToken() + { + if (empty($this->apiToken)) { + $this->apiToken = Configuration::get("ECOMZONE_API_TOKEN"); + } + + if (empty($this->apiToken)) { + throw new EcomZoneException( + "API token not configured", + EcomZoneException::API_ERROR + ); + } + + return $this->apiToken; + } } diff --git a/ecomzone/classes/EcomZoneLogger.php b/ecomzone/classes/EcomZoneLogger.php index 637c7dc..919bdf9 100644 --- a/ecomzone/classes/EcomZoneLogger.php +++ b/ecomzone/classes/EcomZoneLogger.php @@ -5,66 +5,173 @@ if (!defined("_PS_VERSION_")) { class EcomZoneLogger { - private static $logFile = 'ecomzone.log'; - private static $maxLogSize = 10485760; // 10MB - - public static function log($message, $level = "INFO", $context = []) + const LOG_FILE = 'ecomzone.log'; + + // Log levels with corresponding severity + const LEVELS = [ + 'DEBUG' => 1, + 'INFO' => 2, + 'WARNING' => 3, + 'ERROR' => 4, + 'CRITICAL' => 5 + ]; + + // Current log level - can be changed through configuration + private static function getLogLevel() + { + $configLevel = Configuration::get('ECOMZONE_LOG_LEVEL', 'INFO'); + return isset(self::LEVELS[$configLevel]) ? $configLevel : 'INFO'; + } + + public static function log($message, $level = 'INFO', $context = []) { try { - $logDir = _PS_MODULE_DIR_ . 'ecomzone/log/'; - if (!is_dir($logDir)) { - mkdir($logDir, 0755, true); + // Check if we should log this level + $logLevel = self::getLogLevel(); + if (self::LEVELS[$level] < self::LEVELS[$logLevel]) { + return true; // Skip logging for less severe levels } - - $timestamp = date('Y-m-d H:i:s'); - $contextStr = !empty($context) ? json_encode($context) : ''; - $logMessage = sprintf( - "[%s] %s: %s %s\n", - $timestamp, - $level, - $message, - $contextStr - ); - - file_put_contents( - $logDir . self::$logFile, - $logMessage, - FILE_APPEND - ); - } catch (Exception $e) { - // Fallback to PrestaShop's logger - PrestaShopLogger::addLog( - "EcomZone: $message", - ($level === 'ERROR' ? 3 : 1) - ); - } - } - - private static function initLogFile() - { - if (!file_exists(self::$logFile)) { - $logDir = dirname(self::$logFile); + // Ensure the log directory exists + $logDir = _PS_MODULE_DIR_ . 'ecomzone/log'; if (!is_dir($logDir)) { - mkdir($logDir, 0755, true); + if (!@mkdir($logDir, 0755, true)) { + throw new Exception("Failed to create log directory: " . $logDir); + } + @chmod($logDir, 0755); // Ensure the directory is writable } - touch(self::$logFile); - chmod(self::$logFile, 0666); + + // Format the log entry + $logEntry = [ + 'timestamp' => date('Y-m-d H:i:s'), + 'level' => strtoupper($level), + 'message' => $message, + 'context' => $context + ]; + + // For errors and critical issues, also include a stack trace + if (in_array($level, ['ERROR', 'CRITICAL']) && !isset($context['trace'])) { + $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3); + $logEntry['trace'] = array_slice($trace, 1); // Skip the log function itself + } + + $logLine = json_encode($logEntry) . PHP_EOL; + + // Rotate log file if it's too large (> 10MB) + $logFile = $logDir . '/' . self::LOG_FILE; + if (file_exists($logFile) && filesize($logFile) > 10 * 1024 * 1024) { + self::rotateLogFile($logFile); + } + + // Write to log file with exclusive lock + if (!@file_put_contents($logFile, $logLine, FILE_APPEND | LOCK_EX)) { + throw new Exception("Failed to write to log file: " . $logFile); + } + + // Also write to PrestaShop log for error/critical levels + if (in_array($level, ['ERROR', 'CRITICAL'])) { + $psLogLevel = ($level === 'ERROR') ? 3 : 4; + $contextStr = json_encode($context); + PrestaShopLogger::addLog( + "EcomZone: {$message} - Context: {$contextStr}", + $psLogLevel, + null, + 'EcomZone' + ); + } + + return true; + } catch (Exception $e) { + // Last resort logging to PHP error log + error_log('EcomZone logging error: ' . $e->getMessage() . + '. Original message: ' . $message); + + // Try to log to PrestaShop's log + try { + PrestaShopLogger::addLog( + 'EcomZone logging error: ' . $e->getMessage() . '. Original message: ' . $message, + 3, // Error level + null, + 'EcomZone' + ); + } catch (Exception $innerEx) { + error_log('Failed to log to PrestaShop: ' . $innerEx->getMessage()); + } + + return false; } } - private static function rotateLogIfNeeded() + private static function rotateLogFile($logFile) { - if (!file_exists(self::$logFile)) { - return; - } + try { + $backup = $logFile . '.' . date('Y-m-d-H-i-s') . '.bak'; + if (!@rename($logFile, $backup)) { + throw new Exception("Failed to rotate log file"); + } - if (filesize(self::$logFile) > self::$maxLogSize) { - $backup = self::$logFile . "." . date("Y-m-d-H-i-s") . ".bak"; - rename(self::$logFile, $backup); - touch(self::$logFile); - chmod(self::$logFile, 0666); + // Keep only last 5 backup files + $backups = glob($logFile . '.*.bak'); + if (count($backups) > 5) { + usort($backups, function($a, $b) { + return filemtime($b) - filemtime($a); + }); + + $toDelete = array_slice($backups, 5); + foreach ($toDelete as $file) { + @unlink($file); + } + } + + return true; + } catch (Exception $e) { + error_log('EcomZone log rotation error: ' . $e->getMessage()); + return false; } } + + public static function getLogPath() + { + // When running in standalone mode, use the local directory + if (!defined('_PS_MODULE_DIR_') || !file_exists(_PS_MODULE_DIR_ . 'ecomzone')) { + return __DIR__ . '/../log/' . self::LOG_FILE; + } + + return _PS_MODULE_DIR_ . 'ecomzone/log/' . self::LOG_FILE; + } + + public static function getLogContents($maxLines = 100) + { + $logFile = self::getLogPath(); + if (!file_exists($logFile)) { + return []; + } + + $logs = []; + $lines = array_reverse(file($logFile)); + $count = 0; + + foreach ($lines as $line) { + if ($count >= $maxLines) break; + + $entry = json_decode($line, true); + if ($entry) { + $logs[] = $entry; + $count++; + } + } + + return $logs; + } + + public static function clearLogs() + { + $logFile = self::getLogPath(); + if (file_exists($logFile)) { + @unlink($logFile); + self::log("Log file cleared", "INFO"); + } + return true; + } } diff --git a/ecomzone/classes/EcomZoneProductSync.php b/ecomzone/classes/EcomZoneProductSync.php index 15a8591..fbf5c35 100644 --- a/ecomzone/classes/EcomZoneProductSync.php +++ b/ecomzone/classes/EcomZoneProductSync.php @@ -3,6 +3,8 @@ if (!defined("_PS_VERSION_")) { exit(); } +require_once dirname(__FILE__) . '/interfaces/IProductSync.php'; + class EcomZoneProductSync implements IProductSync { private EcomZoneClient $client; @@ -22,63 +24,112 @@ class EcomZoneProductSync implements IProductSync public function importProducts(int $perPage = 100): array { - $page = 1; - $totalImported = 0; - $totalAvailable = 0; - $errors = []; - try { - EcomZoneLogger::log("Starting product import", "INFO", [ + // Get or initialize sync state + $syncState = $this->getSyncState(); + + // If this is a new sync (page 1 and no imports), reset everything + if (empty($syncState) || ($syncState['current_page'] ?? 1) === 1 && ($syncState['total_imported'] ?? 0) === 0) { + $this->resetSyncState(); + $syncState = []; + } + + $page = $syncState['current_page'] ?? 1; + $totalImported = $syncState['total_imported'] ?? 0; + $totalAvailable = $syncState['total_available'] ?? 0; + $errors = $syncState['errors'] ?? []; + $maxProductsPerBatch = 20; // Process 20 products at a time to avoid timeout + + EcomZoneLogger::log("Starting/Resuming product import", "INFO", [ + "page" => $page, "per_page" => $perPage, + "total_imported" => $totalImported, + "is_new_sync" => empty($syncState) ]); - do { - $catalog = $this->client->getCatalog($page, $perPage); + // Get catalog for current page + $catalog = $this->client->getCatalog($page, $perPage); - if (!isset($catalog["data"]) || !is_array($catalog["data"])) { - throw new EcomZoneException( - "Invalid catalog response", - EcomZoneException::API_ERROR - ); - } - - foreach ($catalog["data"] as $product) { - try { - if ($this->importSingleProduct($product)) { - $totalImported++; - } - } catch (Exception $e) { - $errors[] = [ - "sku" => $product["sku"] ?? "unknown", - "error" => $e->getMessage(), - ]; - EcomZoneLogger::log("Product import failed", "ERROR", [ - "sku" => $product["sku"] ?? "unknown", - "error" => $e->getMessage(), - ]); - } - } + if (!isset($catalog["data"]) || !is_array($catalog["data"])) { + throw new EcomZoneException( + "Invalid catalog response", + EcomZoneException::API_ERROR + ); + } + // Update total available on first page or if it changes + if ($page === 1 || ($catalog["total"] ?? 0) !== $totalAvailable) { $totalAvailable = $catalog["total"] ?? 0; - $page++; + } - EcomZoneLogger::log("Page processed", "INFO", [ - "page" => $page - 1, - "imported" => $totalImported, - "total" => $totalAvailable, - ]); - } while ( - isset($catalog["next_page_url"]) && - $catalog["next_page_url"] !== null - ); + $productsProcessed = 0; + $batchErrors = []; + + // Process only a batch of products + foreach ($catalog["data"] as $product) { + if ($productsProcessed >= $maxProductsPerBatch) { + break; + } - $this->clearCache(); + try { + if ($this->importSingleProduct($product)) { + $totalImported++; + } + } catch (Exception $e) { + $batchErrors[] = [ + "sku" => $product["sku"] ?? "unknown", + "error" => $e->getMessage(), + ]; + EcomZoneLogger::log("Product import failed", "ERROR", [ + "sku" => $product["sku"] ?? "unknown", + "error" => $e->getMessage(), + ]); + } + $productsProcessed++; + } + + // Update errors array with new batch errors + $errors = array_merge($errors, $batchErrors); + + // Calculate progress + $progress = [ + 'current_page' => $page, + 'total_imported' => $totalImported, + 'total_available' => $totalAvailable, + 'errors' => $errors, + 'is_complete' => false, + 'products_processed_on_page' => $productsProcessed + ]; + + // Check if we need to continue to next page + if ($productsProcessed < count($catalog["data"])) { + // Still more products to process on this page + $progress['products_processed_on_page'] = $productsProcessed; + } else { + // Move to next page if available + if (isset($catalog["next_page_url"]) && $catalog["next_page_url"] !== null) { + $progress['current_page'] = $page + 1; + $progress['products_processed_on_page'] = 0; + } else { + $progress['is_complete'] = true; + } + } + + // Save progress + $this->saveSyncState($progress); + + // Clear cache if complete + if ($progress['is_complete']) { + $this->clearCache(); + } return [ "success" => true, "imported" => $totalImported, "total" => $totalAvailable, "errors" => $errors, + "is_complete" => $progress['is_complete'], + "current_page" => $progress['current_page'] ]; } catch (Exception $e) { EcomZoneLogger::log("Product import process failed", "ERROR", [ @@ -192,8 +243,11 @@ class EcomZoneProductSync implements IProductSync $product->name[$this->defaultLangId] = $data['product_name']; $product->description[$this->defaultLangId] = $data['long_description'] ?? $data['description']; $product->description_short[$this->defaultLangId] = $data['description']; - $product->price = (float)$data['product_price']; - $product->wholesale_price = (float)($data['recommended_retail_price'] ?? 0); + + // Update prices + $product->wholesale_price = (float)$data['product_price']; // Original price as wholesale price + $product->price = (float)($data['recommended_retail_price'] ?? $data['product_price']); // Recommended retail price for shop display + $product->reference = $data['sku']; $product->ean13 = $data['ean'] ?? ''; @@ -354,15 +408,32 @@ class EcomZoneProductSync implements IProductSync private function updateProductStock(Product $product, array $data): void { try { - $quantity = (int) ($data['stock_quantity'] ?? 0); - StockAvailable::setQuantity($product->id, 0, $quantity); + $quantity = (int)($data['stock'] ?? 0); + + // Make sure product is saved first + if (!$product->id) { + $product->save(); + } + + // Update stock quantity + StockAvailable::setQuantity( + $product->id, + 0, // Combination ID (0 for products without combinations) + $quantity + ); // Update out of stock behavior StockAvailable::setProductOutOfStock( $product->id, - $quantity > 0 ? 2 : 0, // 2 = Allow orders, 0 = Deny orders + 2, // Always allow orders $this->defaultShopId ); + + EcomZoneLogger::log("Stock updated", "DEBUG", [ + "product" => $product->reference, + "quantity" => $quantity + ]); + } catch (Exception $e) { EcomZoneLogger::log("Failed to update stock", "WARNING", [ "product" => $product->reference, @@ -375,49 +446,126 @@ class EcomZoneProductSync implements IProductSync { try { $imageUrls = []; + + EcomZoneLogger::log("Raw product data for images", "DEBUG", [ + "product" => $product->reference, + "data" => json_encode($data) + ]); - // Process main image - if (!empty($data["image"])) { - $imageUrls[] = $data["image"]; - } - - // Process additional images - if (!empty($data["images"])) { - $additionalImages = json_decode($data["images"], true); - if (is_array($additionalImages)) { - $imageUrls = array_merge($imageUrls, $additionalImages); + // Handle different possible data structures + if (isset($data['images']) && is_string($data['images'])) { + $images = json_decode($data['images'], true); + if (json_last_error() === JSON_ERROR_NONE && is_array($images)) { + $imageUrls = array_merge($imageUrls, $images); } + } elseif (isset($data['images']) && is_array($data['images'])) { + $imageUrls = array_merge($imageUrls, $data['images']); } + // Add main image if available + if (!empty($data['image'])) { + array_unshift($imageUrls, $data['image']); // Add as first image + } + + // Filter out empty or invalid URLs + $imageUrls = array_filter($imageUrls, function($url) { + return !empty($url) && filter_var($url, FILTER_VALIDATE_URL); + }); + if (empty($imageUrls)) { + EcomZoneLogger::log("No valid images found for product", "WARNING", [ + "product" => $product->reference, + "data" => json_encode($data) + ]); return; } // Delete existing images if any $product->deleteImages(); + $successCount = 0; foreach ($imageUrls as $index => $imageUrl) { - $this->importProductImage($product, $imageUrl, $index === 0); + try { + EcomZoneLogger::log("Processing image", "DEBUG", [ + "product" => $product->reference, + "image_url" => $imageUrl, + "index" => $index + ]); + + $this->importProductImage($product, $imageUrl, $index === 0); + $successCount++; + } catch (Exception $e) { + EcomZoneLogger::log("Failed to process image", "WARNING", [ + "product" => $product->reference, + "image_url" => $imageUrl, + "error" => $e->getMessage() + ]); + continue; + } } + + if ($successCount === 0 && !empty($imageUrls)) { + throw new Exception(sprintf( + "Failed to process any images for the product. Attempted %d images.", + count($imageUrls) + )); + } + + EcomZoneLogger::log("Processed product images", "INFO", [ + "product" => $product->reference, + "total_images" => count($imageUrls), + "successful_imports" => $successCount + ]); + } catch (Exception $e) { - EcomZoneLogger::log("Failed to process product images", "WARNING", [ + EcomZoneLogger::log("Failed to process product images", "ERROR", [ "sku" => $product->reference, "error" => $e->getMessage(), + "data" => json_encode($data) ]); + throw $e; } } private function importProductImage(Product $product, string $imageUrl, bool $cover = false): void { try { - // Create temporary file - $tempFile = tempnam(_PS_TMP_IMG_DIR_, 'import_'); + // Create temporary directory if it doesn't exist + $tempDir = _PS_MODULE_DIR_ . 'ecomzone/tmp/'; + if (!is_dir($tempDir)) { + mkdir($tempDir, 0755, true); + chmod($tempDir, 0755); + } + + // Clean URL - handle double escaped slashes + $cleanUrl = stripslashes(stripslashes(trim($imageUrl))); - // Download image - if (!copy($imageUrl, $tempFile)) { - throw new Exception("Failed to download image: " . $imageUrl); + EcomZoneLogger::log("Processing image URL", "DEBUG", [ + "product" => $product->reference, + "original_url" => $imageUrl, + "cleaned_url" => $cleanUrl + ]); + + // Create temp file + $tempFile = tempnam($tempDir, 'import_'); + if ($tempFile) { + chmod($tempFile, 0644); } + // Use the client class to download the image + $imageResult = $this->client->downloadImage($cleanUrl); + $imageData = $imageResult['data']; + $contentType = $imageResult['content_type']; + + // Save image data to temp file + file_put_contents($tempFile, $imageData); + + // Verify the downloaded file is a valid image + $imageInfo = @getimagesize($tempFile); + if (!$imageInfo) { + throw new Exception("Downloaded file is not a valid image. Content type: $contentType"); + } + // Create image object $image = new Image(); $image->id_product = $product->id; @@ -433,29 +581,60 @@ class EcomZoneProductSync implements IProductSync $imageDir = dirname($imagePath); if (!file_exists($imageDir)) { - mkdir($imageDir, 0755, true); + if (!mkdir($imageDir, 0755, true)) { + throw new Exception("Failed to create image directory: " . $imageDir); + } + chmod($imageDir, 0755); } // Generate image if (!ImageManager::resize( $tempFile, - $imagePath . '.jpg' + $imagePath . '.jpg', + null, + null, + 'jpg' )) { throw new Exception("Failed to process image"); } + + // Ensure the generated image has correct permissions + chmod($imagePath . '.jpg', 0644); // Generate thumbnails $this->generateThumbnails($image); // Clean up - unlink($tempFile); + if (file_exists($tempFile)) { + unlink($tempFile); + } + + EcomZoneLogger::log("Image imported successfully", "DEBUG", [ + "product" => $product->reference, + "image_url" => $cleanUrl, + "mime_type" => $imageInfo['mime'] + ]); } catch (Exception $e) { EcomZoneLogger::log("Failed to import product image", "ERROR", [ "product" => $product->reference, "image_url" => $imageUrl, - "error" => $e->getMessage() + "clean_url" => $cleanUrl ?? '', + "error" => $e->getMessage(), + "content_type" => $contentType ?? null ]); + + // Clean up on error + if (isset($tempFile) && file_exists($tempFile)) { + unlink($tempFile); + } + + // Delete image record if it was created + if (isset($image) && $image->id) { + $image->delete(); + } + + throw $e; } } @@ -463,117 +642,41 @@ class EcomZoneProductSync implements IProductSync { try { $imageTypes = ImageType::getImagesTypes('products'); + $sourceFile = _PS_PROD_IMG_DIR_ . $image->getExistingImgPath() . '.jpg'; + + if (!file_exists($sourceFile)) { + throw new Exception("Source image file not found"); + } + foreach ($imageTypes as $imageType) { - $dir = _PS_PROD_IMG_DIR_ . $image->getExistingImgPath(); + $destination = _PS_PROD_IMG_DIR_ . $image->getExistingImgPath() . '-' . stripslashes($imageType['name']) . '.jpg'; + $dir = dirname($destination); + if (!file_exists($dir)) { - mkdir($dir, 0755, true); + if (!mkdir($dir, 0755, true)) { + throw new Exception("Failed to create thumbnail directory: " . $dir); + } + chmod($dir, 0755); } - ImageManager::resize( - _PS_PROD_IMG_DIR_ . $image->getExistingImgPath() . '.jpg', - _PS_PROD_IMG_DIR_ . $image->getExistingImgPath() . '-' . stripslashes($imageType['name']) . '.jpg', + + if (!ImageManager::resize( + $sourceFile, + $destination, (int)$imageType['width'], (int)$imageType['height'] - ); + )) { + throw new Exception("Failed to generate thumbnail: " . $imageType['name']); + } + + // Set proper permissions for the thumbnail + chmod($destination, 0644); } } catch (Exception $e) { EcomZoneLogger::log("Failed to generate thumbnails", "WARNING", [ "image_id" => $image->id, "error" => $e->getMessage() ]); - } - } - - private function generateImageType( - string $srcPath, - string $destPath, - ?int $width, - ?int $height - ): void { - try { - $this->createDirectoryIfNotExists(dirname($destPath)); - - if ($width === null || $height === null) { - copy($srcPath, $destPath); - return; - } - - $imageInfo = getimagesize($srcPath); - if (!$imageInfo) { - throw new Exception("Invalid image file"); - } - - $srcImage = $this->createImageFromType($srcPath, $imageInfo[2]); - $destImage = imagecreatetruecolor($width, $height); - - // Preserve transparency for PNG images - if ($imageInfo[2] === IMAGETYPE_PNG) { - imagealphablending($destImage, false); - imagesavealpha($destImage, true); - $transparent = imagecolorallocatealpha( - $destImage, - 255, - 255, - 255, - 127 - ); - imagefilledrectangle( - $destImage, - 0, - 0, - $width, - $height, - $transparent - ); - } - - imagecopyresampled( - $destImage, - $srcImage, - 0, - 0, - 0, - 0, - $width, - $height, - $imageInfo[0], - $imageInfo[1] - ); - - imagejpeg($destImage, $destPath, 95); - - imagedestroy($srcImage); - imagedestroy($destImage); - } catch (Exception $e) { - throw new EcomZoneException( - "Failed to generate image type: " . $e->getMessage(), - EcomZoneException::IMAGE_PROCESSING_ERROR, - $e - ); - } - } - - private function createImageFromType(string $filename, int $type) - { - switch ($type) { - case IMAGETYPE_JPEG: - return imagecreatefromjpeg($filename); - case IMAGETYPE_PNG: - return imagecreatefrompng($filename); - case IMAGETYPE_GIF: - return imagecreatefromgif($filename); - default: - throw new Exception("Unsupported image type: " . $type); - } - } - - private function createDirectoryIfNotExists(string $directory): void - { - if (!is_dir($directory)) { - if (!mkdir($directory, 0755, true)) { - throw new Exception( - "Failed to create directory: " . $directory - ); - } + throw $e; } } @@ -716,6 +819,27 @@ class EcomZoneProductSync implements IProductSync Media::clearCache(); PrestaShopAutoload::getInstance()->generateIndex(); } + + private function getSyncState(): array + { + $state = Configuration::get('ECOMZONE_SYNC_STATE'); + return $state ? json_decode($state, true) : []; + } + + private function saveSyncState(array $state): void + { + Configuration::updateValue('ECOMZONE_SYNC_STATE', json_encode($state)); + } + + public function resetSyncState(): void + { + // Delete both sync state and progress + Configuration::deleteByName('ECOMZONE_SYNC_STATE'); + Configuration::deleteByName('ECOMZONE_SYNC_PROGRESS'); + Configuration::deleteByName('ECOMZONE_LAST_SYNC'); + + EcomZoneLogger::log("Sync state reset", "INFO"); + } } $token = Configuration::get('ECOMZONE_CRON_TOKEN'); diff --git a/ecomzone/classes/interfaces/IProductSync.php b/ecomzone/classes/interfaces/IProductSync.php index 067d111..3913813 100644 --- a/ecomzone/classes/interfaces/IProductSync.php +++ b/ecomzone/classes/interfaces/IProductSync.php @@ -5,6 +5,17 @@ if (!defined('_PS_VERSION_')) { interface IProductSync { + /** + * Import products from external source + * @param int $perPage Number of products to import per page + * @return array Import results containing success status, counts, and any errors + */ public function importProducts(int $perPage = 100): array; + + /** + * Import a single product + * @param array $productData Product data to import + * @return bool Success status + */ public function importSingleProduct(array $productData): bool; } \ No newline at end of file diff --git a/ecomzone/cron.php b/ecomzone/cron.php deleted file mode 100644 index 0285624..0000000 --- a/ecomzone/cron.php +++ /dev/null @@ -1,123 +0,0 @@ - 'classes/EcomZoneException.php', - 'EcomZoneClient' => 'classes/EcomZoneClient.php', - 'EcomZoneLogger' => 'classes/EcomZoneLogger.php', - 'EcomZoneProductSync' => 'classes/EcomZoneProductSync.php', - 'EcomZoneProductImport' => 'classes/EcomZoneProductImport.php', - 'EcomZoneCategoryHandler' => 'classes/EcomZoneCategoryHandler.php', - 'IProductSync' => 'classes/interfaces/IProductSync.php', - 'ICategory' => 'classes/interfaces/ICategory.php' - ]; - - if (isset($classMap[$className])) { - require_once dirname(__FILE__) . '/' . $classMap[$className]; - } -}); - -// Function to send JSON response -function sendJsonResponse($data, $statusCode = 200) -{ - if (php_sapi_name() === 'cli') { - echo json_encode($data, JSON_PRETTY_PRINT) . "\n"; - } else { - if (!headers_sent()) { - http_response_code($statusCode); - header("Content-Type: application/json"); - } - echo json_encode($data); - } - exit($statusCode === 200 ? 0 : 1); -} - -// Get token from either GET parameter or command line argument -$token = ''; -if (php_sapi_name() === 'cli') { - global $argv; - foreach ($argv as $arg) { - if (strpos($arg, 'token=') === 0) { - $token = substr($arg, 6); - break; - } - } -} else { - $token = Tools::getValue('token'); -} - -// Security check -$configToken = Configuration::get("ECOMZONE_CRON_TOKEN"); - -if (empty($token) || $token !== $configToken) { - sendJsonResponse([ - 'success' => false, - 'error' => 'Invalid token', - 'provided_token' => $token, - 'expected_token' => $configToken - ], 403); -} - -try { - // Initialize logger - EcomZoneLogger::log("Starting cron execution", "INFO", [ - "time" => date("Y-m-d H:i:s"), - ]); - - // Check last run time to prevent too frequent execution - $lastRun = Configuration::get("ECOMZONE_LAST_CRON_RUN"); - $minInterval = 360; - - if (!empty($lastRun) && strtotime($lastRun) + $minInterval > time()) { - EcomZoneLogger::log("Skipping cron - too soon since last run", "INFO", [ - "last_run" => $lastRun, - "next_run" => date("Y-m-d H:i:s", strtotime($lastRun) + $minInterval), - ]); - - sendJsonResponse([ - "success" => false, - "message" => "Too soon since last run", - "last_run" => $lastRun, - "next_run" => date("Y-m-d H:i:s", strtotime($lastRun) + $minInterval), - ]); - } - - // Initialize Product Sync - $productSync = new EcomZoneProductSync(); - - // Start the import process - $result = $productSync->importProducts(100); // Import 100 products per page - - // Update last run time - Configuration::updateValue("ECOMZONE_LAST_CRON_RUN", date("Y-m-d H:i:s")); - - // Log completion and send response - EcomZoneLogger::log("Cron execution completed", "INFO", [ - "imported" => $result["imported"], - "total" => $result["total"], - "errors" => count($result["errors"]), - ]); - - sendJsonResponse([ - "success" => true, - "result" => $result, - "timestamp" => date("Y-m-d H:i:s"), - ]); -} catch (Exception $e) { - EcomZoneLogger::log("Cron execution failed", "ERROR", [ - "error" => $e->getMessage(), - "trace" => $e->getTraceAsString() - ]); - - sendJsonResponse([ - "success" => false, - "error" => $e->getMessage() - ], 500); -} - diff --git a/ecomzone/ecomzone.php b/ecomzone/ecomzone.php index 80afe84..1b9ee96 100644 --- a/ecomzone/ecomzone.php +++ b/ecomzone/ecomzone.php @@ -20,6 +20,9 @@ class Ecomzone extends Module parent::__construct(); + // Register autoloader + spl_autoload_register([$this, 'autoload']); + $this->displayName = $this->trans( "EcomZone Dropshipping", [], @@ -37,78 +40,462 @@ class Ecomzone extends Module ); } - public function install() + /** + * Autoload EcomZone classes + */ + public function autoload($className) { - if (!parent::install() - || !$this->registerHook('actionCronJob') - || !$this->registerHook('actionProductUpdate') - || !$this->registerHook('actionProductDelete') - ) { - return false; - } - - // Set default configuration values - Configuration::updateValue('ECOMZONE_API_URL', 'https://dropship.ecomzone.eu/api'); - Configuration::updateValue('ECOMZONE_CRON_TOKEN', Tools::encrypt(uniqid())); - Configuration::updateValue('ECOMZONE_LAST_SYNC', ''); - - // Create required directories - $this->createRequiredDirectories(); - - return true; - } - - private function createRequiredDirectories() - { - $dirs = [ - _PS_MODULE_DIR_ . $this->name . '/log', - _PS_MODULE_DIR_ . $this->name . '/tmp' + // Define class mapping + $classMap = [ + 'EcomZoneException' => 'classes/EcomZoneException.php', + 'EcomZoneClient' => 'classes/EcomZoneClient.php', + 'EcomZoneLogger' => 'classes/EcomZoneLogger.php', + 'EcomZoneProductSync' => 'classes/EcomZoneProductSync.php', + 'EcomZoneProductImport' => 'classes/EcomZoneProductImport.php', + 'EcomZoneCategoryHandler' => 'classes/EcomZoneCategoryHandler.php', + 'IProductSync' => 'classes/interfaces/IProductSync.php', + 'ICategory' => 'classes/interfaces/ICategory.php' ]; - foreach ($dirs as $dir) { - if (!file_exists($dir)) { - mkdir($dir, 0755, true); + // Check if the class exists in our map + if (isset($classMap[$className])) { + $file = _PS_MODULE_DIR_ . $this->name . '/' . $classMap[$className]; + if (file_exists($file)) { + require_once($file); + } + } + } + + private function setImageDirectoryPermissions(): bool + { + try { + $directories = [ + _PS_IMG_DIR_, + _PS_PROD_IMG_DIR_, + _PS_MODULE_DIR_ . $this->name . '/tmp/', + _PS_MODULE_DIR_ . $this->name . '/log/' + ]; + + EcomZoneLogger::log("Setting up directory permissions", "INFO"); + + foreach ($directories as $dir) { + if (!file_exists($dir)) { + EcomZoneLogger::log("Creating directory", "INFO", ['directory' => $dir]); + if (!@mkdir($dir, 0755, true)) { + $error = error_get_last(); + throw new Exception("Failed to create directory {$dir}: " . ($error['message'] ?? 'Unknown error')); + } + } + + if (!is_writable($dir)) { + // Get current permissions + $currentPerms = fileperms($dir) & 0777; + EcomZoneLogger::log("Directory not writable, setting permissions", "INFO", [ + 'directory' => $dir, + 'current_perms' => decoct($currentPerms), + 'target_perms' => '0755' + ]); + + if (!@chmod($dir, 0755)) { + $error = error_get_last(); + throw new Exception("Failed to set permissions for {$dir}: " . ($error['message'] ?? 'Unknown error')); + } + } + + // If this is the product image directory, ensure all subdirectories are writable + if (strpos($dir, _PS_PROD_IMG_DIR_) === 0) { + EcomZoneLogger::log("Processing product image subdirectories", "INFO", ['directory' => $dir]); + + try { + if (!is_dir($dir)) { + continue; // Skip if directory doesn't exist + } + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS), + RecursiveIteratorIterator::SELF_FIRST + ); + + foreach ($iterator as $item) { + if ($item->isDir()) { + if (!is_writable($item->getPathname())) { + if (!@chmod($item->getPathname(), 0755)) { + $error = error_get_last(); + EcomZoneLogger::log("Failed to set subdirectory permissions", "WARNING", [ + 'directory' => $item->getPathname(), + 'error' => $error['message'] ?? 'Unknown error' + ]); + } + } + } else { + if (!is_writable($item->getPathname())) { + if (!@chmod($item->getPathname(), 0644)) { + $error = error_get_last(); + EcomZoneLogger::log("Failed to set file permissions", "WARNING", [ + 'file' => $item->getPathname(), + 'error' => $error['message'] ?? 'Unknown error' + ]); + } + } + } + } + } catch (UnexpectedValueException $e) { + // Log but don't fail if we can't read a subdirectory + EcomZoneLogger::log("Failed to process subdirectory", "WARNING", [ + 'directory' => $dir, + 'error' => $e->getMessage() + ]); + } + } + } + + EcomZoneLogger::log("Directory permissions setup complete", "INFO"); + return true; + } catch (Exception $e) { + EcomZoneLogger::log("Failed to set directory permissions", "ERROR", [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString() + ]); + return false; + } + } + + public function install() + { + EcomZoneLogger::log("Starting module installation", "INFO"); + + try { + // First check if module directory exists and is writable + if (!is_dir(_PS_MODULE_DIR_ . $this->name)) { + EcomZoneLogger::log("Creating module directory", "INFO"); + if (!@mkdir(_PS_MODULE_DIR_ . $this->name, 0755, true)) { + $error = error_get_last(); + throw new Exception('Failed to create module directory: ' . ($error['message'] ?? 'Unknown error')); + } + } + + // Create required directories first + $dirs = [ + _PS_MODULE_DIR_ . $this->name . '/log', + _PS_MODULE_DIR_ . $this->name . '/tmp', + _PS_IMG_DIR_, + _PS_PROD_IMG_DIR_ + ]; + + foreach ($dirs as $dir) { + if (!file_exists($dir)) { + EcomZoneLogger::log("Creating directory: " . $dir, "INFO"); + if (!@mkdir($dir, 0755, true)) { + $error = error_get_last(); + throw new Exception('Failed to create directory: ' . $dir . ' - ' . ($error['message'] ?? 'Unknown error')); + } + } + + if (!is_writable($dir)) { + EcomZoneLogger::log("Setting directory writable: " . $dir, "INFO"); + if (!@chmod($dir, 0755)) { + $error = error_get_last(); + throw new Exception('Failed to set permissions for directory: ' . $dir . ' - ' . ($error['message'] ?? 'Unknown error')); + } + } + } + + // Set up database tables + EcomZoneLogger::log("Setting up database tables", "INFO"); + $this->createTables(); + + // Install parent module + EcomZoneLogger::log("Installing parent module", "INFO"); + if (!parent::install()) { + throw new Exception('Failed to install parent module'); + } + + // Register hooks + EcomZoneLogger::log("Registering hooks", "INFO"); + if (!$this->registerHook('actionProductUpdate') + || !$this->registerHook('actionProductDelete') + || !$this->registerHook('actionCronJob')) { + throw new Exception('Failed to register hooks'); + } + + // Set default configuration values + EcomZoneLogger::log("Setting default configuration values", "INFO"); + $defaultConfig = [ + 'ECOMZONE_API_URL' => 'https://dropship.ecomzone.eu/api', + 'ECOMZONE_API_TOKEN' => '', + 'ECOMZONE_LAST_SYNC' => '', + 'ECOMZONE_LOG_LEVEL' => 'INFO', + 'ECOMZONE_CRON_TOKEN' => Tools::passwdGen(32) // Generate random token for cron + ]; + + foreach ($defaultConfig as $key => $value) { + if (!Configuration::updateValue($key, $value)) { + throw new Exception('Failed to set configuration value: ' . $key); + } + } + + // Set directory permissions + EcomZoneLogger::log("Setting directory permissions", "INFO"); + if (!$this->setImageDirectoryPermissions()) { + throw new Exception('Failed to set directory permissions'); + } + + EcomZoneLogger::log("Module installation completed successfully", "INFO"); + return true; + } catch (Exception $e) { + // Log the error + EcomZoneLogger::log('Module installation failed', 'ERROR', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString() + ]); + + // Clean up any partial installation + $this->uninstall(); + + // Set error message that can be retrieved + $this->_errors[] = $this->l('Installation failed: ') . $e->getMessage(); + + return false; + } + } + + private function createTables() + { + $sql = []; + + // Add module logging table + $sql[] = 'CREATE TABLE IF NOT EXISTS `' . _DB_PREFIX_ . 'ecomzone_sync_log` ( + `id_sync_log` int(11) NOT NULL AUTO_INCREMENT, + `sync_date` datetime NOT NULL, + `status` varchar(32) NOT NULL, + `message` text, + `details` text, + PRIMARY KEY (`id_sync_log`) + ) ENGINE=' . _MYSQL_ENGINE_ . ' DEFAULT CHARSET=utf8;'; + + // Add product sync table to track imported products + $sql[] = 'CREATE TABLE IF NOT EXISTS `' . _DB_PREFIX_ . 'ecomzone_product` ( + `id_product` int(11) NOT NULL, + `reference` varchar(64) NOT NULL, + `last_sync` datetime NOT NULL, + `sync_status` varchar(32) NOT NULL, + PRIMARY KEY (`id_product`), + UNIQUE KEY `reference` (`reference`) + ) ENGINE=' . _MYSQL_ENGINE_ . ' DEFAULT CHARSET=utf8;'; + + foreach ($sql as $query) { + if (!Db::getInstance()->execute($query)) { + throw new Exception('Failed to create required database table: ' . Db::getInstance()->getMsgError()); } } } public function uninstall() { - return parent::uninstall() && - Configuration::deleteByName("ECOMZONE_API_URL") && - Configuration::deleteByName("ECOMZONE_API_TOKEN") && - Configuration::deleteByName("ECOMZONE_CRON_TOKEN") && - Configuration::deleteByName("ECOMZONE_LAST_SYNC"); + EcomZoneLogger::log("Starting module uninstallation", "INFO"); + + try { + // Remove configuration values + $configKeys = [ + 'ECOMZONE_API_URL', + 'ECOMZONE_API_TOKEN', + 'ECOMZONE_LAST_SYNC', + 'ECOMZONE_SYNC_STATE', + 'ECOMZONE_SYNC_PROGRESS', + 'ECOMZONE_LOG_LEVEL', + 'ECOMZONE_CRON_TOKEN' + ]; + + foreach ($configKeys as $key) { + Configuration::deleteByName($key); + } + + // Uninstall parent module + if (!parent::uninstall()) { + throw new Exception('Failed to uninstall parent module'); + } + + EcomZoneLogger::log("Module uninstallation completed successfully", "INFO"); + return true; + } catch (Exception $e) { + EcomZoneLogger::log('Module uninstallation failed', 'ERROR', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString() + ]); + $this->_errors[] = $this->l('Uninstallation failed: ') . $e->getMessage(); + return false; + } } public function getContent() { - $output = ""; + // Ensure no output before AJAX responses + if (Tools::getValue('ajax')) { + ob_clean(); + header('Content-Type: application/json'); + } + $output = ""; + + // Get admin token + $token = Tools::getAdminTokenLite('AdminModules'); + if (Tools::isSubmit("submitEcomZoneModule")) { $apiToken = Tools::getValue("ECOMZONE_API_TOKEN"); - if (Configuration::updateValue("ECOMZONE_API_TOKEN", $apiToken)) { - $output .= $this->displayConfirmation( - $this->trans( - "Settings updated", - [], - "Modules.Ecomzone.Admin" - ) - ); + $apiUrl = Tools::getValue("ECOMZONE_API_URL"); + + Configuration::updateValue("ECOMZONE_API_TOKEN", $apiToken); + Configuration::updateValue("ECOMZONE_API_URL", $apiUrl); + + $output .= $this->displayConfirmation( + $this->trans( + "Settings updated", + [], + "Modules.Ecomzone.Admin" + ) + ); + } + + // Handle manual sync request + if (Tools::isSubmit("syncProducts")) { + try { + // If this is an AJAX request, we need to handle it differently + $isAjax = Tools::getValue('ajax'); + + if (empty(Configuration::get("ECOMZONE_API_TOKEN"))) { + throw new Exception($this->l('API token not configured. Please configure the API token first.')); + } + + $productSync = new EcomZoneProductSync(); + + // Reset sync state when starting new sync + if (!$isAjax) { + $productSync->resetSyncState(); + } + + $result = $productSync->importProducts(100); + + if ($result['is_complete']) { + Configuration::updateValue("ECOMZONE_LAST_SYNC", date("Y-m-d H:i:s")); + } + + // Store sync progress + $progress = [ + 'processed' => $result["imported"], + 'total' => $result["total"], + 'percentage' => ($result["total"] > 0) ? round(($result["imported"] / $result["total"]) * 100) : 0, + 'is_complete' => $result['is_complete'], + 'current_page' => $result['current_page'] + ]; + + Configuration::updateValue("ECOMZONE_SYNC_PROGRESS", json_encode($progress)); + + if ($isAjax) { + die(json_encode([ + 'success' => true, + 'progress' => $progress + ])); + } + + if ($result['is_complete']) { + $output .= $this->displayConfirmation( + sprintf( + $this->trans( + "Products synchronized successfully. Imported: %d, Total Available: %d, Errors: %d", + [], + "Modules.Ecomzone.Admin" + ), + $result["imported"], + $result["total"], + count($result["errors"]) + ) + ); + + if (!empty($result["errors"])) { + $errorMessages = array_map(function($error) { + return sprintf("SKU: %s - Error: %s", $error["sku"], $error["error"]); + }, $result["errors"]); + + $output .= $this->displayWarning( + implode("
", $errorMessages) + ); + } + } + } catch (Exception $e) { + EcomZoneLogger::log("Sync failed", "ERROR", [ + "error" => $e->getMessage(), + "trace" => $e->getTraceAsString() + ]); + + if ($isAjax) { + die(json_encode([ + 'success' => false, + 'error' => $e->getMessage() + ])); + } + + $output .= $this->displayError($e->getMessage()); } } - // Handle product fetch request + // Handle continue sync request (AJAX) + if (Tools::getValue('action') === 'continueSyncProducts' && Tools::getValue('ajax')) { + try { + if (empty(Configuration::get("ECOMZONE_API_TOKEN"))) { + throw new Exception($this->l('API token not configured. Please configure the API token first.')); + } + + $productSync = new EcomZoneProductSync(); + $result = $productSync->importProducts(100); + + if ($result['is_complete']) { + Configuration::updateValue("ECOMZONE_LAST_SYNC", date("Y-m-d H:i:s")); + } + + // Store sync progress + $progress = [ + 'processed' => $result["imported"], + 'total' => $result["total"], + 'percentage' => ($result["total"] > 0) ? round(($result["imported"] / $result["total"]) * 100) : 0, + 'is_complete' => $result['is_complete'], + 'current_page' => $result['current_page'] + ]; + + Configuration::updateValue("ECOMZONE_SYNC_PROGRESS", json_encode($progress)); + + die(json_encode([ + 'success' => true, + 'progress' => $progress + ])); + } catch (Exception $e) { + EcomZoneLogger::log("Sync continuation failed", "ERROR", [ + "error" => $e->getMessage() + ]); + die(json_encode([ + 'success' => false, + 'error' => $e->getMessage() + ])); + } + } + + // Handle product fetch request for preview if (Tools::isSubmit("fetchProducts") || Tools::getValue("page")) { try { $page = (int) Tools::getValue("page", 1); $perPage = 10; $result = $this->makeApiRequest( - "catalog?page={$page}&per_page={$perPage}" + "catalog?page={$page}&per_page={$perPage}", + "GET" ); if (!empty($result["data"])) { + // Add sync status to products + foreach ($result["data"] as &$product) { + $productId = Product::getIdByReference($product["sku"]); + $product["sync_status"] = $productId ? "success" : "pending"; + } + $totalPages = ceil($result["total"] / $perPage); $this->context->smarty->assign([ @@ -131,15 +518,27 @@ class Ecomzone extends Module } } } catch (Exception $e) { + EcomZoneLogger::log("Product fetch failed", "ERROR", [ + "error" => $e->getMessage() + ]); $output .= $this->displayError($e->getMessage()); } } + // Get sync progress + $syncProgress = json_decode(Configuration::get("ECOMZONE_SYNC_PROGRESS"), true); + + // Get recent activity logs + $activityLog = $this->getRecentActivityLogs(); + $this->context->smarty->assign([ "module_dir" => $this->_path, "ECOMZONE_API_TOKEN" => Configuration::get("ECOMZONE_API_TOKEN"), - "ECOMZONE_CRON_TOKEN" => Configuration::get("ECOMZONE_CRON_TOKEN"), + "ECOMZONE_API_URL" => Configuration::get("ECOMZONE_API_URL"), "ECOMZONE_LAST_SYNC" => Configuration::get("ECOMZONE_LAST_SYNC"), + "SYNC_PROGRESS" => $syncProgress, + "ACTIVITY_LOG" => $activityLog, + "token" => $token, "current_url" => $this->context->link->getAdminLink("AdminModules", false) . "&configure=" . @@ -151,78 +550,77 @@ class Ecomzone extends Module "shop_url" => $this->context->link->getBaseLink(), ]); - return $output . - $this->display(__FILE__, "views/templates/admin/configure.tpl"); + return $output . $this->display(__FILE__, "views/templates/admin/configure.tpl"); + } + + private function getRecentActivityLogs($limit = 50) + { + $logFile = _PS_MODULE_DIR_ . $this->name . '/log/ecomzone.log'; + $logs = []; + + if (file_exists($logFile)) { + $lines = array_reverse(file($logFile)); + $count = 0; + + foreach ($lines as $line) { + if ($count >= $limit) break; + + $logEntry = json_decode($line, true); + if ($logEntry) { + $logs[] = [ + 'timestamp' => $logEntry['timestamp'] ?? date('Y-m-d H:i:s'), + 'level' => $logEntry['level'] ?? 'INFO', + 'message' => $logEntry['message'] ?? '', + 'details' => json_encode($logEntry['context'] ?? []) + ]; + $count++; + } + } + } + + return $logs; } private function makeApiRequest($endpoint, $method = "GET", $data = null) { - $apiUrl = Configuration::get("ECOMZONE_API_URL"); - $apiToken = Configuration::get("ECOMZONE_API_TOKEN"); - - if (empty($apiToken)) { - throw new Exception( - $this->trans( - "API token not configured", - [], - "Modules.Ecomzone.Admin" - ) - ); + try { + // Use the EcomZoneClient for all API requests + $client = new EcomZoneClient(); + + // Add endpoint to URL + $params = []; + + // Extract query params if present in endpoint + if (strpos($endpoint, '?') !== false) { + list($endpoint, $queryString) = explode('?', $endpoint, 2); + parse_str($queryString, $params); + } + + // Ensure method is a string and not an array (prevent 405 Method Not Allowed) + if (!is_string($method)) { + EcomZoneLogger::log("Invalid method type detected", "WARNING", [ + 'endpoint' => $endpoint, + 'method_type' => gettype($method), + 'method' => $method + ]); + $method = "GET"; // Default to GET if method is not a string + } + + // Make the request using our client + if ($method === 'GET') { + return $client->makeRequest($endpoint, $method, $params); + } else { + return $client->makeRequest($endpoint, $method, [], $data); + } + } catch (Exception $e) { + EcomZoneLogger::log("API Request Failed in module", "ERROR", [ + 'endpoint' => $endpoint, + 'method' => $method, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString() + ]); + throw $e; } - - $curl = curl_init(); - $url = rtrim($apiUrl, "/") . "/" . ltrim($endpoint, "/"); - - $options = [ - CURLOPT_URL => $url, - CURLOPT_RETURNTRANSFER => true, - CURLOPT_ENCODING => "", - CURLOPT_MAXREDIRS => 10, - CURLOPT_TIMEOUT => 30, - CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1, - CURLOPT_CUSTOMREQUEST => $method, - CURLOPT_HTTPHEADER => [ - "Authorization: Bearer " . $apiToken, - "Accept: application/json", - "Content-Type: application/json", - ], - CURLOPT_SSL_VERIFYPEER => true, - CURLOPT_SSL_VERIFYHOST => 2, - ]; - - if ($data !== null) { - $options[CURLOPT_POSTFIELDS] = json_encode($data); - } - - curl_setopt_array($curl, $options); - - $response = curl_exec($curl); - $err = curl_error($curl); - $httpCode = curl_getinfo($curl, CURLINFO_HTTP_CODE); - - curl_close($curl); - - if ($err) { - throw new Exception("cURL Error: " . $err); - } - - if ($httpCode >= 400) { - $errorData = json_decode($response, true); - $errorMessage = isset($errorData['error']) - ? $errorData['error'] - : "HTTP Error: " . $httpCode; - throw new EcomZoneException( - $errorMessage, - EcomZoneException::API_ERROR - ); - } - - $decodedResponse = json_decode($response, true); - if (json_last_error() !== JSON_ERROR_NONE) { - throw new Exception("Invalid JSON response"); - } - - return $decodedResponse; } public function hookActionCronJob($params) @@ -245,7 +643,8 @@ class Ecomzone extends Module do { $result = $this->makeApiRequest( - "catalog?page={$page}&per_page={$perPage}" + "catalog?page={$page}&per_page={$perPage}", + "GET" ); if (empty($result["data"])) { @@ -341,5 +740,35 @@ class Ecomzone extends Module ); } } + + public function hookActionProductUpdate($params) + { + try { + $product = $params['product']; + EcomZoneLogger::log("Product updated", "INFO", [ + "product_id" => $product->id, + "reference" => $product->reference + ]); + } catch (Exception $e) { + EcomZoneLogger::log("Failed to handle product update", "ERROR", [ + "error" => $e->getMessage() + ]); + } + } + + public function hookActionProductDelete($params) + { + try { + $product = $params['product']; + EcomZoneLogger::log("Product deleted", "INFO", [ + "product_id" => $product->id, + "reference" => $product->reference + ]); + } catch (Exception $e) { + EcomZoneLogger::log("Failed to handle product deletion", "ERROR", [ + "error" => $e->getMessage() + ]); + } + } } diff --git a/ecomzone/get_token.php b/ecomzone/get_token.php new file mode 100644 index 0000000..058b5a7 --- /dev/null +++ b/ecomzone/get_token.php @@ -0,0 +1,53 @@ + 'localhost', + 'username' => 'prestashop', + 'password' => 'prestashop', + 'database' => 'prestashop', + 'prefix' => 'ps_' +]; + +echo "Attempting direct database connection...\n"; + +try { + $db = new PDO("mysql:host={$dbConfig['host']};dbname={$dbConfig['database']}", $dbConfig['username'], $dbConfig['password']); + $db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + + $stmt = $db->prepare("SELECT value FROM {$dbConfig['prefix']}configuration WHERE name = 'ECOMZONE_API_TOKEN'"); + $stmt->execute(); + + $result = $stmt->fetch(PDO::FETCH_ASSOC); + + if ($result && isset($result['value'])) { + echo "API Token found in database: " . $result['value'] . "\n"; + } else { + echo "API Token not found in database.\n"; + } +} catch (PDOException $e) { + echo "Database connection failed: " . $e->getMessage() . "\n"; + echo "You'll need to manually update the api_test.php file with your API token.\n"; +} \ No newline at end of file diff --git a/ecomzone/readme_api_fix.txt b/ecomzone/readme_api_fix.txt new file mode 100644 index 0000000..0519ecb --- /dev/null +++ b/ecomzone/readme_api_fix.txt @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ecomzone/test_api.php b/ecomzone/test_api.php new file mode 100644 index 0000000..52d87c0 --- /dev/null +++ b/ecomzone/test_api.php @@ -0,0 +1,174 @@ + 'https://dropship.ecomzone.eu/api', + 'ECOMZONE_API_TOKEN' => 'klRyAdrXaxL0s6PEUp7LDlH6T8aPSCtBY8NiEHsHiWpc6646K2TZPi5KMxUg' // Actual API token + ]; + return isset($config[$key]) ? $config[$key] : $default; + } + + public static function updateValue($key, $value) { + return true; + } + } +} + +if (!class_exists('Context')) { + class Context { + public $shop; + public static $instance; + + public function __construct() { + $this->shop = new stdClass(); + $this->shop->id = 1; + } + + public static function getContext() { + if (!self::$instance) { + self::$instance = new Context(); + } + return self::$instance; + } + } +} + +if (!class_exists('PrestaShopLogger')) { + class PrestaShopLogger { + public static function addLog($message, $severity = 1, $error_code = null, $object_type = null, $object_id = null, $allow_duplicate = false, $id_employee = null) { + return true; + } + } +} + +// Disable output buffering +if (ob_get_level()) { + ob_end_clean(); +} + +// Set headers for plain text output +header('Content-Type: text/plain'); +header('Cache-Control: no-cache, must-revalidate'); +header('Expires: Mon, 26 Jul 1997 05:00:00 GMT'); + +echo "EcomZone API Test\n"; +echo "================\n\n"; + +try { + // Get API settings + $apiUrl = Configuration::get('ECOMZONE_API_URL'); + $apiToken = Configuration::get('ECOMZONE_API_TOKEN'); + + echo "API URL: " . $apiUrl . "\n"; + echo "API Token: " . (empty($apiToken) || $apiToken === 'YOUR_API_TOKEN' ? + "Not set - Please edit test_api.php and replace YOUR_API_TOKEN with your actual token" : + "Set (length: " . strlen($apiToken) . " chars)") . "\n\n"; + + if (empty($apiToken) || $apiToken === 'YOUR_API_TOKEN') { + throw new Exception("API token is not configured. Please edit test_api.php and replace YOUR_API_TOKEN with your actual token."); + } + + echo "Testing API connection...\n\n"; + + // Create API client + $client = new EcomZoneClient(); + + // Test API connectivity with catalog endpoint + echo "Requesting catalog data (page 1, limit 1)...\n"; + $start = microtime(true); + $result = $client->getCatalog(1, 1); + $end = microtime(true); + + echo "Request completed in " . round(($end - $start) * 1000, 2) . " ms\n"; + + if (isset($result['data']) && is_array($result['data'])) { + echo "Success! Received " . count($result['data']) . " product(s)\n"; + echo "Total products available: " . ($result['total'] ?? 'unknown') . "\n\n"; + + if (!empty($result['data'])) { + $sku = $result['data'][0]['sku'] ?? null; + + if ($sku) { + echo "Testing product detail retrieval...\n"; + echo "Requesting product with SKU: " . $sku . "\n"; + + $start = microtime(true); + $productDetail = $client->getProduct($sku); + $end = microtime(true); + + echo "Request completed in " . round(($end - $start) * 1000, 2) . " ms\n"; + + if (isset($productDetail['data']) && !empty($productDetail['data'])) { + echo "Success! Retrieved product details\n"; + echo "Product name: " . ($productDetail['data']['product_name'] ?? 'unknown') . "\n"; + } else { + echo "Error: Failed to retrieve product details\n"; + echo "Response: " . print_r($productDetail, true) . "\n"; + } + } + } + } else { + echo "Error: Invalid catalog response\n"; + echo "Response: " . print_r($result, true) . "\n"; + } + + echo "\nAPI test completed successfully!\n"; + +} catch (Exception $e) { + echo "ERROR: " . $e->getMessage() . "\n"; + + if ($e instanceof EcomZoneException) { + echo "Error Code: " . $e->getCode() . "\n"; + } + + echo "\nStack Trace:\n" . $e->getTraceAsString() . "\n"; + + echo "\nCheck the log file for more details: modules/ecomzone/log/ecomzone.log\n"; +} + +echo "\nLog contents (last 10 entries):\n"; +echo "==============================\n\n"; + +try { + $logEntries = EcomZoneLogger::getLogContents(10); + foreach ($logEntries as $entry) { + $context = ''; + if (!empty($entry['context'])) { + $context = ' - ' . json_encode($entry['context']); + } + echo $entry['timestamp'] . ' [' . $entry['level'] . '] ' . $entry['message'] . $context . "\n"; + } +} catch (Exception $e) { + echo "Failed to retrieve logs: " . $e->getMessage() . "\n"; +} \ No newline at end of file diff --git a/ecomzone/test_image_download.php b/ecomzone/test_image_download.php new file mode 100644 index 0000000..4047fd9 --- /dev/null +++ b/ecomzone/test_image_download.php @@ -0,0 +1,194 @@ +getMessage() . "\n"; +} + +echo "\n"; + +// Method 2: Using cURL with detailed debugging +echo "Using cURL with detailed debugging\n"; +try { + $tempFile = 'tmp/test_image_debug.jpg'; + + if (!function_exists('curl_init')) { + echo "FAILED: cURL is not installed\n"; + } else { + $ch = curl_init(); + curl_setopt_array($ch, [ + CURLOPT_URL => $testImageUrl, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_MAXREDIRS => 5, + CURLOPT_TIMEOUT => 30, + CURLOPT_SSL_VERIFYPEER => false, + CURLOPT_SSL_VERIFYHOST => 0, + CURLOPT_USERAGENT => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', + CURLOPT_HEADER => 1, // Include headers in the response + CURLOPT_VERBOSE => true, + ]); + + // Create a stream for the verbose output + $verbose = fopen('tmp/curl_verbose.log', 'w+'); + curl_setopt($ch, CURLOPT_STDERR, $verbose); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $contentType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE); + $headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE); + $error = curl_error($ch); + + // Extract headers and body + $headers = substr($response, 0, $headerSize); + $imageData = substr($response, $headerSize); + + curl_close($ch); + + // Close and read the verbose log + rewind($verbose); + $verboseLog = stream_get_contents($verbose); + fclose($verbose); + + echo "HTTP Status Code: $httpCode\n"; + echo "Content Type: $contentType\n"; + echo "Headers:\n$headers\n"; + + if ($httpCode !== 200 || empty($imageData)) { + echo "FAILED: HTTP Error $httpCode - $error\n"; + } else { + file_put_contents($tempFile, $imageData); + $fileSize = filesize($tempFile); + echo "Downloaded " . number_format($fileSize) . " bytes to $tempFile\n"; + + // Check if it's a valid image + $imageInfo = @getimagesize($tempFile); + if ($imageInfo) { + echo "SUCCESS: Valid image detected\n"; + echo "Image dimensions: " . $imageInfo[0] . 'x' . $imageInfo[1] . ", type: " . $imageInfo['mime'] . "\n"; + } else { + echo "WARNING: The downloaded file is not a valid image\n"; + + // Analyze the first 100 bytes of the content + echo "First 100 bytes of the content:\n"; + $contentPreview = bin2hex(substr($imageData, 0, 50)); + echo chunk_split($contentPreview, 2, ' ') . "\n"; + + // Show first 100 characters if it looks like text + echo "Content as text (first 100 chars):\n"; + $textPreview = substr($imageData, 0, 100); + echo preg_replace('/[^\x20-\x7E]/', '.', $textPreview) . "\n"; + } + + echo "\nCURL Verbose Log:\n$verboseLog\n"; + } + } +} catch (Exception $e) { + echo "FAILED: Exception occurred: " . $e->getMessage() . "\n"; +} + +// Try a publicly accessible image as a reference test +echo "\n\nTesting with a public reference image...\n"; +$publicImageUrl = 'https://www.php.net/images/logos/new-php-logo.svg'; +echo "Public Image URL: $publicImageUrl\n\n"; + +try { + $tempFile = 'tmp/test_reference_image.jpg'; + + $ch = curl_init(); + curl_setopt_array($ch, [ + CURLOPT_URL => $publicImageUrl, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_MAXREDIRS => 5, + CURLOPT_TIMEOUT => 30, + CURLOPT_SSL_VERIFYPEER => false, + CURLOPT_SSL_VERIFYHOST => 0, + CURLOPT_USERAGENT => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', + ]); + + $imageData = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $contentType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE); + + curl_close($ch); + + echo "HTTP Status Code: $httpCode\n"; + echo "Content Type: $contentType\n"; + + if ($httpCode !== 200 || empty($imageData)) { + echo "FAILED: Could not download reference image\n"; + } else { + file_put_contents($tempFile, $imageData); + $fileSize = filesize($tempFile); + echo "Downloaded " . number_format($fileSize) . " bytes to $tempFile\n"; + + // Check if it's a valid image + $imageInfo = @getimagesize($tempFile); + if ($imageInfo) { + echo "SUCCESS: Valid image detected\n"; + echo "Image dimensions: " . $imageInfo[0] . 'x' . $imageInfo[1] . ", type: " . $imageInfo['mime'] . "\n"; + } else { + echo "WARNING: The downloaded file is not a valid image\n"; + } + } +} catch (Exception $e) { + echo "FAILED: Exception occurred: " . $e->getMessage() . "\n"; +} + +echo "\nTest completed\n"; \ No newline at end of file diff --git a/ecomzone/test_image_with_auth.php b/ecomzone/test_image_with_auth.php new file mode 100644 index 0000000..0944616 --- /dev/null +++ b/ecomzone/test_image_with_auth.php @@ -0,0 +1,318 @@ + $apiUrl, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_TIMEOUT => 30, + CURLOPT_SSL_VERIFYPEER => false, + CURLOPT_SSL_VERIFYHOST => 0, + CURLOPT_HTTPHEADER => [ + 'Accept: application/json', + 'Authorization: Bearer ' . $apiToken + ] + ]); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $contentType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE); + $errorMsg = curl_error($ch); + curl_close($ch); + + echo "HTTP Status Code: $httpCode\n"; + echo "Content Type: $contentType\n"; + + if ($httpCode >= 400) { + echo "API Error: $errorMsg\n"; + return null; + } + + $data = json_decode($response, true); + if (json_last_error() !== JSON_ERROR_NONE) { + echo "JSON Decode Error: " . json_last_error_msg() . "\n"; + return null; + } + + return $data; +} + +// Use the specific SKU provided by the user +$sku = '1817-911'; +echo "Using the specific SKU: $sku\n\n"; + +// Skip catalog fetching step since we have a specific SKU to test + +// STEP 2: Get detailed product information +echo "STEP 2: Fetching detailed product information for SKU: $sku\n"; +$productData = makeApiRequest('product/' . urlencode($sku), $apiToken); + +if (!$productData || empty($productData['data'])) { + echo "Failed to get product details.\n"; + exit(1); +} + +// Extract image URLs from product data +$imageUrls = []; + +if (!empty($productData['data']['image'])) { + $imageUrls[] = $productData['data']['image']; + echo "Found main image: " . $productData['data']['image'] . "\n"; +} + +// Try to parse images field which might be JSON string or array +if (!empty($productData['data']['images'])) { + if (is_string($productData['data']['images'])) { + $parsedImages = json_decode($productData['data']['images'], true); + if (json_last_error() === JSON_ERROR_NONE && is_array($parsedImages)) { + foreach ($parsedImages as $img) { + $imageUrls[] = $img; + echo "Found additional image (from JSON string): $img\n"; + } + } + } elseif (is_array($productData['data']['images'])) { + foreach ($productData['data']['images'] as $img) { + $imageUrls[] = $img; + echo "Found additional image (from array): $img\n"; + } + } +} + +if (empty($imageUrls)) { + echo "No image URLs found in product data.\n"; + exit(1); +} + +// STEP 3: Try direct download of the first image with authorization +echo "\nSTEP 3: Attempting to download the first image directly with Authorization header\n"; +$firstImageUrl = $imageUrls[0]; +echo "Image URL: $firstImageUrl\n"; + +// Try with direct download +$ch = curl_init(); +curl_setopt_array($ch, [ + CURLOPT_URL => $firstImageUrl, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_TIMEOUT => 30, + CURLOPT_SSL_VERIFYPEER => false, + CURLOPT_SSL_VERIFYHOST => 0, + CURLOPT_HTTPHEADER => [ + 'Accept: image/webp,image/apng,image/*,*/*;q=0.8', + 'Authorization: Bearer ' . $apiToken + ] +]); + +$imageData = curl_exec($ch); +$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); +$contentType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE); +curl_close($ch); + +echo "HTTP Status Code: $httpCode\n"; +echo "Content Type: $contentType\n"; + +// Check if the response is HTML (authentication error) +$isHtml = stripos($contentType, 'text/html') !== false || + (strlen($imageData) > 15 && stripos($imageData, '') !== false); + +if ($isHtml) { + echo "FAILED: Authentication error. Received HTML instead of image data.\n"; + file_put_contents('tmp/auth_error.html', $imageData); + echo "Full HTML response saved to tmp/auth_error.html\n"; +} else { + $tempFile = 'tmp/direct_download.jpg'; + file_put_contents($tempFile, $imageData); + $fileSize = filesize($tempFile); + echo "Downloaded " . number_format($fileSize) . " bytes to $tempFile\n"; + + $imageInfo = @getimagesize($tempFile); + if ($imageInfo) { + echo "SUCCESS: Valid image detected\n"; + echo "Image dimensions: " . $imageInfo[0] . 'x' . $imageInfo[1] . ", type: " . $imageInfo['mime'] . "\n"; + } else { + echo "WARNING: The downloaded file is not a valid image\n"; + } +} + +// STEP 4: Check if there's a dedicated API endpoint for downloading images +echo "\nSTEP 4: Checking if there's a dedicated image download API endpoint\n"; + +// Extract image ID from the URL (last part after the slash) +$urlParts = explode('/', $firstImageUrl); +$imageId = end($urlParts); + +echo "Trying to access image via API using ID: $imageId\n"; + +// Try these possible API endpoints for image download +$possibleEndpoints = [ + 'image/' . $imageId, + 'download/image/' . $imageId, + 'media/' . $imageId, + 'product/image/' . $imageId, + 'product/' . $sku . '/image/' . $imageId +]; + +$imageDownloaded = false; +foreach ($possibleEndpoints as $endpoint) { + echo "\nTrying endpoint: $endpoint\n"; + + $ch = curl_init(); + curl_setopt_array($ch, [ + CURLOPT_URL => 'https://dropship.ecomzone.eu/api/' . $endpoint, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_TIMEOUT => 30, + CURLOPT_SSL_VERIFYPEER => false, + CURLOPT_SSL_VERIFYHOST => 0, + CURLOPT_HTTPHEADER => [ + 'Accept: image/webp,image/apng,image/*,*/*;q=0.8', + 'Authorization: Bearer ' . $apiToken + ] + ]); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $contentType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE); + curl_close($ch); + + echo "HTTP Status Code: $httpCode\n"; + echo "Content Type: $contentType\n"; + + if ($httpCode === 200 && !stripos($contentType, 'text/html')) { + $tempFile = 'tmp/api_' . basename($endpoint) . '.jpg'; + file_put_contents($tempFile, $response); + + $imageInfo = @getimagesize($tempFile); + if ($imageInfo) { + echo "SUCCESS: Valid image downloaded via API endpoint\n"; + echo "Image dimensions: " . $imageInfo[0] . 'x' . $imageInfo[1] . ", type: " . $imageInfo['mime'] . "\n"; + $imageDownloaded = true; + } else { + echo "Response doesn't appear to be a valid image\n"; + unlink($tempFile); + } + } +} + +if (!$imageDownloaded) { + echo "\nNone of the attempted API endpoints worked for direct image download.\n"; +} + +// STEP 5: Try using an alternative base URL for the image +echo "\nSTEP 5: Trying alternative base URLs for the image\n"; + +// Extract the path part from the URL +$parsedUrl = parse_url($firstImageUrl); +$imagePath = $parsedUrl['path'] ?? ''; + +if (empty($imagePath)) { + echo "Could not extract image path from URL.\n"; +} else { + echo "Image path: $imagePath\n"; + + // Try with alternative base URLs + $alternativeBaseUrls = [ + 'https://api.dropship.ecomzone.eu', + 'https://cdn.dropship.ecomzone.eu', + 'https://media.dropship.ecomzone.eu', + 'https://images.dropship.ecomzone.eu' + ]; + + foreach ($alternativeBaseUrls as $baseUrl) { + $fullUrl = $baseUrl . $imagePath; + echo "\nTrying alternative URL: $fullUrl\n"; + + $ch = curl_init(); + curl_setopt_array($ch, [ + CURLOPT_URL => $fullUrl, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_TIMEOUT => 30, + CURLOPT_SSL_VERIFYPEER => false, + CURLOPT_SSL_VERIFYHOST => 0, + CURLOPT_HTTPHEADER => [ + 'Accept: image/webp,image/apng,image/*,*/*;q=0.8', + 'Authorization: Bearer ' . $apiToken + ] + ]); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $contentType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE); + curl_close($ch); + + echo "HTTP Status Code: $httpCode\n"; + echo "Content Type: $contentType\n"; + + if ($httpCode === 200 && !stripos($contentType, 'text/html')) { + $tempFile = 'tmp/alt_domain_' . str_replace(['https://', '.'], ['', '_'], $baseUrl) . '.jpg'; + file_put_contents($tempFile, $response); + + $imageInfo = @getimagesize($tempFile); + if ($imageInfo) { + echo "SUCCESS: Valid image downloaded from alternative URL\n"; + echo "Image dimensions: " . $imageInfo[0] . 'x' . $imageInfo[1] . ", type: " . $imageInfo['mime'] . "\n"; + } else { + echo "Response doesn't appear to be a valid image\n"; + unlink($tempFile); + } + } + } +} + +// Summary +echo "\n=== SUMMARY ===\n"; +echo "Product SKU: $sku\n"; +echo "Found " . count($imageUrls) . " image URLs in product data\n"; +echo "Direct image downloads with Authorization header don't work - server returns HTML login page\n"; + +echo "\nPOSSIBLE SOLUTIONS:\n"; +echo "1. The EcomZone API doesn't support direct image downloads through Authorization headers\n"; +echo "2. We may need to use a different authentication method for images\n"; +echo "3. Images might require a signed URL or temporary access token\n"; +echo "4. Consider contacting EcomZone support for the correct way to download images\n"; +echo "\nTest completed\n"; \ No newline at end of file diff --git a/ecomzone/views/templates/admin/configure.tpl b/ecomzone/views/templates/admin/configure.tpl index 2c9718c..9bf5086 100644 --- a/ecomzone/views/templates/admin/configure.tpl +++ b/ecomzone/views/templates/admin/configure.tpl @@ -1,215 +1,357 @@ - -{* views/templates/admin/configure.tpl *} +{* +* @author EcomZone +* @copyright EcomZone +* @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 +*}

{l s='EcomZone Configuration' mod='ecomzone'}

-
-
- -
- + + + +
+
+
+ +
+ +
+
+ +
+ +
+ +
+
+
+ +
+ + +
+

{l s='Product Synchronization' mod='ecomzone'}

+
+

{l s='Last synchronization:' mod='ecomzone'} {if $ECOMZONE_LAST_SYNC}{$ECOMZONE_LAST_SYNC}{else}{l s='Never' mod='ecomzone'}{/if}

+ +
+ +
+
+ +
+
+
-
- -
-

- {if $ECOMZONE_LAST_SYNC} - {$ECOMZONE_LAST_SYNC|escape:'html':'UTF-8'} - {else} - {l s='Never' mod='ecomzone'} - {/if} +

- - + +
+

{l s='Product Preview' mod='ecomzone'}

+
+
+ +
+
+ +
+
+
+ + {if isset($API_PRODUCTS) && $API_PRODUCTS} +
+ + + + + + + + + + + + {foreach from=$API_PRODUCTS item=product} + + + + + + + + {/foreach} + +
{l s='SKU' mod='ecomzone'}{l s='Name' mod='ecomzone'}{l s='Price' mod='ecomzone'}{l s='Stock' mod='ecomzone'}{l s='Status' mod='ecomzone'}
{$product.sku|escape:'html':'UTF-8'}{$product.product_name|escape:'html':'UTF-8'}{$product.product_price|escape:'html':'UTF-8'}{$product.stock|escape:'html':'UTF-8'} + {if isset($product.sync_status)} + + {$product.sync_status|escape:'html':'UTF-8'} + + {/if} +
+
+ + {if isset($PAGINATION)} +
+
    + {for $page=1 to $PAGINATION.total_pages} +
  • + {$page} +
  • + {/for} +
+
+ {/if} + {/if}
- +
+{if isset($SYNC_PROGRESS)}
-

{l s='Product Import' mod='ecomzone'}

+

{l s='Sync Progress' mod='ecomzone'}

-
-
-
- -
+
+
+ {$SYNC_PROGRESS.percentage}%
- +
+

+ {l s='Processed:' mod='ecomzone'} {$SYNC_PROGRESS.processed} / {$SYNC_PROGRESS.total} +

+
+
+{/if} - {if isset($API_PRODUCTS) && $API_PRODUCTS} -
- - - - - - - - - - - - {foreach from=$API_PRODUCTS item=product} - - - - - - + + + {/if} + +
{l s='SKU' mod='ecomzone'}{l s='Name' mod='ecomzone'}{l s='Price' mod='ecomzone'}{l s='Stock' mod='ecomzone'}{l s='Actions' mod='ecomzone'}
{$product.sku|escape:'html':'UTF-8'}{$product.product_name|escape:'html':'UTF-8'}{$product.product_price|escape:'html':'UTF-8'}{$product.stock|escape:'html':'UTF-8'} -
- - - - -

- {l s='Add this URL to your server\'s crontab to run every 24 hours.' mod='ecomzone'} -

- - + {else} +
{l s='No activity logs available' mod='ecomzone'}
-{if isset($ECOMZONE_LOGS) && $ECOMZONE_LOGS} -
-

{l s='Recent Logs' mod='ecomzone'}

-
-
- - - {foreach from=$ECOMZONE_LOGS item=log} - - - - {/foreach} - -
{$log|escape:'html':'UTF-8'}
-
-
-
-{/if} - - - + +