From e25a685d5f45a2f6985b9125fd10493d931d899c Mon Sep 17 00:00:00 2001 From: Maksym Kolomiiets Date: Mon, 9 Mar 2026 14:27:07 +0100 Subject: [PATCH 1/4] add task solution --- index.html | 3 +- package-lock.json | 9 +- package.json | 2 +- public/img/icons/ArrowLeft.png | Bin 0 -> 242 bytes public/img/icons/ArrowLeft111.png | Bin 0 -> 242 bytes public/img/icons/ArrowLeftHollow.png | Bin 0 -> 222 bytes public/img/icons/ArrowLeftHover.png | Bin 0 -> 228 bytes public/img/icons/ArrowRight.png | Bin 0 -> 239 bytes public/img/icons/ArrowRightHollow.png | Bin 0 -> 223 bytes public/img/icons/ArrowRightHover.png | Bin 0 -> 218 bytes public/img/icons/ArrowUp.png | Bin 0 -> 236 bytes public/img/icons/Close.png | Bin 0 -> 325 bytes public/img/icons/Favicon.png | Bin 0 -> 597 bytes public/img/icons/FavoriteFilled.png | Bin 0 -> 345 bytes public/img/icons/Favorites.png | Bin 0 -> 353 bytes public/img/icons/Home.png | Bin 0 -> 358 bytes public/img/icons/Menu.png | Bin 0 -> 217 bytes public/img/icons/Shoppingbag.png | Bin 0 -> 391 bytes public/img/logo/Logo.png | Bin 0 -> 2547 bytes src/App.scss | 36 +- src/App.tsx | 37 ++- src/api/Products.ts | 54 +++ .../Breadcrumbs/Breadcrumbs.module.scss | 50 +++ src/components/Breadcrumbs/Breadcrumbs.tsx | 37 +++ src/components/Footer/Footer.module.scss | 93 ++++++ src/components/Footer/Footer.tsx | 47 +++ src/components/Header/Header.module.scss | 237 ++++++++++++++ src/components/Header/Header.tsx | 133 ++++++++ src/components/Loader/Loader.module.scss | 25 ++ src/components/Loader/Loader.tsx | 7 + src/hooks/useLocalStorage.ts | 22 ++ src/index.tsx | 14 +- src/modules/CartPage/CartPage.module.scss | 91 ++++++ src/modules/CartPage/CartPage.tsx | 67 ++++ .../CartPage/components/CartItem.module.scss | 95 ++++++ src/modules/CartPage/components/CartItem.tsx | 57 ++++ .../FavoritesPage/FavoritesPage.module.scss | 19 ++ src/modules/FavoritesPage/FavoritesPage.tsx | 25 ++ src/modules/HomePage/HomePage.module.scss | 43 +++ src/modules/HomePage/HomePage.tsx | 50 +++ .../Categories/Categories.module.scss | 86 +++++ .../components/Categories/Categories.tsx | 60 ++++ .../PicturesSlider/PicturesSlider.module.scss | 90 +++++ .../PicturesSlider/PicturesSlider.tsx | 94 ++++++ .../ProductDetailsPage.module.scss | 307 ++++++++++++++++++ .../ProductDetailsPage.tsx | 302 +++++++++++++++++ .../ProductsPage/ProductsPage.module.scss | 144 ++++++++ src/modules/ProductsPage/ProductsPage.tsx | 183 +++++++++++ .../ProductCard/ProductCard.module.scss | 162 +++++++++ .../components/ProductCard/ProductCard.tsx | 98 ++++++ .../ProductsSlider/ProductsSlider.module.scss | 76 +++++ .../ProductsSlider/ProductsSlider.tsx | 96 ++++++ src/modules/shared/context/CartContext.tsx | 85 +++++ .../shared/context/FavoritesContext.tsx | 60 ++++ src/styles/fonts.scss | 20 ++ src/styles/utils.scss | 22 ++ src/types/CartItem.ts | 7 + src/types/Product.ts | 15 + src/types/ProductDetails.ts | 24 ++ 59 files changed, 3171 insertions(+), 13 deletions(-) create mode 100644 public/img/icons/ArrowLeft.png create mode 100644 public/img/icons/ArrowLeft111.png create mode 100644 public/img/icons/ArrowLeftHollow.png create mode 100644 public/img/icons/ArrowLeftHover.png create mode 100644 public/img/icons/ArrowRight.png create mode 100644 public/img/icons/ArrowRightHollow.png create mode 100644 public/img/icons/ArrowRightHover.png create mode 100644 public/img/icons/ArrowUp.png create mode 100644 public/img/icons/Close.png create mode 100644 public/img/icons/Favicon.png create mode 100644 public/img/icons/FavoriteFilled.png create mode 100644 public/img/icons/Favorites.png create mode 100644 public/img/icons/Home.png create mode 100644 public/img/icons/Menu.png create mode 100644 public/img/icons/Shoppingbag.png create mode 100644 public/img/logo/Logo.png create mode 100644 src/api/Products.ts create mode 100644 src/components/Breadcrumbs/Breadcrumbs.module.scss create mode 100644 src/components/Breadcrumbs/Breadcrumbs.tsx create mode 100644 src/components/Footer/Footer.module.scss create mode 100644 src/components/Footer/Footer.tsx create mode 100644 src/components/Header/Header.module.scss create mode 100644 src/components/Header/Header.tsx create mode 100644 src/components/Loader/Loader.module.scss create mode 100644 src/components/Loader/Loader.tsx create mode 100644 src/hooks/useLocalStorage.ts create mode 100644 src/modules/CartPage/CartPage.module.scss create mode 100644 src/modules/CartPage/CartPage.tsx create mode 100644 src/modules/CartPage/components/CartItem.module.scss create mode 100644 src/modules/CartPage/components/CartItem.tsx create mode 100644 src/modules/FavoritesPage/FavoritesPage.module.scss create mode 100644 src/modules/FavoritesPage/FavoritesPage.tsx create mode 100644 src/modules/HomePage/HomePage.module.scss create mode 100644 src/modules/HomePage/HomePage.tsx create mode 100644 src/modules/HomePage/components/Categories/Categories.module.scss create mode 100644 src/modules/HomePage/components/Categories/Categories.tsx create mode 100644 src/modules/HomePage/components/PicturesSlider/PicturesSlider.module.scss create mode 100644 src/modules/HomePage/components/PicturesSlider/PicturesSlider.tsx create mode 100644 src/modules/ProductsDetailsPage/ProductDetailsPage.module.scss create mode 100644 src/modules/ProductsDetailsPage/ProductDetailsPage.tsx create mode 100644 src/modules/ProductsPage/ProductsPage.module.scss create mode 100644 src/modules/ProductsPage/ProductsPage.tsx create mode 100644 src/modules/shared/components/ProductCard/ProductCard.module.scss create mode 100644 src/modules/shared/components/ProductCard/ProductCard.tsx create mode 100644 src/modules/shared/components/ProductsSlider/ProductsSlider.module.scss create mode 100644 src/modules/shared/components/ProductsSlider/ProductsSlider.tsx create mode 100644 src/modules/shared/context/CartContext.tsx create mode 100644 src/modules/shared/context/FavoritesContext.tsx create mode 100644 src/styles/fonts.scss create mode 100644 src/styles/utils.scss create mode 100644 src/types/CartItem.ts create mode 100644 src/types/Product.ts create mode 100644 src/types/ProductDetails.ts diff --git a/index.html b/index.html index 095fb3a4537..8df2efecd5a 100644 --- a/index.html +++ b/index.html @@ -2,8 +2,9 @@ + - Vite + React + TS + Nice Gatgets
diff --git a/package-lock.json b/package-lock.json index 836b9e63b46..7ef0cda3bbb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,7 @@ }, "devDependencies": { "@cypress/react18": "^2.0.1", - "@mate-academy/scripts": "^1.8.5", + "@mate-academy/scripts": "^2.1.3", "@mate-academy/students-ts-config": "*", "@mate-academy/stylelint-config": "*", "@types/node": "^20.14.10", @@ -1184,10 +1184,11 @@ } }, "node_modules/@mate-academy/scripts": { - "version": "1.8.5", - "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-1.8.5.tgz", - "integrity": "sha512-mHRY2FkuoYCf5U0ahIukkaRo5LSZsxrTSgMJheFoyf3VXsTvfM9OfWcZIDIDB521kdPrScHHnRp+JRNjCfUO5A==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-2.1.3.tgz", + "integrity": "sha512-a07wHTj/1QUK2Aac5zHad+sGw4rIvcNl5lJmJpAD7OxeSbnCdyI6RXUHwXhjF5MaVo9YHrJ0xVahyERS2IIyBQ==", "dev": true, + "license": "MIT", "dependencies": { "@octokit/rest": "^17.11.2", "@types/get-port": "^4.2.0", diff --git a/package.json b/package.json index ae251685c8b..6fd0a6cbfe9 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ }, "devDependencies": { "@cypress/react18": "^2.0.1", - "@mate-academy/scripts": "^1.8.5", + "@mate-academy/scripts": "^2.1.3", "@mate-academy/students-ts-config": "*", "@mate-academy/stylelint-config": "*", "@types/node": "^20.14.10", diff --git a/public/img/icons/ArrowLeft.png b/public/img/icons/ArrowLeft.png new file mode 100644 index 0000000000000000000000000000000000000000..f836f04be2ba2cba15b1bb42d7f911eca88598b3 GIT binary patch literal 242 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`oCO|{#S9GG!XV7ZFl&wkP>``W z$lZxy-8q?;Kn_c~qpu?a!^VE@KZ&eBe!HiOV@L(#-ATTD4h8~l=~|0)B=jCu72WE_J$xaukcCBUiz=8SZ((9yU|R^hV#NdYm^qL^;Oo&Bwf2d!#bukD5;-=W#Ni~ zz$r%OSGESkwXOWf8(n=sLczko&ymAW)pJ>2?5v_a@0}+&YbP0l+XkK7}Zp^ literal 0 HcmV?d00001 diff --git a/public/img/icons/ArrowLeft111.png b/public/img/icons/ArrowLeft111.png new file mode 100644 index 0000000000000000000000000000000000000000..f836f04be2ba2cba15b1bb42d7f911eca88598b3 GIT binary patch literal 242 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`oCO|{#S9GG!XV7ZFl&wkP>``W z$lZxy-8q?;Kn_c~qpu?a!^VE@KZ&eBe!HiOV@L(#-ATTD4h8~l=~|0)B=jCu72WE_J$xaukcCBUiz=8SZ((9yU|R^hV#NdYm^qL^;Oo&Bwf2d!#bukD5;-=W#Ni~ zz$r%OSGESkwXOWf8(n=sLczko&ymAW)pJ>2?5v_a@0}+&YbP0l+XkK7}Zp^ literal 0 HcmV?d00001 diff --git a/public/img/icons/ArrowLeftHollow.png b/public/img/icons/ArrowLeftHollow.png new file mode 100644 index 0000000000000000000000000000000000000000..2011b0a13380ab9aaba519a473d20b902eb19d94 GIT binary patch literal 222 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`oCO|{#S9GG!XV7ZFl&wkP>``W z$lZxy-8q?;Kn_c~qpu?a!^VE@KZ&eBezB*EV@L(#(@7gS8yt9C|G#9?OG$kxu&zLQ zg|J1>MjoZ-c76*cDn!W6S>F31JE&!Pi+@ACgH5#5^HbNoor=z{V_agqI;F!kHA$U$ zx70?PNuBD|q2JOs%su)*Z&&Pk3#XdH0%wn2=l|yz`DCqNgvYK2v)}C1a@|t6#(pEv OdInEdKbLh*2~7a=%uICv literal 0 HcmV?d00001 diff --git a/public/img/icons/ArrowLeftHover.png b/public/img/icons/ArrowLeftHover.png new file mode 100644 index 0000000000000000000000000000000000000000..77768b27c8a3f3262c4d117c7860cab95fc2f45b GIT binary patch literal 228 zcmeAS@N?(olHy`uVBq!ia0vp^Y(UJ#0V1dK=^Fzn&H|6fVg?3oVGw3ym^DWND9BhG z+)n1kp1 z=5)+{s3LRFZ}%#5?p~8UDYH&~_ceBYC7QNw?b6m0eU1DV=dEb2>3I3hzz V6HPeYrve?p;OXk;vd$@?2>^UpP^16= literal 0 HcmV?d00001 diff --git a/public/img/icons/ArrowRight.png b/public/img/icons/ArrowRight.png new file mode 100644 index 0000000000000000000000000000000000000000..06cac0d9466d7c652f87eacc8ec33017886fe928 GIT binary patch literal 239 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`oCO|{#S9GG!XV7ZFl&wkP>``W z$lZxy-8q?;Kn_c~qpu?a!^VE@KZ&eBev7AzV@L(#-AT554GKJN_7{)LcU-Ohh2xGx zUc#Pp2$)7S@*)S+RRH#|64ku7Puqg<_po*4pPLx^Kdt zR*KE6;1P=GOP*h&;&-q)W$Wa-*(`AaOcQI*3-<(^Q40yZ8Z+&R;*|cgDz&qP-Rlg? g6}IO}eyG>GrfcwSvf{ptK=&|sy85}Sb4q9e0F1R#hyVZp literal 0 HcmV?d00001 diff --git a/public/img/icons/ArrowRightHollow.png b/public/img/icons/ArrowRightHollow.png new file mode 100644 index 0000000000000000000000000000000000000000..ee116b69ab894488717ba65757f0953ab800f23a GIT binary patch literal 223 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`oCO|{#S9GG!XV7ZFl&wkP>``W z$lZxy-8q?;Kn_c~qpu?a!^VE@KZ&eBeu<}xV@L(#-GGg}4F){L+I$Zb&CKRKXsKh6 zSIAq~Be_842(yVvwQ5vEfUQc9h;?71(Z);nqOE*Rt?T2xSuFc%%LM6<_gbZOEdTX) zEY+RWStsvp_#QdvFF Oeg;ohKbLh*2~7YS?Ma9L literal 0 HcmV?d00001 diff --git a/public/img/icons/ArrowRightHover.png b/public/img/icons/ArrowRightHover.png new file mode 100644 index 0000000000000000000000000000000000000000..ae6b8a8ae0327c2b9cfc9ca583529bce2af00a0e GIT binary patch literal 218 zcmeAS@N?(olHy`uVBq!ia0vp^Y(UJ#0V1dK=^Fzn&H|6fVg?3oVGw3ym^DWND9BhG z7zC$_zv_`g;4NzkzhSsho+)T)0^ z+6{Mn@^v~QcyGe&Mh-JQTL#|%Zd>N92E685KR9O3spNJ&$g7uN6U8p`E)QrmgQu&X J%Q~loCIE&TMD73p literal 0 HcmV?d00001 diff --git a/public/img/icons/ArrowUp.png b/public/img/icons/ArrowUp.png new file mode 100644 index 0000000000000000000000000000000000000000..c57c3fe29f28948a282287d30c281d8575a90e7b GIT binary patch literal 236 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`oCO|{#S9GG!XV7ZFl&wkP>``W z$lZxy-8q?;Kn_c~qpu?a!^VE@KZ&eBexs+0V@L(#+y0HbEe1TUweF3XO^IB`PP2KQ z4ohS)>&)N~kx+JQ?r`9+n)l?`r{CKc?e4X9m;|nT$ftD6M!!OnbJND1Dr&}?CklOh zza(`tPrV`wd$_r_Yuang&{>b64gdfE literal 0 HcmV?d00001 diff --git a/public/img/icons/Close.png b/public/img/icons/Close.png new file mode 100644 index 0000000000000000000000000000000000000000..a822fd1c84a50b622ec0b86ce4abde2ace6bf045 GIT binary patch literal 325 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`oCO|{#S9GG!XV7ZFl&wkP>``W z$lZxy-8q?;Kn_c~qpu?a!^VE@KZ&eB{w+@z$B+ufyFu1MO%6P@-QpXV|6GYOiqA-dCcpU0r+bvyN2u zv`;T)u711j#N{P_jjLoubvbupo-#aQ@3BNiq$4g-9*ImMY{A(D5Z`Hj~ TC^r2F^d*C*tDnm{r-UW|A3=Vd literal 0 HcmV?d00001 diff --git a/public/img/icons/Favicon.png b/public/img/icons/Favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..5aad5cbd6acd73e70b9458937dc22f0ee61bb229 GIT binary patch literal 597 zcmV-b0;>IqP)Fy-fqLD4qiXMVo1yY;!P*E^5vcQKC5%?BH5513i44-1q!>p$w{PUtR zK~YhhDEs$^l_vw{bYqAsw>e!oY<4w*aM}lRapH#ZPruP-d7p>NzpO2oyIYHuGFY9{(H+7u* zfHdZ(+3KgHteoHId={ShTaI-8BmV#9etL=o{5Oi^fr@21>qmXE8l^5@9s4l;qZpaG zJX*EL#E#U|AzwQGf}KB+M#Q5wvZTOzT!QPR*U)^ofg@A@Jgs-`T^aioigvUN#^;=Q z1(!m>@@c_jCPR;$GxZF|uz@*p^D z^dT}sCNnbtKy_$rUi`vTxSfbkvN`M*nX!!LV-g(i0C%^5e-P$aTUzK1U)EIFKrxF* z3?2chr9h5t>Z~ec{OGT%qn4IGMF|QV&xmXh`w0!T^=+sLZrC8l8A;uHm&bG4;!%>g j+tk0=THh z9OUlAuw@Y z>K(iuBJgw?GgG+(w?f?!-WP{DnDq|ICTKZ4`krv3KURe=VLhY$$w}p9_b(e{YRR3- z&w9;x=gM)T8BUg>x9av=6uzF#H6yy}U+TLr;sr53KDQq|vExk@JG<9e&H@e3gkPG) zNefs|Z~l4}ZvO^E9Q%7;?&6OFQrV^<#hr2+bz``W z$lZxy-8q?;Kn_c~qpu?a!^VE@KZ&eB{%=ng$B+ufv%v=in;dw24z$}Hxy+?=kZ%dA zc7XK;?)`Ru4Z{|MF~|lqsa7z{+X)DNl}LFfQ2)+n#)LQbT2&ZI95hYEUq7mleZ(uc zEa~yasNEI&Vr{Gi4$kZ7t8sr+Ss>Bd79rxwbl+>9&oaZ?dn;FmC>w6O^3!t;$IJrZ z%@gmn&;GM$?i6Vajx@pFs}?(nvStX2cK44Cfm=J63mV*Kq|Ch6m49mM!JQtpAKlptCO@lny>q21JV0(! z>z~U``W z$lZxy-8q?;Kn_c~qpu?a!^VE@KZ&eBex9d`V@L(#+lgm+8x(k2<2lO{m}DK@i;dh1 zSltttf-NV9)QJfkb`;*fq`rOMldT7yFPyVxYp`C%VdKr$cM30j+sLrIz_r2SnS|k% zoa|mQ| literal 0 HcmV?d00001 diff --git a/public/img/icons/Shoppingbag.png b/public/img/icons/Shoppingbag.png new file mode 100644 index 0000000000000000000000000000000000000000..fd5e081b50a694ae81d8786c229c49f4439316cb GIT binary patch literal 391 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`oCO|{#S9GG!XV7ZFl&wkP>``W z$lZxy-8q?;Kn_c~qpu?a!^VE@KZ&eBIT=qE$B+ufwZRt)4;k=G<+=YLZ-U>2vZiIy zTa;6nXDqn8Am>17(;=1?&J%1YEb_8}q}l#ZSkCsmn_dgpT8%rt@E)!A zV=*#rJGMP3U+Btzh1KDUC3jtxb3bs$in*jS_4QvbW{xk*n_0dt)#fbPr)9+d!sOle jFZWb;USGU({xN0ow%ntKE}Xj#3^xW(S3j3^P65E|1&Nv|ph6;Zlw5k(auPEmpsA^9Re>Nh)lrBW_b2mw-K zf1u(+k)01n2qJO;LHeQKb19Wt5pq%GM2T9miPENNQ+v-&oQr+8)Ax2~?&jv^{-%;o zZGY10cK*M4`{wI(2o!Q?+xe>Se&yNd@j&~`*rmWSU)A( zi$&N)bwL1!WgWfiV{o4FzP>vaM4uV)EcEr+GulU^xyIyNwI4WEZxc^GIxx60w=a0- z{9C{SZvE(~t>>|tL5ILn3&c<%S#86%@0iAUi1CJbm@7W!2z-e6%fGH%ve*i{d^rFX z?x+h+O+7pY9l~f!D?7w!XcM-*A%#*ms#JNL^9lS-)4Qy!^D(J^N`Fw=r=dQ0eB06Z z;EsX0|NQwiECnnv{KaRXLl8IAjgX|F3QR+bK!&IcPUmt9yJdmY1Tyy&L=tWzvF*_yM(@j4IX~H+c-r&Lu58vcxf5?OE9)2nP_%rjD-+Sr3 zb6(iz2jBnFQRo1bp-Zv{5uAs$y4G#6gmH%IZ^RK=MU)Xu~7U%#}Fq!lxV{9BkMl+YZ z1Y~xzP;@N!p*`5Nu3lLv@IJMC=8wZKKK-k+4-E~*?tFD_{$vqiz0d#r!8z8uJ|K<{ zZ0QGf??$-sevbWj^1;Jj89O-+YY5G|03VO^{&JZe1VsfjfWlbjKzpD$Ji}AMm1Ey< zw{Gu$?3Yg`Ub*^qA^ZG^w=PeexO67=)nnElo;$g>aPubaDh_=80DR{2{jmLOca6as zqe>^^vCIgMfI!meKr<6ra8W5bChvg^YHzQ7kRG!e$FpcGH|x*!>;A0lF|hmPmzc#M z{;9aN>&byj())?_x z4X8GDD=t?lKK-S$1Q(A^C=J=;YFET50`r))RXnI>Pi&VB^< z9IA-bRmkS^{zbiyI#)fI#(sO}#?Ky~99m*lj0BiratKyd0$5(m2RE)Py>su_*-h9j zfHg+PWU6ih>^J+I?|pMe&uCB#KKtBj z2KE1bl}4nip}HVLjGo^`%5y`LK;zB?Ju)@y8oGrEF>)TPs59k(YbDx4&}1{F%v>$$ z?COFfv<}Cz*fh?Ws$QoNjp2pB%m;MTV2l0Oa z61sP_ZPjJNwozpxDJ7Vo{DoUj&uVI65|20C<&6m9h*{xS1C7Ht)>mkxh;l4qP<0kB zngg`n@|I#2Gck#wd*N;vN(P}w(k;UKaK>K3>GDZa=A*qE10wy42Xy+OEMau zeLLPw@f*I0bxH(~f!c=ScL9u?Mytzf>JN)RnuifYtye7!lDrsO$ z2W*%^=|2upE}c$H>C{kIohWah++gq+B+?3Du2N6lK^AilX(>(3s0rG!ws376PlSlN1JVHT8iu?>2Ts&gIQ zAaOV45m5J#6%LPvrYh3WX5a{JMoLvfm7x*tBY}E#g&{+|ms4mQ6(6z!=-H4#YWL!x zX^`3?8z5*<=_DJ1c7l!J{)gj&7&{siN`=%dGJzfr(ihNGg{Vu4S5J;?-3iMM`M^A@P<^m#?*GRDqkG1}&eWo{DxLP74{?1C&BQ0ZSmmNwv9>CUO?yg5aOKlelyOL#l`7A~Gwm9)R)F-~PXa^_ zdOsCcTxbAuO3@CT5JSW!RfPkJt|7mc+%Dnblp-YZ5p^lSIWLIup=(F~r!Yi(p7?d3 z66B^mAekj3^_14HE{%c)^=xHL7rdsMaD9I=!qfUBwLb!R(HbEwZn>JLt!Sa}^3+r3 zfavHdR=pzJb=A$9jv*2-UET;A2QwU~VlairVF+Jhh;ZYETclA(BDx8$d{F(hbqz-O z3*|0U?nTwd1dl#qTnzB=XS4V^qfjQEHcE;136+s#%y6(LHkL6Ffky`YNao41a0yjo zssaNZ?$y(q1{z&uY^KIo588* ( -
-

Product Catalog

-
-); +export const App = () => { + return ( +
+
+ +
+
+ + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + +
+
+
+
+ ); +}; diff --git a/src/api/Products.ts b/src/api/Products.ts new file mode 100644 index 00000000000..eb584f327c9 --- /dev/null +++ b/src/api/Products.ts @@ -0,0 +1,54 @@ +import { Product } from '../types/Product'; +import { ProductDetails } from '../types/ProductDetails'; + +const BASE_URL = 'api/products.json'; + +export function getProducts(): Promise { + return fetch(BASE_URL).then(response => { + if (!response) { + throw new Error('Failed to fetch products'); + } + + return response.json(); + }); +} + +const fetchFromCategory = (category: string, productId: string) => { + const url = `api/${category}.json`; + + return fetch(url) + .then(response => { + if (!response.ok) { + throw new Error(); + } + + return response.json(); + }) + .then((products: ProductDetails[]) => { + const found = products.find(p => p.id === productId); + + if (!found) { + throw new Error(); + } + + return found; + }); +}; + +export function getProductDetails(productId: string): Promise { + return fetchFromCategory('phones', productId) + .catch(() => { + return fetchFromCategory('tablets', productId); + }) + .catch(() => { + return fetchFromCategory('accessories', productId); + }); +} + +export function getSuggestedProducts(): Promise { + return getProducts().then(products => { + const shuffled = [...products].sort(() => 0.5 - Math.random()); + + return shuffled.slice(0, 8); + }); +} diff --git a/src/components/Breadcrumbs/Breadcrumbs.module.scss b/src/components/Breadcrumbs/Breadcrumbs.module.scss new file mode 100644 index 00000000000..4eafbba3b29 --- /dev/null +++ b/src/components/Breadcrumbs/Breadcrumbs.module.scss @@ -0,0 +1,50 @@ +@import '../../styles/utils'; + +.breadcrumbs { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 24px; + margin-top: 24px; +} + +.homeLink { + display: flex; + align-items: center; + justify-content: center; + + img { + width: 16px; + height: 16px; + } +} + +.separator { + display: flex; + align-items: center; + justify-content: center; + + img { + width: 16px; + height: 16px; + filter: grayscale(100%) opacity(0.5); + } +} + +.link { + color: $secondary; + font-size: 12px; + font-weight: 600; + text-decoration: none; + transition: color 0.3s; + + &:hover { + color: $primary; + } +} + +.lastItem { + color: $secondary; + font-size: 12px; + font-weight: 600; +} diff --git a/src/components/Breadcrumbs/Breadcrumbs.tsx b/src/components/Breadcrumbs/Breadcrumbs.tsx new file mode 100644 index 00000000000..e5c4e3f06ed --- /dev/null +++ b/src/components/Breadcrumbs/Breadcrumbs.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import styles from './Breadcrumbs.module.scss'; + +type Props = { + category: string; + lastItem?: string; +}; + +export const Breadcrumbs: React.FC = ({ category, lastItem }) => { + const categoryName = category.charAt(0).toUpperCase() + category.slice(1); + + return ( +
+ + Home + + +
+ > +
+ + + {categoryName} + + + {lastItem && ( + <> +
+ > +
+ {lastItem} + + )} +
+ ); +}; diff --git a/src/components/Footer/Footer.module.scss b/src/components/Footer/Footer.module.scss new file mode 100644 index 00000000000..66809b45e45 --- /dev/null +++ b/src/components/Footer/Footer.module.scss @@ -0,0 +1,93 @@ +@import '../../styles/utils'; + +.footer { + border-top: 1px solid #E2E6E9; + background-color: $white; + padding: 32px 0; + margin-top: auto; +} + +.content { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + + @media (max-width: 640px) { + flex-direction: column; + gap: 24px; + } +} + +.logo { + display: flex; + align-items: center; +} + +.links { + display: flex; + gap: 106px; + + a { + text-decoration: none; + color: $secondary; + font-weight: 700; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.04em; + transition: color 0.3s; + + &:hover { + color: $primary; + } + } + + @media (max-width: 640px) { + flex-direction: column; + align-items: center; + gap: 16px; + } +} + +.arrowButton { + width: 32px; + height: 32px; + border: 1px solid #B4BDC3; + border-radius: 50%; + display: flex; + justify-content: center; + align-items: center; + transition: all 0.3s; + background-color: transparent; + padding: 0; + + img { + width: 14px; + height: 14px; + object-fit: contain; + } +} + +.backToTop { + display: flex; + align-items: center; + justify-content: center; + gap: 16px; + cursor: pointer; + background: none; + border: none; + + color: $secondary; + font-weight: 600; + font-size: 12px; + transition: color 0.3s; + + &:hover { + color: $primary; + + .arrowButton { + border-color: $primary; + color: $primary; + } + } +} diff --git a/src/components/Footer/Footer.tsx b/src/components/Footer/Footer.tsx new file mode 100644 index 00000000000..626050215b3 --- /dev/null +++ b/src/components/Footer/Footer.tsx @@ -0,0 +1,47 @@ +import { Link } from 'react-router-dom'; +import styles from './Footer.module.scss'; + +export const Footer = () => { + const handleScrollToTop = () => { + window.scrollTo({ + top: 0, + behavior: 'smooth', + }); + }; + + return ( + + ); +}; diff --git a/src/components/Header/Header.module.scss b/src/components/Header/Header.module.scss new file mode 100644 index 00000000000..70906ca0df1 --- /dev/null +++ b/src/components/Header/Header.module.scss @@ -0,0 +1,237 @@ +@import '../../styles/utils'; + +$header-height-desktop: 64px; +$header-height-mobile: 48px; + +.header { + position: sticky; + top: 0; + z-index: 100; + background-color: $white; + border-bottom: 1px solid #E2E6E9; + height: 64px; + display: flex; + align-items: center; + transition: height 0.3s ease; + + @media (max-width: 1199px) { + height: $header-height-mobile; + } +} + +.logo { + display: flex; + align-items: center; + margin-right: 20px; + height: 100%; +} + +.container { + width: 100%; + padding-left: 24px; + padding-right: 0; + display: flex; + justify-content: space-between; + align-items: center; + height: 100%; +} + +.left, .right { + display: flex; + align-items: center; + gap: 32px; + height: 100%; +} + +.nav { + display: flex; + gap: 32px; + height: 100%; + + @media (max-width: 640px) { + display: none; + } +} + +.link { + text-decoration: none; + color: $secondary; + font-weight: 700; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.04em; + transition: color 0.3s; + height: 100%; + display: flex; + align-items: center; + box-sizing: border-box; + + &:hover { + color: $primary; + } + + &.is-active { + color: $primary; + border-bottom: 3px solid $primary; + } +} + +.iconButton { + width: $header-height-desktop; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + border-left: 1px solid #E2E6E9; + transition: background-color 0.3s; + padding: 0; + box-sizing: border-box; + border-bottom: 3px solid transparent; + + &:hover { + background-color: $hover-bg; + } + + &.is-active { + color: $primary; + border-bottom: 3px solid $primary; + } + + @media (max-width: 1199px) { + width: $header-height-mobile; + } +} + +.icons { + display: flex; + height: 100%; + + @media (max-width: 640px) { + display: none; + } +} + +.iconWrapper { + position: relative; + display: flex; + justify-content: center; + align-items: center; + width: 100%; + height: 100%; +} + +.iconBadge { + position: absolute; + top: 14px; + right: 14px; + display: flex; + justify-content: center; + align-items: center; + width: 14px; + height: 14px; + border-radius: 50%; + background-color: $accent; + border: 1px solid $white; + color: $white; + font-size: 9px; + font-weight: 700; + line-height: 10px; + + @media (max-width: 1199px) { + top: 8px; + right: 8px; + } +} + +.menuButton { + display: none; + width: $header-height-mobile; + height: 100%; + background: transparent; + border: none; + border-left: 1px solid #E2E6E9; + cursor: pointer; + align-items: center; + justify-content: center; + + @media (max-width: 640px) { + display: flex; + } +} + +.mobileMenu { + position: fixed; + top: $header-height-mobile; + left: 0; + bottom: 0; + width: 100%; + background-color: $white; + z-index: 99; + transform: translateX(-100%); + transition: transform 0.3s ease-in-out; + display: flex; + flex-direction: column; + align-items: center; + border-top: 1px solid #E2E6E9; + padding-top: 24px; + + &.isOpen { + transform: translateX(0); + } +} + +.mobileNav { + display: flex; + flex-direction: column; + width: 100%; + gap: 16px; + + .link { + width: 100%; + height: 27px; + justify-content: center; + border-bottom: none; + + span { + display: flex; + align-items: center; + height: 100%; + border-bottom: 3px solid transparent; + box-sizing: border-box; + } + + &.is-active { + color: $primary; + border-bottom: none; + + span { + border-bottom: 3px solid $primary; + } + } + } +} + +.mobileIcons { + display: flex; + justify-content: center; + align-items: center; + width: 100%; + margin-top: auto; + border-top: 1px solid #E2E6E9; + + .iconButton { + width: 50%; + height: 64px; + border-left: none; + border-right: 1px solid #E2E6E9; + + &:last-child { + border-right: none; + } + } + + &.is-active { + color: $primary; + border-bottom: 3px solid $primary; + } +} diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx new file mode 100644 index 00000000000..64f596a93bf --- /dev/null +++ b/src/components/Header/Header.tsx @@ -0,0 +1,133 @@ +import { NavLink, useLocation } from 'react-router-dom'; +import classNames from 'classnames'; +import styles from './Header.module.scss'; +import { useFavorites } from '../../modules/shared/context/FavoritesContext'; +import { useCart } from '../../modules/shared/context/CartContext'; +import { useEffect, useState } from 'react'; + +const getLinkClass = ({ isActive }: { isActive: boolean }) => + classNames(styles.link, { [styles['is-active']]: isActive }); + +const getIconClass = ({ isActive }: { isActive: boolean }) => + classNames(styles.iconButton, { [styles['is-active']]: isActive }); + +export const Header = () => { + const { favorites } = useFavorites(); + const { totalCount } = useCart(); + const [isMenuOpen, setIsMenuOpen] = useState(false); + const location = useLocation(); + + useEffect(() => { + setIsMenuOpen(false); + }, [location]); + + useEffect(() => { + if (isMenuOpen) { + document.body.style.overflow = 'hidden'; + } else { + document.body.style.overflow = ''; + } + }, [isMenuOpen]); + + return ( +
+
+
+ + SiteLogo.png + + + +
+ +
+
+ +
+ Favorites + {favorites.length > 0 && ( + {favorites.length} + )} +
+
+ +
+ ShoppingBagIcon.png + {totalCount > 0 && ( + {totalCount} + )} +
+
+
+ +
+
+ +
+ + +
+ +
+ Favorites + {favorites.length > 0 && ( + {favorites.length} + )} +
+
+ + +
+ ShoppingBagIcon.png + {totalCount > 0 && ( + {totalCount} + )} +
+
+
+
+
+ ); +}; diff --git a/src/components/Loader/Loader.module.scss b/src/components/Loader/Loader.module.scss new file mode 100644 index 00000000000..040c96200b3 --- /dev/null +++ b/src/components/Loader/Loader.module.scss @@ -0,0 +1,25 @@ +.Loader { + display: flex; + width: 100%; + justify-content: center; + align-items: center; + + &__content { + border-radius: 50%; + width: 2em; + height: 2em; + margin: 1em auto; + border: 0.3em solid #ddd; + border-left-color: #000; + animation: load8 1.2s infinite linear; + } +} + +@keyframes load8 { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} diff --git a/src/components/Loader/Loader.tsx b/src/components/Loader/Loader.tsx new file mode 100644 index 00000000000..fce7a58717b --- /dev/null +++ b/src/components/Loader/Loader.tsx @@ -0,0 +1,7 @@ +import './Loader.module.scss'; + +export const Loader = () => ( +
+
+
+); diff --git a/src/hooks/useLocalStorage.ts b/src/hooks/useLocalStorage.ts new file mode 100644 index 00000000000..c3c3c40ca44 --- /dev/null +++ b/src/hooks/useLocalStorage.ts @@ -0,0 +1,22 @@ +import { useState } from 'react'; + +export function useLocalStorage(key: string, initialValue: T) { + const [storedValue, setStoredValue] = useState(() => { + try { + const item = window.localStorage.getItem(key); + + return item ? JSON.parse(item) : initialValue; + } catch { + return initialValue; + } + }); + + const setValue = (value: T | ((value: T) => T)) => { + const valueToStore = value instanceof Function ? value(storedValue) : value; + + setStoredValue(valueToStore); + window.localStorage.setItem(key, JSON.stringify(valueToStore)); + }; + + return [storedValue, setValue] as const; +} diff --git a/src/index.tsx b/src/index.tsx index 50470f1508d..dca915e455d 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,4 +1,16 @@ import { createRoot } from 'react-dom/client'; import { App } from './App'; +import { HashRouter as Router } from 'react-router-dom'; +import './styles/fonts.scss'; +import { FavoritesProvider } from './modules/shared/context/FavoritesContext'; +import { CartProvider } from './modules/shared/context/CartContext'; -createRoot(document.getElementById('root') as HTMLElement).render(); +createRoot(document.getElementById('root') as HTMLElement).render( + + + + + + + , +); diff --git a/src/modules/CartPage/CartPage.module.scss b/src/modules/CartPage/CartPage.module.scss new file mode 100644 index 00000000000..573c9d06cf6 --- /dev/null +++ b/src/modules/CartPage/CartPage.module.scss @@ -0,0 +1,91 @@ +@import '../../styles/utils'; + +.page { + padding-bottom: 80px; +} + +.backButton { + background: none; + border: none; + color: $secondary; + font-weight: 700; + cursor: pointer; + padding: 0; + margin: 24px 0; + font-size: 12px; + display: flex; + align-items: center; + gap: 8px; + + &:hover { color: $primary; } +} + +.title { + margin-bottom: 32px; + font-size: 32px; + color: $primary; +} + +.grid { + display: grid; + grid-template-columns: 1fr 320px; + gap: 32px; + + @media (max-width: 640px) { + grid-template-columns: 1fr; + } +} + +.itemsList { + display: flex; + flex-direction: column; +} + +.summary { + border: 1px solid #E2E6E9; + border-radius: 8px; + padding: 24px; + height: fit-content; + background: $white; +} + +.summaryInfo { + text-align: center; + margin-bottom: 24px; +} + +.totalPrice { + font-size: 32px; + font-weight: 800; + color: $primary; + margin: 0; +} + +.totalLabel { + color: $secondary; + font-size: 14px; + margin-top: 8px; +} + +.divider { + height: 1px; + background-color: #E2E6E9; + margin-bottom: 24px; +} + +.checkoutButton { + width: 100%; + height: 48px; + background-color: $orange; + color: $white; + border: none; + border-radius: 8px; + font-weight: 700; + font-size: 14px; + cursor: pointer; + transition: opacity 0.3s; + + &:hover { + opacity: 0.9; + } +} diff --git a/src/modules/CartPage/CartPage.tsx b/src/modules/CartPage/CartPage.tsx new file mode 100644 index 00000000000..e4c61f6623a --- /dev/null +++ b/src/modules/CartPage/CartPage.tsx @@ -0,0 +1,67 @@ +import { useNavigate } from 'react-router-dom'; +import styles from './CartPage.module.scss'; +import { useCart } from '../shared/context/CartContext'; +import { CartItem } from './components/CartItem'; + +export const CartPage = () => { + const navigate = useNavigate(); + const { cart, totalCount, clearCart } = useCart(); + + const totalPrice = cart.reduce((sum, item) => { + return sum + item.product.price * item.quantity; + }, 0); + + const handleCheckout = () => { + const isConfirmed = window.confirm( + 'Checkout is not implemented yet. Do you want to clear the Cart?', + ); + + if (isConfirmed) { + clearCart(); + } + }; + + const handleBack = () => { + if (window.history.state && window.history.state.idx > 0) { + navigate(-1); + } else { + navigate('/phones', { replace: true }); + } + }; + + return ( +
+ + +

Cart

+ + {cart.length === 0 ? ( +

Your cart is empty

+ ) : ( +
+
+ {cart.map(item => ( + + ))} +
+ +
+
+

${totalPrice}

+

Total for {totalCount} items

+
+ +
+ + +
+
+ )} +
+ ); +}; diff --git a/src/modules/CartPage/components/CartItem.module.scss b/src/modules/CartPage/components/CartItem.module.scss new file mode 100644 index 00000000000..8e888d1cd56 --- /dev/null +++ b/src/modules/CartPage/components/CartItem.module.scss @@ -0,0 +1,95 @@ +@import '../../../styles/utils'; + +.cartItem { + display: grid; + grid-template-columns: 24px 80px 1fr 120px 80px; + align-items: center; + gap: 16px; + + padding: 24px; + border: 1px solid #E2E6E9; + border-radius: 8px; + background: $white; + margin-bottom: 16px; + + @media (max-width: 640px) { + grid-template-columns: 1fr; + text-align: center; + gap: 24px; + } +} + +.removeButton { + background: none; + border: none; + color: #B4BDC3; + font-size: 24px; + cursor: pointer; + + &:hover { + color: $primary; + } +} + +.imageLink { + width: 80px; + height: 80px; + display: flex; + justify-content: center; + align-items: center; +} + +.image { + max-width: 100%; + max-height: 100%; + object-fit: contain; +} + +.nameLink { + color: $primary; + font-weight: 600; + font-size: 14px; + line-height: 21px; + display: -webkit-box; + overflow: hidden; +} + +.quantityControls { + display: flex; + align-items: center; + justify-content: space-between; + width: 100px; +} + +.controlButton { + width: 32px; + height: 32px; + border: 1px solid #E2E6E9; + background: $white; + border-radius: 50%; + display: flex; + justify-content: center; + align-items: center; + cursor: pointer; + + &:hover:not(.disabled) { + border-color: $primary; + } + + &.disabled { + color: #B4BDC3; + cursor: default; + } +} + +.quantity { + font-size: 14px; + font-weight: 600; +} + +.price { + font-size: 20px; + font-weight: 800; + color: $primary; + text-align: right; +} diff --git a/src/modules/CartPage/components/CartItem.tsx b/src/modules/CartPage/components/CartItem.tsx new file mode 100644 index 00000000000..5b78406d2b0 --- /dev/null +++ b/src/modules/CartPage/components/CartItem.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import classname from 'classnames'; +import styles from './CartItem.module.scss'; +import { CartItem as CartItemType } from '../../../types/CartItem'; +import { useCart } from '../../shared/context/CartContext'; + +type Props = { + item: CartItemType; +}; + +export const CartItem: React.FC = ({ item }) => { + const { removeFromCart, increaseQuantity, decreaseQuantity } = useCart(); + const { product } = item; + + return ( +
+ + + + {product.name} + + + + {product.name} + + +
+ + + {item.quantity} + + +
+ +
${product.price}
+
+ ); +}; diff --git a/src/modules/FavoritesPage/FavoritesPage.module.scss b/src/modules/FavoritesPage/FavoritesPage.module.scss new file mode 100644 index 00000000000..fb839d57aa0 --- /dev/null +++ b/src/modules/FavoritesPage/FavoritesPage.module.scss @@ -0,0 +1,19 @@ +@import '../../styles/utils'; + +.page { + padding-bottom: 80px; +} + +.count { + color: $secondary; + font-size: 14px; + font-weight: 600; + margin-bottom: 24px; +} + +.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(270px, 1fr)); + gap: 16px; + justify-items: center; +} diff --git a/src/modules/FavoritesPage/FavoritesPage.tsx b/src/modules/FavoritesPage/FavoritesPage.tsx new file mode 100644 index 00000000000..f3b346aa604 --- /dev/null +++ b/src/modules/FavoritesPage/FavoritesPage.tsx @@ -0,0 +1,25 @@ +import styles from './FavoritesPage.module.scss'; +import { useFavorites } from '../../modules/shared/context/FavoritesContext'; +import { ProductCard } from '../shared/components/ProductCard/ProductCard'; + +export const FavoritesPage = () => { + const { favorites } = useFavorites(); + + return ( +
+

Favourites

+ +

{favorites.length} items

+ + {favorites.length === 0 ? ( +

You have no favorites yet

+ ) : ( +
+ {favorites.map(product => ( + + ))} +
+ )} +
+ ); +}; diff --git a/src/modules/HomePage/HomePage.module.scss b/src/modules/HomePage/HomePage.module.scss new file mode 100644 index 00000000000..b18c78e41c7 --- /dev/null +++ b/src/modules/HomePage/HomePage.module.scss @@ -0,0 +1,43 @@ +@import '../../styles/utils'; + +.homePage { + padding-bottom: 80px; +} + +.title { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + border: 0; + padding: 0; + white-space: nowrap; + clip-path: inset(100%); + clip: rect(0 0 0 0); + overflow: hidden; +} + +.heading { + font-size: 32px; + font-weight: 800; + color: $primary; + margin-bottom: 24px; + margin-top: 24px; + letter-spacing: -0.01em; + + @media (min-width: 640px) { + font-size: 48px; + margin-bottom: 32px; + margin-top: 32px; + } + + @media (min-width: 1200px) { + font-size: 48px; + margin-bottom: 56px; + margin-top: 56px; + } +} + +.section { + margin-bottom: 80px; +} diff --git a/src/modules/HomePage/HomePage.tsx b/src/modules/HomePage/HomePage.tsx new file mode 100644 index 00000000000..ffd7bee5115 --- /dev/null +++ b/src/modules/HomePage/HomePage.tsx @@ -0,0 +1,50 @@ +import styles from './HomePage.module.scss'; +import { PicturesSlider } from './components/PicturesSlider/PicturesSlider'; +import { useState, useEffect, useMemo } from 'react'; +// eslint-disable-next-line max-len +import { ProductsSlider } from '../shared/components/ProductsSlider/ProductsSlider'; +import { getProducts } from '../../api/Products'; +import { Product } from '../../types/Product'; +import { Categories } from './components/Categories/Categories'; + +export const HomePage = () => { + const [products, setProducts] = useState([]); + + useEffect(() => { + getProducts().then(setProducts); + }, []); + + const hotPrices = useMemo(() => { + return [...products] + .filter(p => p.price < p.fullPrice) + .sort((a, b) => b.fullPrice - b.price - (a.fullPrice - a.price)); + }, [products]); + + const brandNew = useMemo(() => { + return [...products].sort((a, b) => b.year - a.year || b.price - a.price); + }, [products]); + + return ( +
+

Product Catalog

+ +

Welcome to Nice Gadgets store!

+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+
+ ); +}; diff --git a/src/modules/HomePage/components/Categories/Categories.module.scss b/src/modules/HomePage/components/Categories/Categories.module.scss new file mode 100644 index 00000000000..c3903abd1bf --- /dev/null +++ b/src/modules/HomePage/components/Categories/Categories.module.scss @@ -0,0 +1,86 @@ +@import '../../../../styles/utils'; + +.categories { + margin-bottom: 80px +} + +.title { + font-size: 32px; + font-weight: 800; + color: $primary; + margin-bottom: 24px; +} + +.grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 16px; + + + @media (max-width: 640px) { + grid-template-columns: 1fr; + gap: 32px; + } +} + +.imageWrapper { + width: 100%; + aspect-ratio: 1; + background-color: #89939a; + margin-bottom: 24px; + overflow: hidden; + display: flex; + justify-content: center; + align-items: center; + border-radius: 8px; +} + +.image { + width: 100%; + height: 100%; + object-fit: cover; + transition: transform 0.3s; +} + +.categoryName { + font-size: 24px; + font-weight: 700; + color: $primary; + margin-bottom: 8px; + transition: color 0.3s; +} + +.categoryCount { + font-size: 14px; + font-weight: 600; + color: $secondary; +} + +.categoryCard { + text-decoration: none; + display: flex; + flex-direction: column; + transition: transform 0.3s; + + &:hover { + .image { + transform: scale(1.05); + } + + .categoryName { + color: $accent; // Or $orange + } + } + + &:nth-child(1) .imageWrapper { + background-color: #FCDBC1; + } + + &:nth-child(2) .imageWrapper { + background-color: #8D8D92; + } + + &:nth-child(3) .imageWrapper { + background-color: rgb(89, 119, 89); + } +} diff --git a/src/modules/HomePage/components/Categories/Categories.tsx b/src/modules/HomePage/components/Categories/Categories.tsx new file mode 100644 index 00000000000..9d0a5dc4ae3 --- /dev/null +++ b/src/modules/HomePage/components/Categories/Categories.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import styles from './Categories.module.scss'; +import { Product } from '../../../../types/Product'; + +type Props = { + products: Product[]; +}; + +export const Categories: React.FC = ({ products }) => { + const phonesCount = products.filter(p => p.category === 'phones').length; + const tabletsCount = products.filter(p => p.category === 'tablets').length; + const accessoriesCount = products.filter( + p => p.category === 'accessories', + ).length; + + return ( +
+

Shop by category

+ +
+ +
+ Phones +
+

Mobile phones

+

{phonesCount} models

+ + + +
+ Tablets +
+

Tablets

+

{tabletsCount} models

+ + + +
+ Accessories +
+

Accessories

+

{accessoriesCount} models

+ +
+
+ ); +}; diff --git a/src/modules/HomePage/components/PicturesSlider/PicturesSlider.module.scss b/src/modules/HomePage/components/PicturesSlider/PicturesSlider.module.scss new file mode 100644 index 00000000000..ac3129fbb19 --- /dev/null +++ b/src/modules/HomePage/components/PicturesSlider/PicturesSlider.module.scss @@ -0,0 +1,90 @@ +@import '../../../../styles/utils'; + +.slider { + margin-bottom: 64px; +} + +.container { + display: flex; + align-items: center; + gap: 16px; + height: 400px; + + @media (max-width: 1199px) { + height: 189px; + } + + @media (max-width: 639px) { + height: 320px; + gap: 0; + } +} + +.frame { + flex-grow: 1; + height: 100%; + border-radius: 16px; + overflow: hidden; + position: relative; +} + +.strip { + display: flex; + height: 100%; + width: 100%; + transition: transform 0.5s ease-in-out; +} + +.slide { + min-width: 100%; + height: 100%; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } +} + +.arrow { + width: 32px; + height: 100%; + background: none; + border: 1px solid #E2E6E9; + border-radius: 16px; + cursor: pointer; + display: flex; + justify-content: center; + align-items: center; + + img { + width: 10px; + height: 10px; + object-fit: contain; + } + + @media (max-width: 639px) { + display: none; + } +} + +.dots { + display: flex; + justify-content: center; + gap: 12px; + margin-top: 16px; +} + +.dot { + width: 14px; + height: 4px; + background-color: #E2E6E9; + border: none; + cursor: pointer; + padding: 0; + transition: background-color 0.3s; + + &.active { + background-color: $primary; + } +} diff --git a/src/modules/HomePage/components/PicturesSlider/PicturesSlider.tsx b/src/modules/HomePage/components/PicturesSlider/PicturesSlider.tsx new file mode 100644 index 00000000000..cc51a53c2af --- /dev/null +++ b/src/modules/HomePage/components/PicturesSlider/PicturesSlider.tsx @@ -0,0 +1,94 @@ +import { useState, useEffect } from 'react'; +import classNames from 'classnames'; +import styles from './PicturesSlider.module.scss'; + +const images = [ + 'img/banner-phones.png', + 'img/banner-tablets.png', + 'img/banner-accessories.png', +]; + +export const PicturesSlider = () => { + const [currentIndex, setCurrentIndex] = useState(0); + const [isLeftHovered, setIsLeftHovered] = useState(false); + const [isRightHovered, setIsRightHovered] = useState(false); + + useEffect(() => { + const interval = setInterval(() => { + setCurrentIndex(prev => (prev === images.length - 1 ? 0 : prev + 1)); + }, 5000); + + return () => clearInterval(interval); + }, []); + + const handleNext = () => { + setCurrentIndex(prev => (prev === images.length - 1 ? 0 : prev + 1)); + }; + + const handlePrev = () => { + setCurrentIndex(prev => (prev === 0 ? images.length - 1 : prev - 1)); + }; + + return ( +
+
+ + +
+
+ {images.map((img, index) => ( +
+ BannerImage +
+ ))} +
+
+ + +
+ +
+ {images.map((_, index) => ( +
+
+ ); +}; diff --git a/src/modules/ProductsDetailsPage/ProductDetailsPage.module.scss b/src/modules/ProductsDetailsPage/ProductDetailsPage.module.scss new file mode 100644 index 00000000000..62ee6a78d52 --- /dev/null +++ b/src/modules/ProductsDetailsPage/ProductDetailsPage.module.scss @@ -0,0 +1,307 @@ +@import '../../styles/utils'; + +.page { + padding-bottom: 80px; +} + +.backButton { + background: none; + border: none; + color: $secondary; + font-weight: 700; + cursor: pointer; + padding: 0; + margin: 24px 0; + font-size: 12px; + display: flex; + align-items: center; + gap: 8px; + + &:hover { + color: $primary; + } +} + +.title { + margin-bottom: 40px; + font-size: 32px; + color: $primary; +} + +.topSection { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 64px; + margin-bottom: 80px; + + @media (max-width: 640px) { + grid-template-columns: 1fr; + gap: 32px; + } +} + +.gallery { + display: flex; + gap: 16px; + height: 450px; + + @media (max-width: 640px) { + flex-direction: column-reverse; + height: auto; + } +} + +.thumbnails { + display: flex; + flex-direction: column; + gap: 16px; + + @media (max-width: 640px) { + flex-direction: row; + justify-content: center; + margin-top: 16px; + } +} + +.thumbnail { + width: 80px; + height: 80px; + border: 1px solid #E2E6E9; + border-radius: 8px; + cursor: pointer; + display: flex; + justify-content: center; + align-items: center; + padding: 8px; + + img { + width: 100%; + height: 100%; + object-fit: contain; + } + + &.selected { + border-color: $primary; + } +} + +.mainImage { + flex-grow: 1; + display: flex; + justify-content: center; + align-items: center; + + img { + max-width: 100%; + max-height: 100%; + object-fit: contain; + } +} + +.actions { + display: flex; + flex-direction: column; + gap: 24px; + width: 320px; + + @media (max-width: 640px) { + width: 100%; + } +} + +.selectorBlock { + border-bottom: 1px solid #E2E6E9; + padding-bottom: 24px; +} + +.selectorLabel { + display: block; + color: $secondary; + font-size: 12px; + font-weight: 600; + margin-bottom: 8px; +} + +.colorsGrid, .capacityGrid { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.colorCircle { + width: 32px; + height: 32px; + border-radius: 50%; + border: 1px solid #E2E6E9; + cursor: pointer; + position: relative; + + &.selected { + border-color: $primary; + &::after { + content: ''; + position: absolute; + inset: -4px; + border: 1px solid $primary; + border-radius: 50%; + } + } +} + +.capacityButton { + padding: 8px 16px; + border: 1px solid #E2E6E9; + background: $white; + color: $primary; + border-radius: 8px; + text-decoration: none; + font-size: 14px; + cursor: pointer; + transition: all 0.2s; + + &.selected { + background-color: $primary; + color: $white; + border-color: $primary; + } + + &:hover:not(.selected) { + border-color: $primary; + } +} + +.priceBlock { + display: flex; + align-items: center; + gap: 16px; +} + +.priceDiscount { + font-size: 32px; + font-weight: 800; + color: $primary; +} + +.priceRegular { + font-size: 22px; + color: $secondary; + text-decoration: line-through; +} + +.buttons { + display: flex; + gap: 16px; + + button { + height: 48px; + cursor: pointer; + font-weight: 700; + } +} + +.addToCart { + flex-grow: 1; + background-color: $orange; + color: $white; + border: none; + border-radius: 8px; + + &:hover { + opacity: 0.9 + } +} + +.addToFavorite { + width: 48px; + height: 48px; + background: transparent; + border: 1px solid #B4BDC3; + display: flex; + border-radius: 50%; + justify-content: center; + align-items: center; + cursor: pointer; + transition: border-color 0.3s; + + &:hover { + border-color: $primary; + } +} + +.specsSummary { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 8px; + + .specRow { + display: flex; + justify-content: space-between; + } + + .specName { + color: $secondary; + } + .specValue { + color: $primary; + } +} + +.bottomSection { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 64px; + margin-top: 80px; + + @media (max-width: 640px) { + grid-template-columns: 1fr; + } +} + +.about { + h3 { + font-size: 24px; + margin-bottom: 32px; + border-bottom: 1px solid #E2E6E9; + padding-bottom: 16px; + } + + .aboutSection { + margin-bottom: 32px; + h4 { + font-size: 16px; + margin-bottom: 16px; + } + p { + color: $secondary; + font-size: 14px; + line-height: 1.6; + margin-bottom: 16px; + } + } +} + +.techSpecs { + h3 { + font-size: 24px; + margin-bottom: 32px; + border-bottom: 1px solid #E2E6E9; + padding-bottom: 16px; + } + + .specRow { + display: flex; + justify-content: space-between; + margin-bottom: 8px; + font-size: 14px; + } + + .specName { + color: $secondary + } + + .specValue { + color: $primary; + font-weight: 600; + } +} diff --git a/src/modules/ProductsDetailsPage/ProductDetailsPage.tsx b/src/modules/ProductsDetailsPage/ProductDetailsPage.tsx new file mode 100644 index 00000000000..5877aaa37b5 --- /dev/null +++ b/src/modules/ProductsDetailsPage/ProductDetailsPage.tsx @@ -0,0 +1,302 @@ +import { useState, useEffect } from 'react'; +import { useParams, useNavigate, Link } from 'react-router-dom'; +import classnames from 'classnames'; +import { ProductDetails } from '../../types/ProductDetails'; +import { getProductDetails, getSuggestedProducts } from '../../api/Products'; +import { Loader } from '../../components/Loader/Loader'; +import styles from './ProductDetailsPage.module.scss'; +import { Product } from '../../types/Product'; +import { useFavorites } from '../shared/context/FavoritesContext'; +import { useCart } from '../shared/context/CartContext'; +// eslint-disable-next-line max-len +import { ProductsSlider } from '../shared/components/ProductsSlider/ProductsSlider'; +import { Breadcrumbs } from '../../components/Breadcrumbs/Breadcrumbs'; + +export const ProductDetailsPage = () => { + const { productId } = useParams(); + const navigate = useNavigate(); + + const [product, setProduct] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [isError, setIsError] = useState(false); + const [selectedImage, setSelectedImage] = useState(null); + const [suggestedProducts, setSuggestedProducts] = useState([]); + const { addToFavorites, removeFromFavorites, isFavorite } = useFavorites(); + const { addToCart, cart } = useCart(); + + useEffect(() => { + if (!productId) { + return; + } + + setIsLoading(true); + + getProductDetails(productId) + .then(data => { + setProduct(data); + if (data.images && data.images.length > 0) { + setSelectedImage(data.images[0]); + } else { + setSelectedImage((data as unknown as Product).image || ''); + } + }) + .catch(() => { + setIsError(true); + }) + .finally(() => { + setIsLoading(false); + }); + getSuggestedProducts().then(setSuggestedProducts); + }, [productId]); + + if (isLoading) { + return ; + } + + if (isError || !product) { + return

Product was not found

; + } + + const images = product.images || []; + const colors = product.colorsAvailable || []; + const capacities = product.capacityAvailable || []; + const description = product.description || []; + const cell = product.cell || []; + + const getLink = (newColor: string, newCapacity: string) => { + const colorId = newColor.toLowerCase().replace(/ /g, '-'); + const capacityId = newCapacity.toLowerCase().replace(/ /g, '-'); + + return `/product/${product.namespaceId}-${capacityId}-${colorId}`; + }; + + const handleBack = () => { + if (window.history.state && window.history.state.idx > 0) { + navigate(-1); + } else { + const category = (product as unknown as Product)?.category || 'phones'; + + navigate(`/${category}`, { replace: true }); + } + }; + + const productToAdd: Product = { + id: product.id, + category: product.category, + phoneId: product.namespaceId, + itemId: product.id, + name: product.name, + fullPrice: product.priceRegular, + price: product.priceDiscount, + screen: product.screen, + capacity: product.capacity, + color: product.color, + ram: product.ram, + year: 0, + image: images[0] || '', + }; + + const isFav = isFavorite(productToAdd); + const isInCart = cart.some(item => item.id === product.id); + + const handleFavoriteClick = () => { + if (isFav) { + removeFromFavorites(productToAdd); + } else { + addToFavorites(productToAdd); + } + }; + + const handleAddToCart = () => { + if (!isInCart) { + addToCart(productToAdd); + } + }; + + return ( +
+ + + + +

{product.name}

+ +
+
+
+ {images.map(image => ( +
setSelectedImage(image)} + > + Thumbnail +
+ ))} +
+ +
+ {product.name} +
+
+ +
+
+ Available colors +
+ {colors.map(color => ( + + ))} +
+
+ +
+ Select capacity +
+ {capacities.map(capacity => ( + + {capacity} + + ))} +
+
+ +
+ + ${product.priceDiscount} + + ${product.priceRegular} +
+ +
+ + +
+ +
+
+ Screen + {product.screen} +
+
+ Resolution + {product.resolution} +
+
+ Processor + {product.processor} +
+
+ RAM + {product.ram} +
+
+
+
+ +
+
+

About

+ {description.map(section => ( +
+

{section.title}

+ {/* FIX: Handle text as an array of strings */} + {Array.isArray(section.text) ? ( + section.text.map(paragraph => ( +

+ {paragraph} +

+ )) + ) : ( +

{section.text}

+ )} +
+ ))} +
+ +
+

Tech specs

+
+ Screen + {product.screen} +
+
+ Resolution + {product.resolution} +
+
+ Processor + {product.processor} +
+
+ RAM + {product.ram} +
+
+ Built in memory + {product.capacity} +
+
+ Camera + {product.camera} +
+
+ Zoom + {product.zoom} +
+
+ Cell + {cell.join(', ')} +
+
+
+ +
+ +
+
+ ); +}; diff --git a/src/modules/ProductsPage/ProductsPage.module.scss b/src/modules/ProductsPage/ProductsPage.module.scss new file mode 100644 index 00000000000..28c86a56b6e --- /dev/null +++ b/src/modules/ProductsPage/ProductsPage.module.scss @@ -0,0 +1,144 @@ +@import '../../styles/utils'; + +.count { + font-family: Mont, sans-serif; + font-weight: 600; + font-size: 14px; + line-height: 21px; + letter-spacing: 0%; + color: $secondary; + margin-bottom: 40px; +} + +.page { + max-width: 1136px; + padding-bottom: 80px; +} + +.title { + font-family: Mont, sans-serif; + display: flex; + font-size: 48px; + font-weight: 800; + line-height: 56px; + letter-spacing: -1%; + color: $primary; + margin-bottom: 8px; +} + +.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(270px, 1fr)); + gap: 16px; + justify-items: center; + margin-bottom: 40px; + + @media (max-width: 1199px) { + grid-template-columns: repeat(auto-fill, minmax(237px, 1fr)); + } + + @media (max-width: 639px) { + grid-template-columns: repeat(auto-fill, minmax(212px, 1fr)); + } +} + +.controls { + display: flex; + gap: 16px; + margin-bottom: 24px; +} + +.controlBlock { + display: flex; + flex-direction: column; + gap: 4px; +} + +.label { + font-size: 12px; + font-weight: 700; + color: $secondary; +} + +.select { + height: 40px; + padding: 0 12px; + border: 1px solid #B4BDC3; + border-radius: 8px; + background-color: $white; + color: $primary; + font-family: inherit; + font-weight: 600; + font-size: 14px; + cursor: pointer; + outline: none; + min-width: 130px; + + &:hover { + border-color: $primary; + } + + &:focus { + border-color: $primary; + } +} + +.pagination { + display: flex; + justify-content: center; + gap: 8px; + align-items: center; +} + +.pageButton { + width: 32px; + height: 32px; + border: 1px solid #E2E6E9; + background-color: $white; + border-radius: 50%; + display: flex; + justify-content: center; + align-items: center; + cursor: pointer; + font-family: inherit; + font-size: 14px; + color: $primary; + transition: all 0.3s; + + &:disabled { + color: $secondary; + border-color: #E2E6E9; + cursor: default; + } + + &:hover:not(:disabled) { + border-color: $primary; + } + + &.active { + background-color: $primary; + color: $white; + border-color: $primary; + } +} + +.pageArrow { + width: 32px; + height: 32px; + border: 1px solid #E2E6E9; + background-color: $white; + border-radius: 50%; + display: flex; + justify-content: center; + align-items: center; + cursor: pointer; + + &:disabled { + cursor: default; + opacity: 0.5; + } + + &:hover:not(:disabled) { + border-color: $primary; + } +} diff --git a/src/modules/ProductsPage/ProductsPage.tsx b/src/modules/ProductsPage/ProductsPage.tsx new file mode 100644 index 00000000000..438bab35036 --- /dev/null +++ b/src/modules/ProductsPage/ProductsPage.tsx @@ -0,0 +1,183 @@ +import { useState, useEffect, useMemo } from 'react'; +import { useLocation, useSearchParams } from 'react-router-dom'; +import { Product } from '../../types/Product'; +import { getProducts } from '../../api/Products'; +import { ProductCard } from '../shared/components/ProductCard/ProductCard'; +import { Loader } from '../../components/Loader/Loader'; +import styles from './ProductsPage.module.scss'; +import classname from 'classnames'; +import { Breadcrumbs } from '../../components/Breadcrumbs/Breadcrumbs'; + +export const ProductsPage = () => { + const [products, setProducts] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [isError, setIsError] = useState(false); + const [searchParams, setSearchParams] = useSearchParams(); + + const location = useLocation(); + const category = location.pathname.slice(1); + + const sort = searchParams.get('sort') || 'age'; + const perPage = searchParams.get('perPage') || 'all'; + const currentPage = Number(searchParams.get('page')) || 1; + + useEffect(() => { + setIsLoading(true); + getProducts() + .then(allProducts => { + const filteredProducts = allProducts.filter( + product => product.category === category, + ); + + setProducts(filteredProducts); + }) + .catch(() => setIsError(true)) + .finally(() => setIsLoading(false)); + }, [category]); + + const sortedProducts = useMemo(() => { + return [...products].sort((a, b) => { + switch (sort) { + case 'age': + return b.year - a.year; + case 'title': + return a.name.localeCompare(b.name); + case 'price': + return a.price - b.price; + default: + return 0; + } + }); + }, [products, sort]); + + const totalItems = sortedProducts.length; + let visibleProducts = sortedProducts; + let totalPages = 1; + + if (perPage !== 'all') { + const limit = Number(perPage); + + totalPages = Math.ceil(totalItems / limit); + const start = (currentPage - 1) * limit; + const end = start + limit; + + visibleProducts = sortedProducts.slice(start, end); + } + + const handleSortChange = (event: React.ChangeEvent) => { + const params = new URLSearchParams(searchParams); + + params.set('sort', event.target.value); + params.set('page', '1'); + setSearchParams(params); + }; + + const handlePerPageChange = (event: React.ChangeEvent) => { + const params = new URLSearchParams(searchParams); + + params.set('perPage', event.target.value); + params.set('page', '1'); + setSearchParams(params); + }; + + const handlePageChange = (page: number) => { + const params = new URLSearchParams(searchParams); + + params.set('page', page.toString()); + setSearchParams(params); + window.scrollTo({ top: 0, behavior: 'smooth' }); + }; + + const pages = Array.from({ length: totalPages }, (_, i) => i + 1); + + return ( +
+ + {category === 'phones' &&

Mobile phones

} + {category === 'tablets' &&

Tablets

} + {category === 'accessories' && ( +

Accessories

+ )} +

{products.length} models

+ + {!isLoading && !isError && products.length > 0 && ( +
+
+ {/*eslint-disable-next-line jsx-a11y/label-has-associated-control*/} + + +
+ +
+ {/*eslint-disable-next-line jsx-a11y/label-has-associated-control*/} + + +
+
+ )} + + {isLoading && } + + {!isLoading && !isError && ( +
+ {visibleProducts.map(product => ( + + ))} +
+ )} + + {!isLoading && !isError && perPage !== 'all' && totalPages > 1 && ( +
+ + + {pages.map(page => ( + + ))} + + +
+ )} + + {products.length === 0 && !isLoading && !isError && ( +

There are no {category} yet

+ )} +
+ ); +}; diff --git a/src/modules/shared/components/ProductCard/ProductCard.module.scss b/src/modules/shared/components/ProductCard/ProductCard.module.scss new file mode 100644 index 00000000000..375af579c52 --- /dev/null +++ b/src/modules/shared/components/ProductCard/ProductCard.module.scss @@ -0,0 +1,162 @@ +@use "sass:color"; +@import '../../../../styles/utils'; + +.image { + width: 100%; + height: 100%; + object-fit: contain; + + transition: transform 0.3s ease-in-out; +} + +.card { + width: 100%; + height: 506px; + display: flex; + flex-direction: column; + align-items: center; + border: 1px solid #E2E6E9; + border-radius: 8px; + background-color: $white; + padding: 32px 0; + transition: box-shadow 0.3s ease-in-out; + + &:hover { + box-shadow: 0 2px 16px rgba(0, 0, 0, 0.1); + + .image { + transform: scale(1.1); + } + } +} + +.imageWrapper { + display: flex; + justify-content: center; + align-items: center; + width: 208px; + height: 196px; + margin-bottom: 24px; + + a { + display: flex; + width: 100%; + height: 100%; + justify-content: center; + align-items: center; + } +} + +.info { + display: flex; + flex-direction: column; + gap: 8px; +} + +.title { + width: 208px; + height: 58px; + font-size: 14px; + color: $primary; +} + +.priceContainer { + display: flex; + align-items: center; + justify-content: space-between; + width: 108px; + height: 31px; + gap: 8px; +} + +.price { + color: $primary; + font-size: 22px; + font-weight: 800; + line-height: 140%; + letter-spacing: 0%; +} + +.fullPrice { + color: $secondary; + font-weight: 600; + font-size: 22px; + line-height: 100%; + letter-spacing: 0%; + text-decoration: line-through; +} + +.divider { + width: 100%; + height: 1px; + background-color: #E2E6E9; + margin: 8px 0; +} + +.specs { + width: 208px; + height: 77px; + display: flex; + flex-direction: column; + justify-content: space-between; + gap: 8px; +} + +.spec { + align-items: center; + display: flex; + justify-content: space-between; + font-size: 12px; + + span:first-child { + color: $secondary; + } + + span:last-child { + color: #313237; + } +} + +.actions { + width: 208px; + height: 40px; + display: flex; + justify-content: space-between; + margin-top: 16px; +} + +.addToCart { + width: 160px; + height: 40px; + border-radius: 8px; + display: flex; + justify-content: center; + align-items: center; + background-color: $orange; + color: $white; + border: none; + font-weight: 700; + cursor: pointer; + transition: backgroundcolor 0.3s; + + &:hover { + background-color: color.adjust($orange, $lightness: 5%); + } +} + +.addToFavorite { + width: 40px; + height: 40px; + border: 1px solid #B4BDC3; + border-radius: 50%; + display: flex; + justify-content: center; + align-items: center; + background: transparent; + cursor: pointer; + transition: border-color 0.3s background-color 0.3s; + + &:hover { + border-color: $primary; + } +} diff --git a/src/modules/shared/components/ProductCard/ProductCard.tsx b/src/modules/shared/components/ProductCard/ProductCard.tsx new file mode 100644 index 00000000000..5edc235ee7a --- /dev/null +++ b/src/modules/shared/components/ProductCard/ProductCard.tsx @@ -0,0 +1,98 @@ +import React from 'react'; +import styles from './ProductCard.module.scss'; +import classname from 'classnames'; +import { Product } from '../../../../types/Product'; +import { Link } from 'react-router-dom'; +import { useFavorites } from '../../context/FavoritesContext'; +import { useCart } from '../../context/CartContext'; + +type Props = { + product: Product; +}; + +export const ProductCard: React.FC = ({ product }) => { + const { addToFavorites, removeFromFavorites, isFavorite } = useFavorites(); + const { addToCart, cart } = useCart(); + + const isFav = isFavorite(product); + const isInCart = cart.some(item => item.id === product.id); + + const handleFavoriteClick = () => { + if (isFav) { + removeFromFavorites(product); + } else { + addToFavorites(product); + } + }; + + const handleCartClick = () => { + if (!isInCart) { + addToCart(product); + } + }; + + return ( +
+
+ + {product.name} + +
+ +
+ +

{product.name}

+ + +
+ ${product.price} + ${product.fullPrice} +
+ +
+ +
+
+ Screen + {product.screen} +
+
+ Capacity + {product.capacity} +
+
+ RAM + {product.ram} +
+
+ +
+ + +
+
+
+ ); +}; diff --git a/src/modules/shared/components/ProductsSlider/ProductsSlider.module.scss b/src/modules/shared/components/ProductsSlider/ProductsSlider.module.scss new file mode 100644 index 00000000000..cc2dc042e75 --- /dev/null +++ b/src/modules/shared/components/ProductsSlider/ProductsSlider.module.scss @@ -0,0 +1,76 @@ +@import '../../../../styles/utils'; + +.slider { + margin-bottom: 80px; +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 24px; +} + +.title { + font-size: 32px; + font-weight: 800; + color: $primary; + margin: 0; +} + +.buttons { + display: flex; + gap: 16px; +} + +.button { + width: 32px; + height: 32px; + border: 1px solid #B4BDC3; + border-radius: 50%; + background: transparent; + display: flex; + justify-content: center; + align-items: center; + cursor: pointer; + transition: all 0.3s; + padding: 0; + + img { + width: 16px; + height: 16px; + object-fit: contain; + } + + &:hover:not(.disabled) { + border-color: $primary; + } + + &.disabled { + cursor: default; + opacity: 0.5; + border-color: #E2E6E9; + } +} + + +.frame { + overflow: hidden; + width: 100%; +} + +.list { + display: flex; + gap: 16px; + transition: transform 1s ease; + + > * { + flex-shrink: 0; + width: 272px; + } +} + +.cardWrapper { + flex-shrink: 0; + width: 272px; +} diff --git a/src/modules/shared/components/ProductsSlider/ProductsSlider.tsx b/src/modules/shared/components/ProductsSlider/ProductsSlider.tsx new file mode 100644 index 00000000000..33c56d6b3a4 --- /dev/null +++ b/src/modules/shared/components/ProductsSlider/ProductsSlider.tsx @@ -0,0 +1,96 @@ +import React, { useState } from 'react'; +import classNames from 'classnames'; +import styles from './ProductsSlider.module.scss'; +import { Product } from '../../../../types/Product'; +import { ProductCard } from '../ProductCard/ProductCard'; + +type Props = { + title: string; + products: Product[]; +}; + +export const ProductsSlider: React.FC = ({ title, products }) => { + const [step, setStep] = useState(0); + const itemWidth = 272; + const gap = 16; + const frameSize = 4; + const animationDuration = 1000; + const [isPrevHovered, setIsPrevHovered] = useState(false); + const [isNextHovered, setIsNextHovered] = useState(false); + + const stepWidth = itemWidth + gap; + const maxStep = products.length - frameSize; + const effectiveMaxStep = maxStep > 0 ? maxStep : 0; + + const handlePrev = () => { + setStep(prev => (prev > 0 ? prev - 1 : 0)); + }; + + const handleNext = () => { + setStep(prev => (prev < maxStep ? prev + 1 : prev)); + }; + + return ( +
+
+

{title}

+ +
+ + + +
+
+ +
+
+ {products.map(product => ( +
+ +
+ ))} +
+
+
+ ); +}; diff --git a/src/modules/shared/context/CartContext.tsx b/src/modules/shared/context/CartContext.tsx new file mode 100644 index 00000000000..3626e2ff13c --- /dev/null +++ b/src/modules/shared/context/CartContext.tsx @@ -0,0 +1,85 @@ +import { createContext, useContext, useMemo } from 'react'; +import { Product } from '../../../types/Product'; +import { CartItem } from '../../../types/CartItem'; +import { useLocalStorage } from '../../../hooks/useLocalStorage'; + +type CartContextType = { + cart: CartItem[]; + addToCart: (product: Product) => void; + removeFromCart: (productId: string) => void; + increaseQuantity: (productId: string) => void; + decreaseQuantity: (productId: string) => void; + clearCart: () => void; + totalCount: number; +}; + +const CartContext = createContext(undefined); + +export const CartProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const [cart, setCart] = useLocalStorage('cart', []); + + const removeFromCart = (productId: string) => { + setCart(cart.filter(item => item.id !== productId)); + }; + + const increaseQuantity = (productId: string) => { + setCart( + cart.map(item => + item.id === productId ? { ...item, quantity: item.quantity + 1 } : item, + ), + ); + }; + + const decreaseQuantity = (productId: string) => { + setCart( + cart.map(item => + item.id === productId ? { ...item, quantity: item.quantity - 1 } : item, + ), + ); + }; + + const addToCart = (product: Product) => { + const existingItem = cart.find(item => item.id === product.id); + + if (existingItem) { + increaseQuantity(product.id); + } else { + setCart([...cart, { id: product.id, quantity: 1, product }]); + } + }; + + const clearCart = () => { + setCart([]); + }; + + const totalCount = useMemo(() => { + return cart.reduce((total, item) => total + item.quantity, 0); + }, [cart]); + + const value = useMemo( + () => ({ + cart, + addToCart, + removeFromCart, + increaseQuantity, + decreaseQuantity, + clearCart, + totalCount, + }), + [cart, totalCount], + ); + + return {children}; +}; + +export const useCart = () => { + const context = useContext(CartContext); + + if (context === undefined) { + throw new Error('useCart must be used within a CartProvider'); + } + + return context; +}; diff --git a/src/modules/shared/context/FavoritesContext.tsx b/src/modules/shared/context/FavoritesContext.tsx new file mode 100644 index 00000000000..edec4906621 --- /dev/null +++ b/src/modules/shared/context/FavoritesContext.tsx @@ -0,0 +1,60 @@ +import React, { createContext, useContext, useMemo } from 'react'; +import { Product } from '../../../types/Product'; +import { useLocalStorage } from '../../../hooks/useLocalStorage'; + +type FavoritesContextType = { + favorites: Product[]; + addToFavorites: (product: Product) => void; + removeFromFavorites: (product: Product) => void; + isFavorite: (product: Product) => boolean; +}; + +const FavoritesContext = createContext( + undefined, +); + +export const FavoritesProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const [favorites, setFavorites] = useLocalStorage('favorites', []); + + const isFavorite = (product: Product) => { + return favorites.some(fav => fav.id === product.id); + }; + + const addToFavorites = (product: Product) => { + if (!isFavorite(product)) { + setFavorites([...favorites, product]); + } + }; + + const removeFromFavorites = (product: Product) => { + setFavorites(favorites.filter(fav => fav.id !== product.id)); + }; + + const value = useMemo( + () => ({ + favorites, + addToFavorites, + removeFromFavorites, + isFavorite, + }), + [favorites], + ); + + return ( + + {children} + + ); +}; + +export const useFavorites = () => { + const context = useContext(FavoritesContext); + + if (context === undefined) { + throw new Error('useFavorites must be used within a FavoritesProvider'); + } + + return context; +}; diff --git a/src/styles/fonts.scss b/src/styles/fonts.scss new file mode 100644 index 00000000000..2ea259d0c5a --- /dev/null +++ b/src/styles/fonts.scss @@ -0,0 +1,20 @@ +@font-face { + font-family: Mont; + src: url('/fonts/Mont-Regular.otf') format('opentype'); + font-weight: 400; + font-style: normal; +} + +@font-face { + font-family: Mont; + src: url('/fonts/Mont-SemiBold.otf') format('opentype'); + font-weight: 600; + font-style: normal; +} + +@font-face { + font-family: Mont; + src: url('/fonts/Mont-Bold.otf') format('opentype'); + font-weight: 800; + font-style: normal; +} diff --git a/src/styles/utils.scss b/src/styles/utils.scss new file mode 100644 index 00000000000..5a30535fd63 --- /dev/null +++ b/src/styles/utils.scss @@ -0,0 +1,22 @@ +$accent: #2196F3; +$primary: #313237; +$secondary: #89939A; +$white: #FFF; +$hover-bg: #FAFBFC; +$orange: #F86800; +$desktop-width: 1200px; +$col-gap: 16px; + +@mixin flex-center { + display: flex; + justify-content: center; + align-items: center; +} + +@mixin container { + max-width: $desktop-width; + width: 100%; + margin: 0 auto; + padding: 0 16px; + box-sizing: border-box; +} diff --git a/src/types/CartItem.ts b/src/types/CartItem.ts new file mode 100644 index 00000000000..e22f60a5541 --- /dev/null +++ b/src/types/CartItem.ts @@ -0,0 +1,7 @@ +import { Product } from './Product'; + +export interface CartItem { + id: string; + quantity: number; + product: Product; +} diff --git a/src/types/Product.ts b/src/types/Product.ts new file mode 100644 index 00000000000..afb02cd58c6 --- /dev/null +++ b/src/types/Product.ts @@ -0,0 +1,15 @@ +export interface Product { + id: string; + category: string; + phoneId: string; + itemId: string; + name: string; + fullPrice: number; + price: number; + screen: string; + capacity: string; + color: string; + ram: string; + year: number; + image: string; +} diff --git a/src/types/ProductDetails.ts b/src/types/ProductDetails.ts new file mode 100644 index 00000000000..8448d535ff7 --- /dev/null +++ b/src/types/ProductDetails.ts @@ -0,0 +1,24 @@ +export interface ProductDetails { + id: string; + category: string; + namespaceId: string; + name: string; + capacityAvailable: string[]; + capacity: string; + priceRegular: number; + priceDiscount: number; + colorsAvailable: string[]; + color: string; + images: string[]; + description: { + title: string; + text: string; + }[]; + screen: string; + resolution: string; + processor: string; + ram: string; + camera: string; + zoom: string; + cell: string[]; +} From 0f1b3bd9f2a8c80e5221dc8197924fba278d9856 Mon Sep 17 00:00:00 2001 From: Maksym Kolomiiets Date: Wed, 11 Mar 2026 00:17:00 +0100 Subject: [PATCH 2/4] add task solution --- public/img/icons/Favorites.svg | 3 +++ public/img/icons/Shoppingbag.svg | 5 ++++ public/img/logo/Logo.svg | 25 +++++++++++++++++++ src/components/Footer/Footer.tsx | 2 +- src/components/Header/Header.module.scss | 5 ++++ src/components/Header/Header.tsx | 6 ++--- .../ProductDetailsPage.module.scss | 9 ++++++- .../ProductCard/ProductCard.module.scss | 10 ++++++++ 8 files changed, 60 insertions(+), 5 deletions(-) create mode 100644 public/img/icons/Favorites.svg create mode 100644 public/img/icons/Shoppingbag.svg create mode 100644 public/img/logo/Logo.svg diff --git a/public/img/icons/Favorites.svg b/public/img/icons/Favorites.svg new file mode 100644 index 00000000000..ca57cfedd8a --- /dev/null +++ b/public/img/icons/Favorites.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/img/icons/Shoppingbag.svg b/public/img/icons/Shoppingbag.svg new file mode 100644 index 00000000000..6030970f2e9 --- /dev/null +++ b/public/img/icons/Shoppingbag.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/img/logo/Logo.svg b/public/img/logo/Logo.svg new file mode 100644 index 00000000000..44e264fcc9e --- /dev/null +++ b/public/img/logo/Logo.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/Footer/Footer.tsx b/src/components/Footer/Footer.tsx index 626050215b3..edcedd1d1b5 100644 --- a/src/components/Footer/Footer.tsx +++ b/src/components/Footer/Footer.tsx @@ -14,7 +14,7 @@ export const Footer = () => {
- Logo + Logo
diff --git a/src/components/Header/Header.module.scss b/src/components/Header/Header.module.scss index 70906ca0df1..121a0058376 100644 --- a/src/components/Header/Header.module.scss +++ b/src/components/Header/Header.module.scss @@ -118,6 +118,11 @@ $header-height-mobile: 48px; align-items: center; width: 100%; height: 100%; + + @media (max-width: 640px) { + width: 30%; + height: 80%; + } } .iconBadge { diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx index 64f596a93bf..20e2fad67e3 100644 --- a/src/components/Header/Header.tsx +++ b/src/components/Header/Header.tsx @@ -34,7 +34,7 @@ export const Header = () => {
- SiteLogo.png + SiteLogo.png